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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { spawn } from "node:child_process";
import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
Expand Down Expand Up @@ -114,6 +114,45 @@ function listFiles(root) {
return files;
}

function persistedBindingShape(files) {
const sessionFile = files.find((path) => path.endsWith(".jsonl"));
const sidecarFile = files.find((path) => path.endsWith(".claude-sdk-oauth-binding.json"));
let sidecar;
let branch = [];
try {
sidecar = sidecarFile ? JSON.parse(readFileSync(sidecarFile, "utf8")) : undefined;
} catch {
sidecar = { malformed: true };
}
try {
branch = sessionFile
? readFileSync(sessionFile, "utf8")
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((entry) => entry.type !== "session")
.map((entry) => ({
id: entry.id,
type: entry.type,
customType: entry.customType,
role: entry.message?.role,
}))
: [];
} catch {
branch = [{ malformed: true }];
}
return {
sidecar: sidecar
? {
sessionPath: sidecar.sessionPath,
sessionId: sidecar.sessionId,
markerEntryId: sidecar.markerEntryId,
}
: null,
branch,
};
}

const common = (sessionDir) => [
"-p",
"--provider",
Expand Down Expand Up @@ -148,25 +187,31 @@ try {
const firstContinuity = continuityFrom(first.stdout);
const secondContinuity = continuityFrom(second.stdout);
const sessionFiles = listFiles(stack.box.sessionDir);
const resumed = secondContinuity.some(
(observation) => observation.kind === "fork" && observation.reason === "registry_miss",
const deltaOnlyResume = secondContinuity.some(
(observation) => observation.reason === "registry_miss" && observation.deltaMessages === 1,
);
const flattened = secondContinuity.some(
(observation) => observation.kind === "flatten" || observation.kind === "bootstrap",
);
const replayedHistory = secondContinuity.some(
(observation) =>
typeof observation.deltaMessages === "number" && observation.deltaMessages > 1,
);
const passed =
first.code === 0 &&
second.code === 0 &&
sessionFiles.length > 0 &&
stack.providerRequests.length >= 2 &&
resumed &&
!flattened;
deltaOnlyResume &&
!flattened &&
!replayedHistory;
summary = {
passed,
first: { code: first.code, continuity: firstContinuity },
second: { code: second.code, continuity: secondContinuity },
sessionFileCount: sessionFiles.length,
providerRequests: stack.providerRequests.length,
...(!passed ? { persistedBinding: persistedBindingShape(sessionFiles) } : {}),
stderr: {
first: [
...first.stderr.split("\n").filter(Boolean).slice(0, 5),
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
### Fixed

- Published Senpi tarballs now retain the lockfile-recorded Babel 8 dependency closure inside the bundled codemode sidecar, preventing `@babel/parser` resolution failures during extension startup ([#923](https://github.com/code-yeongyu/senpi/issues/923)).
- Headless Claude SDK OAuth continuation now restores its persisted SDK binding across separate CLI processes, so
`-p -c` resumes the existing lineage instead of resending the full conversation after a `registry_miss`.
- Headless Claude SDK OAuth continuation now restores a bounded SDK lineage from a private sidecar only after its
session marker, committed assistant, identity, and local SDK transcript all verify, so `-p -c` sends only the new
turn while imports, forks, provider switches, rewrites, compaction, malformed state, and drift fail closed.

- Claude SDK OAuth now selects the glibc Claude Code binary before the musl variant on glibc Linux hosts and when libc detection is unavailable, while retaining musl-first selection on detected musl hosts and fallback to either installed package ([code-yeongyu/oh-my-openagent#6963](https://github.com/code-yeongyu/oh-my-openagent/issues/6963)).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Generated: 2026-08-07 | Commit: `4f26b8282`
| `session-stream.ts` | Resident-lane attempts (`createResidentAttempt`), flatten serialization + directive dedupe |
| `session-continuity.ts` | `decideNativeContinuity` decision table: `delta` / `reattach` / `fork` / `flatten` / `bootstrap` |
| `session-registry.ts` | Resident SDK query registry: idle reaping, eviction, state transitions (with `session-registry-state/pump/wiring.ts`) |
| `session-binding.ts` | Branch-local binding checkpoint for restart-time resume verification |
| `session-binding.ts` | Branch marker + committed-assistant anchor for trusted restart bindings |
| `session-binding-store.ts` | Strict, private, fixed-size sidecar that owns persisted SDK lineage capabilities |
| `session-commit-boundary.ts` | `message_end` commit boundary; divergence decided against the SDK ledger, not in-flight staging |
| `session-observability.ts` | `ContinuityObservation` (kind, reason, delta count, `payloadBytes`, `collapsedDirectives`), `session.log` events |
| `system-prompt.ts` | `systemPromptMode` handling (`full` default, `preset-append` deprecated, `override` from file); no array-splitting, the CLI joins arrays |
Expand All @@ -30,7 +31,7 @@ Generated: 2026-08-07 | Commit: `4f26b8282`

## INVARIANTS (from changes.md)

- Resume-first: every query replacement re-attaches with `resume`; flatten is a last resort for a missing transcript or unusable binding. A live session is never abandoned for a flattened re-send.
- Resume-first: every live query replacement re-attaches with `resume`; persisted restarts reattach only when the private sidecar, session marker, committed assistant, identity, prefix, and SDK transcript agree. A live session is never abandoned for a flattened re-send.
- The SDK ledger is authoritative for divergence; decide at the `message_end` commit boundary. Result-only turns are a supported shape, not divergence.
- Fork point is the last assistant boundary strictly before the divergence.
- Non-fork reattach passes `resume` and must omit `sessionId` (the SDK rejects the pair). Fork adds `resumeSessionAt` + `forkSession`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,67 @@
# claude-sdk-oauth extension changes

## 2026-08-18 - Anchor restart records at branch state (issue #6981 review)

### What changed

- The persisted record is derived from the resident registry entry plus the sent-hashes the branch actually carries.
The process binding map is no longer read at `message_end`: it holds the previous turn's state while that handler
runs, and only a prefix digest right after a restart, so reading it anchored this turn's marker to a stale or absent
sent-stream (duplicate resend after restart) or threw on the restored shape (orphaned marker, silent flatten on the
next restart).
- The safe-suffix allowlist now admits the whole display-only metadata family the co-resident builtins append after a
committed assistant: stop-hook state/diagnostics/output, rule activations, and rule scans. Previously a project with
a Stop hook emitting diagnostics or output failed restore on every restart.
- Records are keyed and compared by canonical session path, so a symlinked directory or another spelling of the same
file resolves to one sidecar instead of silently losing the binding.
- A non-`clean` commit outcome no longer anchors a record, an orphaned record whose session id does not match is
deleted rather than left on disk, and the pending-fork labels that lost their producers are gone.
- `session_before_fork` no longer records a taint: forks mint a new session id and file, so the taint was unreachable;
`session_start(fork)` invalidation plus path/session binding is what isolates a fork.

### Declared residuals

- Branch-derived hashes decline to anchor when a compaction boundary sits on the branch: the walk is not
compaction-aware while admission compares against the compaction-truncated context, so anchoring across a boundary
would inflate `sentCount` and flatten every later restart. Declining leaves restart resume unavailable for that
session instead of silently wrong. Not reachable while this lane is active (the lane stands senpi compaction down),
so it needs a compaction entry from another provider's turns, a legacy version, or an imported file.
- `isContentlessUserMessage` matches only a literal zero-length array, while the context path normalizes a null or
missing `content` to an empty array before filtering. A legacy, imported, or hand-edited message with a null
`content` therefore diverges between the two derivations. It fails closed (cold-seed), and it is the same untrusted
input class the trust boundary covers.
- `verifyRestoredTranscript` requires the stored assistant boundary to exist in the transcript, not to be its tip.
- A `custom_message` or `branch_summary` entry shifts the same way: the context path converts both to a user message
and hashes them, while the branch walk skips them because they persist as their own entry types rather than as
`message`. The branch therefore under-counts, never over-counts, so a restart either flattens (divergence inside the
anchored prefix) or re-sends the later messages as delta (divergence after it). The cost is a lost cache, not a
wrong resume, and unlike the compaction residual it self-corrects rather than persisting.

### Trust boundary

- The session JSONL is untrusted input (it can be imported, hand-edited, or copied) and carries only a capability-free
marker. The sidecar is the trusted store: mode 0600, strict schema, bounded size, keyed to one canonical session
path and session id. Integrity rests on same-user file ownership, the same boundary as the SDK's own config dir.

### Cost

- Each committed assistant appends one small marker entry to the session file and rewrites the fixed-size sidecar, so
session files grow by one marker per assistant message (several per tool-loop turn) while the record itself stays
constant-size regardless of conversation length.

## 2026-08-18 - Persist restart bindings for headless continuation (issue #6981)

### What changed

- Successful resident Claude SDK OAuth turns now append the complete continuity binding as a branch-local custom
session entry, including the SDK session id, sent-message hashes, assistant boundaries, account/model identity,
and prompt/tool fingerprints.
- `session_start` restores the newest valid checkpoint into the resident binding map before the first resumed turn.
A missing, invalidated, or malformed checkpoint still fails closed to the existing bootstrap/flatten path.
- Assistant rewrites and terminal failures do not persist a clean checkpoint.
- Successful resident turns append a capability-free branch marker and atomically replace a private, fixed-size
sidecar containing the SDK lineage, sent-prefix digest, assistant hash/boundary, identity, and prompt/tool
fingerprints.
- Startup/resume restores only when the sidecar belongs to the current session file and header, its exact marker and
adjacent committed assistant remain on the active branch, and the local SDK transcript still contains the stored
top-level assistant boundary. Imported JSONL and legacy custom payloads are never lineage authority.
- Provider exits, assistant rewrites, accepted compaction, forks, tree navigation, and extension removal delete
durable and process state. Persisted identity drift and config-dir transcript roots fail closed; reload retains the
fresher process binding, and nested binding state is copied at the registry boundary.

### Why

Expand All @@ -24,7 +76,8 @@

### Expected merge-conflict zones

- MEDIUM in `session-binding.ts` and `session-registry-wiring.ts`; LOW in their focused tests and issue #6981
- MEDIUM in `session-binding.ts`, `session-binding-store.ts`, `session-registry-wiring.ts`,
`session-continuity.ts`, `session-reattach.ts`, and `session-stream.ts`; LOW in their focused tests and issue #6981
regression.

## 2026-08-18 - Select the Claude binary for the host libc
Expand Down Expand Up @@ -361,7 +414,7 @@ LOW in `oauth-login.ts` (added `check` to the returned shape + optional `readSet
- **Abort.** `interrupt()` receipts gate the outcome: `still_queued: []` keeps the live session; a legacy or uncertain receipt closes the query but keeps the binding for reattach. Abort never taints and never flattens. Teardown during resume-initialization is synchronous, so an aborted initialization is torn down before the next assertion point.
- **Fingerprint.** The generated `Current date:` line is normalized before hashing, so a UTC midnight rollover no longer retires a live conversation; cwd and every other prompt region stay fail-closed. Host tool policy is fingerprinted by an explicit `HOST_TOOL_POLICY_FINGERPRINT` version instead of callback source text, and the executable path plus `includePartialMessages` now participate.
- **Account failover.** Shared-root lanes (`oauth-slots`, `ambient`) reattach, or fork at the last verified boundary when cross-account resume is denied. The `config-dir` lane keeps per-account credentials inside its own `CLAUDE_CONFIG_DIR` and no official SDK API moves a transcript across roots, so its failover is the one declared residual that still flattens.
- **Restart.** A branch-local binding checkpoint records `{sdkSessionId, sentCount, sentPrefixHash, lastAssistantUuid, accountName, claudeConfigDir, modelId}`; on the first turn after a restart it is verified against the SDK transcript before resuming, forks at the boundary when the local prefix advanced, and flattens only when the transcript or boundary is gone.
- **Restart.** A bounded branch-local checkpoint records the SDK lineage, sent-prefix digest, last assistant boundary, account/model identity, and prompt/tool fingerprints. Startup/resume restores it only when the checkpoint is followed by its committed assistant and the current prefix digest matches; malformed, invalidated, divergent, or unavailable SDK state falls back without guessing a fork boundary.
- **Observability.** Every main turn emits exactly one continuity observation (kind + sanitized reason + delta count) as an assistant diagnostic and a structured `claude_sdk_oauth_session_continuity` `session.log` event (paired with `claude_sdk_oauth_session_close`); the TUI shows a muted notice only for degradations. `closeSession` no longer discards its reason — the retained cause is attributed to the next admission.
- **Escape hatch.** `resumeMode: "off"` (or `SENPI_CLAUDE_SDK_OAUTH_RESUME=off`) still restores the legacy per-turn behaviour and reports `disabled` observations.
- Merge-conflict risk: high across this directory. New modules: session-continuity.ts, session-reattach.ts, session-binding.ts, session-commit-boundary.ts, session-observability.ts, session-reaper.ts, session-entry-annotations.ts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import type {
Options,
SDKMessage,
SDKUserMessage,
SessionMessage,
SettingSource,
ThinkingConfig,
} from "@anthropic-ai/claude-agent-sdk";
import { createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { createSdkMcpServer, getSessionMessages, query } from "@anthropic-ai/claude-agent-sdk";
import type { Base64ImageSource, ContentBlockParam } from "@anthropic-ai/sdk/resources";

export type {
Expand All @@ -16,6 +17,7 @@ export type {
Options,
SDKMessage,
SDKUserMessage,
SessionMessage,
SettingSource,
ThinkingConfig,
};
Expand All @@ -32,9 +34,10 @@ export type SdkQuery = (input: SdkQueryInput) => SdkQueryHandle;
export type SdkBoundary = {
query: SdkQuery;
createSdkMcpServer: typeof createSdkMcpServer;
getSessionMessages: typeof getSessionMessages;
};

const defaultSdkBoundary: SdkBoundary = { query, createSdkMcpServer };
const defaultSdkBoundary: SdkBoundary = { query, createSdkMcpServer, getSessionMessages };
let activeSdkBoundary = defaultSdkBoundary;

export function getSdkBoundary(): SdkBoundary {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { z } from "zod";

const SHA256_HEX = /^[0-9a-f]{64}$/;
const MAX_RECORD_BYTES = 16 * 1024;

const storedBindingSchema = z.strictObject({
schemaVersion: z.literal(1),
sessionPath: z.string().min(1).max(4096),
sessionId: z.string().min(1).max(256),
markerEntryId: z.string().min(1).max(256),
sdkSessionId: z.string().min(1).max(256),
sentCount: z.number().int().nonnegative().safe(),
sentPrefixHash: z.string().regex(SHA256_HEX),
assistantContentHash: z.string().regex(SHA256_HEX),
lastAssistantUuid: z.string().min(1).max(256).nullable(),
accountName: z.string().min(1).max(256),
modelId: z.string().min(1).max(256),
systemPromptHash: z.string().regex(SHA256_HEX),
toolsetHash: z.string().regex(SHA256_HEX),
});

export type StoredBinding = Readonly<z.infer<typeof storedBindingSchema>>;

export function bindingSidecarPath(sessionFile: string): string {
return `${canonicalSessionPath(sessionFile)}.claude-sdk-oauth-binding.json`;
}

/**
* Records are keyed by the canonical session path so a symlinked directory or a
* different spelling of the same file resolves to one sidecar instead of
* silently losing the binding.
*/
export function canonicalSessionPath(sessionFile: string): string {
const resolved = resolve(sessionFile);
try {
return realpathSync(resolved);
} catch {
try {
return join(realpathSync(dirname(resolved)), basename(resolved));
} catch {
return resolved;
}
}
}

export async function readStoredBinding(sessionFile: string): Promise<StoredBinding | undefined> {
const path = bindingSidecarPath(sessionFile);
let metadata: Awaited<ReturnType<typeof stat>>;
try {
metadata = await stat(path);
} catch (error) {
if (isNotFoundError(error)) return undefined;
throw error;
}
if (metadata.size > MAX_RECORD_BYTES) return undefined;

let value: unknown;
try {
value = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error instanceof SyntaxError) return undefined;
throw error;
}
const parsed = storedBindingSchema.safeParse(value);
if (!parsed.success || parsed.data.sessionPath !== canonicalSessionPath(sessionFile)) return undefined;
return parsed.data;
}

export async function writeStoredBinding(sessionFile: string, record: StoredBinding): Promise<void> {
const sessionPath = canonicalSessionPath(sessionFile);
const parsed = storedBindingSchema.parse(record);
if (canonicalSessionPath(parsed.sessionPath) !== sessionPath) {
throw new StoredBindingPathError(sessionPath, parsed.sessionPath);
}
const canonical: StoredBinding = { ...parsed, sessionPath };
const serialized = `${JSON.stringify(canonical)}\n`;
if (Buffer.byteLength(serialized) > MAX_RECORD_BYTES) {
throw new StoredBindingSizeError(Buffer.byteLength(serialized), MAX_RECORD_BYTES);
}

const path = bindingSidecarPath(sessionPath);
const temporaryPath = join(dirname(path), `.${basename(path)}-${randomUUID()}.tmp`);
try {
await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
await rename(temporaryPath, path);
} catch (error) {
await rm(temporaryPath, { force: true });
throw error;
}
}

export async function deleteStoredBinding(sessionFile: string): Promise<void> {
await rm(bindingSidecarPath(sessionFile), { force: true });
}

class StoredBindingPathError extends Error {
constructor(expected: string, actual: string) {
super(`Stored binding path mismatch: expected ${expected}, received ${actual}`);
this.name = "StoredBindingPathError";
}
}

class StoredBindingSizeError extends Error {
constructor(actual: number, maximum: number) {
super(`Stored binding exceeds ${maximum} bytes: ${actual}`);
this.name = "StoredBindingSizeError";
}
}

function isNotFoundError(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
Loading