Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/upstream-pi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,34 @@
"remote": "https://github.com/earendil-works/pi.git",
"branch": "main",
"lastReviewedSha": "7e94d36a44479326a6b2ee4227fc41f10ea978aa",
"docsSource": "packages/coding-agent/docs"
"docsSource": "packages/coding-agent/docs",
"packagePolicy": {
"bundlePackage": "@casemark/linc",
"sourceUpstream": "https://github.com/earendil-works/pi.git",
"packages": {
"agent-core": {
"upstreamPath": "packages/agent",
"localPath": "packages/agent",
"lincSubpath": "@casemark/linc/agent-core",
"policy": "track-upstream-unmodified"
},
"ai": {
"upstreamPath": "packages/ai",
"localPath": "packages/ai",
"lincSubpath": "@casemark/linc/ai",
"policy": "track-upstream-with-linc-auth-provider-delta"
},
"tui": {
"upstreamPath": "packages/tui",
"localPath": "packages/tui",
"lincSubpath": "@casemark/linc/tui",
"policy": "track-upstream-with-linc-theme-provider-delta"
},
"web-ui": {
"upstreamPath": "packages/web-ui",
"localPath": "packages/web-ui",
"policy": "track-upstream-with-linc-web-ui-delta"
}
}
}
}
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [main]
branches: [main, dev]
pull_request:
branches: [main]
branches: [main, dev]

concurrency:
group: ci-${{ github.ref }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/npm-publish-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ name: NPM Publish Check

on:
pull_request:
branches: [main]
branches: [main, dev]
paths:
- "package.json"
- "package-lock.json"
- "packages/**"
- "scripts/**"
- ".github/workflows/npm-publish-check.yml"
push:
branches: [main]
branches: [main, dev]
paths:
- "package.json"
- "package-lock.json"
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/models.generated.ts",
"!**/.claude/**/*",
"!packages/web-ui/src/app.css",
"!packages/mom/data/**/*",
"!!**/node_modules"
Expand Down
2 changes: 2 additions & 0 deletions docs/linc/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ npm run docs:preview
```

Linc is based on Pi and selectively tracks upstream changes while keeping case.dev as the provider path.

For release mechanics, see [Release](release.md).
50 changes: 50 additions & 0 deletions docs/linc/release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Release

Linc uses `dev` as the stable pre-release branch and `main` as the release branch.

## Branch Contract

- Develop on feature branches.
- Merge feature branches into `dev`.
- Keep `dev` green and usable for pre-release validation.
- Promote `dev` to `main` only when the current pre-release set is ready to publish.
- Keep `main` releasable at all times.
- Do not merge `pi/main` directly into Linc. Review upstream with `npm run upstream:pi`, then port selected changes explicitly.

## Release Pipeline

The intended release path is branch promotion plus GitHub Actions:

1. Merge feature branches into `dev`.
2. Run checks and pre-release smoke from `dev`.
3. Promote `dev` to `main` with a normal merge when ready to publish.
4. Run the `Release Prep` workflow on `main` with `patch`, `minor`, `major`, or an exact version.
5. The workflow runs `scripts/prepare-release.mjs`, updates workspace versions, syncs package versions, promotes changelog `Unreleased` sections, runs `npm run check`, commits, and tags `vX.Y.Z`.
6. The `NPM Publish` workflow runs from the tag and publishes the public packages.
7. The `NPM Publish Check` workflow dry-runs package contents on PRs and pushes when the local version is not already published.

The local `scripts/release.mjs` path is legacy. Prefer the GitHub workflow so versioning, tagging, and npm publishing happen from a clean `main` checkout.

## Published Packages

The bundle package is `@casemark/linc`. It exposes selected package surfaces under Linc subpaths:

- `@casemark/linc/agent-core`
- `@casemark/linc/ai`
- `@casemark/linc/ai/oauth`
- `@casemark/linc/tui`

Linc still carries the internal workspace packages because upstream Pi is the source of truth for most implementation code.

## case.dev Runtime Coupling

case.dev `/linc/v1` does not consume the local worktree. It consumes the published `@casemark/linc` package pinned in the case.dev Linc runtime image.

Before bumping that pin in case.dev:

1. Confirm `linc --mode rpc --provider casedev --model <model>` still starts.
2. Confirm RPC command bodies remain native Pi/Linc JSON and are accepted unchanged.
3. Confirm event frame names stay compatible with C3: `message_update`, `message_end`, `turn_end`, `agent_end`, and `tool_execution_*`.
4. Confirm `turn_end` with `stopReason: "toolUse"` is not treated as final completion by downstream clients.
5. Confirm headless auth still honors `CASEDEV_API_KEY`.
6. Smoke case.dev preview and C3 before promoting the case.dev runtime image or package pin.
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Added first-class CaseMark Core and case.dev OAuth provider exports for Linc authentication.

## [0.2.0] - 2026-05-15

### Fixed
Expand Down
99 changes: 99 additions & 0 deletions packages/ai/src/utils/oauth/casedev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";

const env = typeof process === "undefined" ? undefined : process.env;
const CASEDEV_API_BASE = env?.CASEDEV_API_BASE_URL || "https://api.case.dev";
const NEVER_EXPIRES = 253402300799000;

interface DeviceFlowStartResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete: string;
interval: number;
expiresIn: number;
expiresAt: string;
}

interface DeviceFlowPollResponse {
error?: string;
interval?: number;
tokenType?: string;
apiKey?: string;
expiresAt?: string | null;
scope?: { services: Array<{ service: string; scopes: string[] }> };
}

export async function loginCasedev(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
callbacks.onProgress?.("Starting case.dev device authorization...");

const startRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scopes: { services: [{ service: "all", scopes: ["read", "write"] }] },
}),
signal: callbacks.signal,
});

if (!startRes.ok) {
throw new Error(`Failed to start case.dev device flow: ${startRes.status} ${startRes.statusText}`);
}

const start = (await startRes.json()) as DeviceFlowStartResponse;
callbacks.onAuth({
url: start.verificationUriComplete,
instructions: `Approve the code ${start.userCode}.`,
});
callbacks.onProgress?.("Waiting for approval...");

const pollInterval = (start.interval || 3) * 1000;
const expiresAt = new Date(start.expiresAt).getTime();

while (Date.now() < expiresAt) {
if (callbacks.signal?.aborted) {
throw new Error("Login cancelled");
}

await new Promise((resolve) => setTimeout(resolve, pollInterval));

const pollRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode: start.deviceCode }),
signal: callbacks.signal,
});

if (pollRes.status === 429 || pollRes.status === 202) {
continue;
}

if (pollRes.status === 200) {
const result = (await pollRes.json()) as DeviceFlowPollResponse;
if (result.apiKey) {
return {
refresh: "",
access: result.apiKey,
expires: NEVER_EXPIRES,
};
}
}

if (pollRes.status === 403 || pollRes.status === 410) {
throw new Error("case.dev authorization denied or expired");
}
}

throw new Error("case.dev authorization timed out");
}

export const casedevOAuthProvider: OAuthProviderInterface = {
id: "casedev",
name: "case.dev",
login: loginCasedev,
async refreshToken(credentials) {
return credentials;
},
getApiKey(credentials) {
return credentials.access;
},
};
153 changes: 153 additions & 0 deletions packages/ai/src/utils/oauth/casemark-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";

const env = typeof process === "undefined" ? undefined : process.env;
const CORE_API_BASE = env?.CORE_API_BASE_URL || "https://core.case.dev";
const CORE_OAUTH_CLIENT_ID = env?.LINC_CORE_OAUTH_CLIENT_ID || "linc";
const CORE_OAUTH_SCOPE = env?.LINC_CORE_OAUTH_SCOPE || "core:chat";

interface CoreDeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
interval?: number;
expires_in: number;
}

interface CoreTokenResponse {
access_token?: string;
refresh_token?: string;
token_type?: string;
expires_in?: number;
scope?: string;
error?: string;
error_description?: string;
}

async function readJsonSafe<T>(response: Response): Promise<T | null> {
try {
return (await response.json()) as T;
} catch {
return null;
}
}

function toCredentials(result: CoreTokenResponse): OAuthCredentials {
if (!result.access_token) {
throw new Error("Core OAuth response did not include an access token");
}

return {
refresh: result.refresh_token || "",
access: result.access_token,
expires: Date.now() + Math.max(1, result.expires_in || 3600) * 1000,
scope: result.scope,
tokenType: result.token_type,
};
}

export async function loginCasemarkCore(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
callbacks.onProgress?.("Starting CaseMark Core device authorization...");

const startRes = await fetch(`${CORE_API_BASE}/oauth/device/code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: CORE_OAUTH_CLIENT_ID,
scope: CORE_OAUTH_SCOPE,
}),
signal: callbacks.signal,
});

const start = await readJsonSafe<CoreDeviceCodeResponse>(startRes);
if (!startRes.ok || !start?.device_code || !start.verification_uri_complete) {
throw new Error(`Failed to start Core device flow: ${startRes.status} ${startRes.statusText}`);
}

callbacks.onAuth({
url: start.verification_uri_complete,
instructions: `Approve the code ${start.user_code}.`,
});
callbacks.onProgress?.("Waiting for approval...");

let pollIntervalMs = Math.max(1, start.interval || 5) * 1000;
const expiresAt = Date.now() + start.expires_in * 1000;

while (Date.now() < expiresAt) {
if (callbacks.signal?.aborted) {
throw new Error("Login cancelled");
}

await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));

const pollRes = await fetch(`${CORE_API_BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: CORE_OAUTH_CLIENT_ID,
device_code: start.device_code,
}),
signal: callbacks.signal,
});

const result = await readJsonSafe<CoreTokenResponse>(pollRes);
if (pollRes.ok && result?.access_token) {
return toCredentials(result);
}

const errorCode = result?.error;
if (!errorCode || errorCode === "authorization_pending") {
continue;
}
if (errorCode === "slow_down") {
pollIntervalMs += 1000;
continue;
}
if (errorCode === "access_denied" || errorCode === "invalid_grant") {
throw new Error("Core authorization denied or expired");
}

const description = result?.error_description ? `: ${result.error_description}` : "";
throw new Error(`Core OAuth error: ${errorCode}${description}`);
}

throw new Error("Core authorization timed out");
}

export async function refreshCasemarkCoreToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
if (!credentials.refresh) {
throw new Error("Core OAuth credentials do not include a refresh token");
}

const res = await fetch(`${CORE_API_BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: CORE_OAUTH_CLIENT_ID,
refresh_token: credentials.refresh,
}),
});

const result = await readJsonSafe<CoreTokenResponse>(res);
if (!res.ok || !result?.access_token) {
throw new Error(`Failed to refresh Core OAuth token: ${res.status} ${res.statusText}`);
}

return {
...credentials,
...toCredentials(result),
refresh: result.refresh_token || credentials.refresh,
};
}

export const casemarkCoreOAuthProvider: OAuthProviderInterface = {
id: "casemark-core",
name: "CaseMark Core",
login: loginCasemarkCore,
refreshToken: refreshCasemarkCoreToken,
getApiKey(credentials) {
return credentials.access;
},
};
Loading
Loading