Skip to content
Merged
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
25 changes: 25 additions & 0 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,31 @@ SDK core exposes two related provider-neutral capabilities:
rotation, and revokes stale capabilities. Provider-facing attachments expose
only `sessionId`, `generation`, `isCurrent()`, and `send()`.

### Dispatch-boundary observers (managed router path)

`SessionRouter.request(sessionId, frame, expectedGeneration?, expectedAttachment?, options)`
accepts two optional synchronous observers in `options` — the supported
dispatch-boundary surface for transport-close-aware consumers (#4640):

- **`beforeDispatch(context)`** — fires immediately before the wire write.
Throwing (or any synchronous failure) aborts the dispatch with nothing on the
wire, no sent record, and a retryable rejection carrying the caller's own
error. Returning a thenable (e.g. an `async` function) is a contract
violation: the dispatch aborts pre-send and the eventual rejection is sunk.
- **`onDispatch(context)`** — fires synchronously immediately after the frame is
handed to the socket, never before. `context.frame.id` is the exact correlated
identity a response must carry; from this point a transport close before the
response settles the request as `uncertain_after_send`. Observer throws and
returned-thenable rejections are sunk; they can neither displace settlement
nor reach the process unhandled-rejection channel.

The observer `context.frame` is a **deep-frozen, credential-redacted copy**: the
injected session endpoint `token` (and any other credential field) exists only
on the internal wire frame and is never handed to observer code, and mutation
attempts throw in strict mode. The raw credential-bearing `SdkClient` remains
unexported (`./sdk/client` is blocked in the package export map); the router is
the only supported path to this boundary.

There is no daemon-owned lifecycle control endpoint, provider lifecycle ledger,
notification-root scanner, or provider-created SessionId. Telegram `/session_*`
commands call the SDK lifecycle service directly. A Telegram update or topic
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@

- Fixed Telegram forum topics freezing after the identity header: an attached, trusted session whose topic-host lease expired (20 s `HEARTBEAT_TTL_MS`) could never renew it, because `renewActiveTopicLeases` only renewed sessions that already passed the trusted-lease gate, so every later `turn_stream`/`context_update`/tool frame was rejected pre-send with "trusted attachment lease is stale" and the topic never updated again (#4647). A live attachment that still owns its exact logical session and holds an authorized recovery lease may now re-arm its own expired host lease — from the ownership heartbeat and once more before the publication gate — mirroring `acquireLease` admission (expired-but-owned active lease, or a same-owner resume inside the disconnect-grace window, which also covers the incident's persisted `disconnect_grace` record). Dropped sessions, closed endpoints, foreign lease owners, archive-fenced/inactive topics, malformed bindings, and cross-session ownership checks all still fail closed. Daemon generation bumped 169→170.
- A tool call the agent loop refuses now reports why it was refused. The loop attaches its own failure envelope (`{ failureKind }`) in place of the tool's details, and the TUI still dispatched that envelope at the tool's renderer, which owns only its own detail shape: `search_tool_bm25` threw on `details.tools`, `task` printed `Task result details unavailable`, `resolve` printed `Failed: pending action`, and `write` painted its success card — in every case the rejection text (for example the `\uXXXX`-escaped-arguments rejection that ends a Korean `task` call) never reached the screen. Such a result now renders the same error card a tool without a renderer already produces: the failed status line plus the reason. Results a tool produced itself keep their renderer, including `todo_write`'s own `failureKind`.
- `SdkClient` requests accept `beforeDispatch`/`onDispatch` boundary callbacks (`SdkRequestOptions`), giving dispatch-aware consumers a synchronous post-send boundary without owning the raw transport lifecycle (#4640). `onDispatch` fires immediately after the frame is handed to the socket — never before — with the exact request identity (`frame.id`), `connectionId`, and transport generation; a throwing observer cannot displace settlement, so the request still settles through its response, deadline, or `uncertain_after_send` retirement on transport close. `beforeDispatch` runs before the write and its throw aborts the dispatch with nothing on the wire (no sent record, caller's own error, retryable). This replaces the only previous alternative — a raw `send()` + `onFrame()` request that could never settle on a close after handoff and waited for its own timeout — while keeping pending-request ownership inside the client.
- Discovered oMLX models now keep thinking metadata (`reasoning: true`, `supportsReasoningEffort`, `thinkingFormat: qwen-chat-template`) so `macos-omlx-*` role suffixes (`:low`/`:medium`/`:high`) survive clamp and reach oMLX as `chat_template_kwargs.reasoning_effort`.
- Added built-in `MACOS LOCAL (OMLX)` model profiles (`macos-omlx-fast`, `macos-omlx-balanced`, `macos-omlx-quality`, `macos-omlx-abliterated-fast`, `macos-omlx-abliterated-balanced`) for oMLX local inference on Apple Silicon Macs with native full context support and single-LLM thinking effort role mappings to eliminate model swap latency.
- Fixed an HTTP 400 that killed every deep-interview session on the `google-antigravity` provider before the first assistant turn. The Round-0 topology `ask` schema pinned `round` with `z.literal(0)`, which zod serializes as `const: 0` and the Cloud Code Assist normalizer rewrites to a numeric `enum: [0]` — a shape CCA rejects (`TYPE_STRING`). `round` is now pinned with an integer range `[0, 0]` instead, so the wire schema carries `type: integer` with the bounds spilled into the description (the same treatment `ambiguity` already gets) and no numeric enum remains. Runtime contract unchanged: only `0` validates (#4606).
Expand Down
12 changes: 7 additions & 5 deletions packages/coding-agent/scripts/build-sdk-package-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ function run(command: string[], cwd: string): string {
if (result.exitCode !== 0) {
throw new Error(`${command.join(" ")} failed:\n${new TextDecoder().decode(result.stderr)}`);
}
return new TextDecoder().decode(result.stdout).trim();
const lines = new TextDecoder().decode(result.stdout).trim().split(/\r?\n/);
return lines.at(-1) ?? "";
}

function assertExport(module: Record<string, unknown>, name: string, subpath: string): void {
Expand Down Expand Up @@ -95,9 +96,10 @@ async function runSmoke(): Promise<Surface> {
// Install the matching packed workspace artifacts so the smoke test exercises the
// release dependency boundary without falling back to an older registry package.
run(["bun", "install", "--ignore-scripts"], tempDir);
const installedPackage = JSON.parse(
await fs.readFile(path.join(tempDir, "node_modules", packageName, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
const stagedPackageJsonPath = path.join(tempDir, "node_modules", packageName, "package.json");
const installedPackage = JSON.parse(await fs.readFile(stagedPackageJsonPath, "utf8")) as {
exports?: Record<string, unknown>;
};
const installedAgentPackagePath = path.join(tempDir, "node_modules", "@gajae-code", "agent-core");
const installedAgentPackageJsonPath = path.join(installedAgentPackagePath, "package.json");
const installedAgentPackage = JSON.parse(await fs.readFile(installedAgentPackageJsonPath, "utf8")) as {
Expand Down Expand Up @@ -150,7 +152,7 @@ async function runSmoke(): Promise<Surface> {
const probePath = path.join(tempDir, "probe.ts");
await fs.writeFile(
probePath,
`import * as fs from "node:fs/promises";\nimport * as path from "node:path";\nimport * as root from ${JSON.stringify(packageName)};\nimport * as sdk from ${JSON.stringify(`${packageName}/sdk`)};\nimport * as bus from ${JSON.stringify(`${packageName}/sdk/bus`)};\nconst required = [[root, "createAgentSession", "root"], [root, "SESSION_DIRECTORY_API_VERSION", "root"], [root, "resolveManagedSessionScope", "root"], [root, "listManagedSessionCandidates", "root"], [sdk, "createAgentSession", "sdk"], [bus, "createNotificationsExtension", "sdk/bus"], [sdk, "SESSION_DIRECTORY_API_VERSION", "sdk"], [sdk, "resolveManagedSessionScope", "sdk"], [sdk, "listManagedSessionCandidates", "sdk"]] as const;\nfor (const [module, name, subpath] of required) if (!(name in module)) throw new Error(subpath + " missing " + name);\nconst sandbox = path.join(process.cwd(), "managed-listing-smoke");\nconst cwd = path.join(sandbox, "workspace", "a-b", "c");\nconst agentDir = path.join(sandbox, "agent");\nconst sessionsRoot = path.join(agentDir, "sessions");\nawait fs.mkdir(cwd, { recursive: true });\nconst resolved = await sdk.resolveManagedSessionScope({ cwd, agentDir, sessionsRoot });\nif (resolved.kind !== "resolved") throw new Error("packed resolver failed: " + resolved.message);\nawait fs.mkdir(resolved.scope.directoryPath, { recursive: true, mode: 0o700 });\nawait fs.chmod(sessionsRoot, 0o700);\nawait fs.chmod(resolved.scope.directoryPath, 0o700);\nawait fs.writeFile(path.join(resolved.scope.directoryPath, ".gjc-managed-session-scope.v2.json"), JSON.stringify({ schemaVersion: 1, layoutVersion: 2, identityVersion: 1, platform: process.platform === "win32" ? "win32" : "posix", canonicalPath: resolved.scope.canonicalCwd, identityDigest: resolved.scope.directoryName.slice(3) }) + "\\n", { mode: 0o600 });\nconst transcriptPath = path.join(resolved.scope.directoryPath, "packed-session.jsonl");\nawait fs.writeFile(transcriptPath, JSON.stringify({ type: "session", id: "packed-session", cwd }) + "\\n", { mode: 0o600 });\nconst snapshot = async () => Promise.all((await fs.readdir(sandbox, { recursive: true })).sort().map(async name => { const pathname = path.join(sandbox, name); const stat = await fs.lstat(pathname); return [name, stat.mode, stat.size, stat.mtimeMs, stat.isFile() ? await fs.readFile(pathname, "utf8") : null]; }));\nconst before = JSON.stringify(await snapshot());\nconst listing = await sdk.listManagedSessionCandidates({ scope: resolved.scope });\nif (listing.kind !== "complete" || listing.owned.length !== 1 || listing.owned[0]?.sessionId !== "packed-session") throw new Error("packed readonly listing failed: " + JSON.stringify(listing));\nconst after = JSON.stringify(await snapshot());\nif (after !== before) throw new Error("packed readonly listing mutated the filesystem");\nconst privateSubpath = ${JSON.stringify(`${packageName}/session/internal/managed-session-scope`)};\ntry { await import(privateSubpath); throw new Error("private managed-session scope subpath resolved"); } catch (error) {\n\tif (error instanceof Error && error.message === "private managed-session scope subpath resolved") throw error;\n\tconst message = String(error);\n\tconst exportsRejected = /Package subpath .* is not defined by "exports"/.test(message);\n\tconst bunRejected = message.startsWith("ResolveMessage: Cannot find module '" + privateSubpath + "' from '") && message.endsWith("/probe.ts'");\n\tif (!exportsRejected && !bunRejected) throw new Error("private managed-session scope failed for an unexpected reason: " + message);\n}\nprocess.stdout.write(JSON.stringify({ root: Object.keys(root).sort(), sdk: Object.keys(sdk).sort() }));\n`,
`import * as fs from "node:fs/promises";\nimport * as path from "node:path";\nimport * as root from ${JSON.stringify(packageName)};\nimport * as sdk from ${JSON.stringify(`${packageName}/sdk`)};\nimport * as bus from ${JSON.stringify(`${packageName}/sdk/bus`)};\nconst required = [[root, "createAgentSession", "root"], [root, "SESSION_DIRECTORY_API_VERSION", "root"], [root, "resolveManagedSessionScope", "root"], [root, "listManagedSessionCandidates", "root"], [sdk, "createAgentSession", "sdk"], [bus, "createNotificationsExtension", "sdk/bus"], [sdk, "SESSION_DIRECTORY_API_VERSION", "sdk"], [sdk, "resolveManagedSessionScope", "sdk"], [sdk, "listManagedSessionCandidates", "sdk"]] as const;\nfor (const [module, name, subpath] of required) if (!(name in module)) throw new Error(subpath + " missing " + name);\nconst sandbox = path.join(process.cwd(), "managed-listing-smoke");\nconst cwd = path.join(sandbox, "workspace", "a-b", "c");\nconst agentDir = path.join(sandbox, "agent");\nconst sessionsRoot = path.join(agentDir, "sessions");\nawait fs.mkdir(cwd, { recursive: true });\nconst resolved = await sdk.resolveManagedSessionScope({ cwd, agentDir, sessionsRoot });\nif (resolved.kind !== "resolved") throw new Error("packed resolver failed: " + resolved.message);\nawait fs.mkdir(resolved.scope.directoryPath, { recursive: true, mode: 0o700 });\nawait fs.chmod(sessionsRoot, 0o700);\nawait fs.chmod(resolved.scope.directoryPath, 0o700);\nawait fs.writeFile(path.join(resolved.scope.directoryPath, ".gjc-managed-session-scope.v2.json"), JSON.stringify({ schemaVersion: 1, layoutVersion: 2, identityVersion: 1, platform: process.platform === "win32" ? "win32" : "posix", canonicalPath: resolved.scope.canonicalCwd, identityDigest: resolved.scope.directoryName.slice(3) }) + "\\n", { mode: 0o600 });\nconst transcriptPath = path.join(resolved.scope.directoryPath, "packed-session.jsonl");\nawait fs.writeFile(transcriptPath, JSON.stringify({ type: "session", id: "packed-session", cwd }) + "\\n", { mode: 0o600 });\nconst snapshot = async () => Promise.all((await fs.readdir(sandbox, { recursive: true })).sort().map(async name => { const pathname = path.join(sandbox, name); const stat = await fs.lstat(pathname); return [name, stat.mode, stat.size, stat.mtimeMs, stat.isFile() ? await fs.readFile(pathname, "utf8") : null]; }));\nconst before = JSON.stringify(await snapshot());\nconst listing = await sdk.listManagedSessionCandidates({ scope: resolved.scope });\nif (listing.kind !== "complete" || listing.owned.length !== 1 || listing.owned[0]?.sessionId !== "packed-session") throw new Error("packed readonly listing failed: " + JSON.stringify(listing));\nconst after = JSON.stringify(await snapshot());\nif (after !== before) throw new Error("packed readonly listing mutated the filesystem");\nconst privateSubpath = ${JSON.stringify(`${packageName}/session/internal/managed-session-scope`)};\ntry { await import(privateSubpath); throw new Error("private managed-session scope subpath resolved"); } catch (error) {\n\tif (error instanceof Error && error.message === "private managed-session scope subpath resolved") throw error;\n\tconst message = String(error);\n\tconst exportsRejected = /Package subpath .* is not defined by "exports"/.test(message);\n\tconst bunRejected = (message.startsWith("ResolveMessage: Cannot find module '" + privateSubpath + "' from '") && message.endsWith("/probe.ts'")) || (message.startsWith("ResolveMessage: Cannot find package '" + ${JSON.stringify(packageName)} + "' imported from ") && message.endsWith("/probe.ts"));\n\tif (!exportsRejected && !bunRejected) throw new Error("private managed-session scope failed for an unexpected reason: " + message);\n}\nprocess.stdout.write(JSON.stringify({ root: Object.keys(root).sort(), sdk: Object.keys(sdk).sort() }));\n`,
);
await fs.appendFile(
probePath,
Expand Down
Loading
Loading