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
5 changes: 5 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
authenticated ambient Claude CLI is usable. Empty sentinel credentials and logged-out local CLIs are skipped, and
fallback responses carrying errors such as `Not logged in` cannot emit an immediate or delayed green
`Fallback model responded` notice ([#803](https://github.com/code-yeongyu/senpi/pull/803)).
- The `claude-sdk-oauth` continuity binding is persisted to the session branch and restored on the next start, so a
restart resumes the existing Claude session instead of re-sending the whole conversation as a `registry_miss`
flatten. The binding is only rebuilt when the current sent-prefix digest, account, model, system prompt and
toolset all still match; anything unproven keeps today's behavior
([#809](https://github.com/code-yeongyu/senpi/pull/809)).

### New Features

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# claude-sdk-oauth extension changes

## 2026-08-11 - Persist the continuity binding so a restart resumes instead of re-sending

- `session-binding.ts` has been present since `db8e8cfeb` ("persist continuity bindings for verified restart
resume") but was never wired: nothing called `latestBindingOnBranch()` and nothing ever wrote a
`claude-sdk-oauth-binding` entry. Its unit test was the only consumer, so the sole surviving binding store was the
process-memory `Map` in `session-reattach.ts`.
- Consequence: after a restart both `getSession()` and `getBinding()` miss, `decideNativeContinuity` returns
`bootstrap`, and the lane flattens. `session-stream.ts` synthesizes the reason `registry_miss` because `bootstrap`
carries none, and `session-observability.ts` reports it as `flatten` once `firstTurn` is false. Observed on a
697-message session: 68.0KB and ~60K tokens re-sent on the first turn after every restart.
- Write path: `session-registry-wiring.ts` appends a `BindingCheckpoint` via `pi.appendEntry` at the `message_end`
commit boundary, only after the boundary reports a non-rewritten commit. `registerSessionRegistry` therefore now
takes `Pick<ExtensionAPI, "on" | "appendEntry">`.
- Read path: a new `session_start` handler lifts the newest checkpoint off `ctx.sessionManager.getBranch()` into a
module map. The decision needs the current sent-hash prefix, which only exists once the provider context is built,
so the checkpoint is held until `createResidentAttempt` can verify it.
- Verification is fail-closed. `rehydrateBindingFromCheckpoint` rebuilds the binding from the CURRENT hashes and only
after `prefixDigest(hashes, sentCount)` equals the recorded `sentPrefixHash`, and only when account, model,
`systemPromptHash`, and `toolsetHash` all match. A live entry gets that drift check from `identityDrift`, which a
restarted process cannot run, so `BindingCheckpoint` gained optional `systemPromptHash`/`toolsetHash`; absent means
unknown and unknown never rehydrates. Every refusal falls through to today's cold path.
- The checkpoint stays compact deliberately: `sentPrefixHash` is one sha256 over the prefix, where persisting the
full hash array would add tens of KB to the transcript on every turn. `claudeConfigDir` became optional because
nothing consumes it — a real transcript-existence probe would have to hard-code Claude Code's private
`projects/<mangled-cwd>/<id>.jsonl` layout, and this extension deliberately treats resume failure as that gate
(`session-stream.ts` catches it and falls back to `resume_initialization_failed`).
- `verifyBindingAgainstTranscript` is left untouched and still unused; wiring it needs the transcript probe above.
- This cannot be implemented by an external extension: the binding store, the continuity decision, and the commit
boundary are all private to the builtin provider, and no extension hook can reach them.
- Added `test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts` covering checkpoint derivation, the
branch round-trip, a successful rehydrate, six refusal cases, and single-use consumption.
- Expected merge conflict zones: LOW in `session-binding.ts` (appended store) and `session-sync.ts` (one `export`);
MEDIUM in `session-registry-wiring.ts` (imports, signature, `message_end` tail) and `session-stream.ts` (imports
and the pre-decision block in `createResidentAttempt`).

## 2026-08-11 - Require a real OAuth login for runtime availability

- Removed the literal `apiKey: "claude-sdk-oauth-managed"` registration placeholder. Provider composition treated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ export type BindingCheckpoint = {
sentPrefixHash: string;
lastAssistantUuid: string | null;
accountName: string;
claudeConfigDir: string;
claudeConfigDir?: string;
modelId: string;
/**
* Config identity at the time the checkpoint was written. A live entry has its
* drift checked by `identityDrift`, which a restarted process cannot run because
* the entry is gone. Absent (pre-existing checkpoints) means unknown, and unknown
* never rehydrates.
*/
systemPromptHash?: string;
toolsetHash?: string;
};

export type BindingInvalidation = { schemaVersion: 1; invalidated: true; reason: string };
Expand Down Expand Up @@ -64,3 +72,23 @@ export function verifyBindingAgainstTranscript(input: BindingVerificationInput):
reason: "sent_stream_diverged",
};
}

/**
* Checkpoints read off the branch at `session_start`, held until the turn that can
* verify them. The decision needs the current sent-hash prefix, which only exists
* once the provider context is built, so the read and the check happen in different
* places and this is the hand-off between them.
*/
const checkpoints = new Map<string, BindingCheckpoint>();

export function rememberCheckpoint(senpiSessionId: string, checkpoint: BindingCheckpoint): void {
checkpoints.set(senpiSessionId, checkpoint);
}

export function getCheckpoint(senpiSessionId: string): BindingCheckpoint | undefined {
return checkpoints.get(senpiSessionId);
}

export function forgetCheckpoint(senpiSessionId: string): void {
checkpoints.delete(senpiSessionId);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "../../types.ts";
import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "./account-management.ts";
import {
BINDING_ENTRY_TYPE,
type BindingCheckpoint,
latestBindingOnBranch,
rememberCheckpoint,
} from "./session-binding.ts";
import { AssistantCommitBoundary, isResidentAssistant, isTerminalFailure } from "./session-commit-boundary.ts";
import { bindingFromEntry, rememberBinding } from "./session-reattach.ts";
import {
type ClaudeSdkOauthSessionEntry,
closeSession,
getSession,
recordBranchInfo,
Expand All @@ -25,7 +32,32 @@ function residentEntryFor(sessionId: string, message: AssistantMessage) {
return entry;
}

export function registerSessionRegistry(pi: Pick<ExtensionAPI, "on">): void {
/**
* The compact form of the binding: one prefix digest instead of every sent hash.
* A full hash array would add tens of KB to the transcript on every turn, and the
* digest answers the only question a restart asks - is the prefix still the one
* the SDK session already received.
*/
export function checkpointFromEntry(entry: ClaudeSdkOauthSessionEntry): BindingCheckpoint | undefined {
if (!entry.syncedPrefixHash) return undefined;
return {
schemaVersion: 1,
sdkSessionId: entry.sdkSessionId,
sentCount: entry.sentCount,
sentPrefixHash: entry.syncedPrefixHash,
lastAssistantUuid: entry.assistantUuidByIndex.get(entry.sentCount) ?? null,
accountName: entry.accountName,
modelId: entry.modelId,
systemPromptHash: entry.systemPromptHash,
toolsetHash: entry.toolsetHash,
};
}

export function registerSessionRegistry(pi: Pick<ExtensionAPI, "on" | "appendEntry">): void {
pi.on("session_start", (_event, ctx) => {
const checkpoint = latestBindingOnBranch(ctx.sessionManager.getBranch());
if (checkpoint) rememberCheckpoint(ctx.sessionManager.getSessionId(), checkpoint);
});
pi.on("session_compact", (_event, ctx) => {
recordPendingFork(ctx.sessionManager.getSessionId(), "compaction");
});
Expand Down Expand Up @@ -70,7 +102,10 @@ export function registerSessionRegistry(pi: Pick<ExtensionAPI, "on">): void {
}
if (commitBoundary.commit(sessionId, event.message, entry.modelId) === "rewritten") {
recordPendingFork(sessionId, "assistant_rewritten");
return;
}
const checkpoint = checkpointFromEntry(entry);
if (checkpoint) pi.appendEntry<BindingCheckpoint>(BINDING_ENTRY_TYPE, checkpoint);
});
pi.on("session_shutdown", (event, ctx) => {
closeSession(ctx.sessionManager.getSessionId(), event.reason);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildPromptBlocks } from "./prompt-bridge.ts";
import { dedupeUltraworkBlocks, serializedPayloadBytes } from "./prompt-directive-dedupe.ts";
import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts";
import { getSdkBoundary } from "./sdk-boundary.ts";
import { forgetCheckpoint, getCheckpoint } from "./session-binding.ts";
import { type ContinuityDecision, decideNativeContinuity } from "./session-continuity.ts";
import {
type ContinuityObservation,
Expand All @@ -28,6 +29,7 @@ import { submitSessionTurn } from "./session-registry-pump.ts";
import {
buildDeltaPromptBlocks,
configFingerprint,
prefixDigest,
recordSyncedStream,
sentHashesForEntry,
sentMessageHashes,
Expand Down Expand Up @@ -129,6 +131,44 @@ function entrySnapshot(entry: ClaudeSdkOauthSessionEntry, hashes: readonly strin
};
}

/**
* A restart empties both the registry and the in-memory binding map, so an unchanged
* session would bootstrap and re-send its entire history. The branch checkpoint
* outlives the process, but it carries only a digest, so the binding is rebuilt from
* the CURRENT hashes and only once that digest proves the prefix is identical to what
* the SDK session already received. Anything unproven - a shorter history, a changed
* prefix, a different account/model, an unknown config identity - is left alone and
* takes the existing cold path.
*/
export function rehydrateBindingFromCheckpoint(
sessionId: string,
hashes: readonly string[],
fingerprint: { systemPromptHash: string; toolsetHash: string },
accountName: string,
modelId: string,
): boolean {
const checkpoint = getCheckpoint(sessionId);
if (!checkpoint) return false;
forgetCheckpoint(sessionId);
if (checkpoint.accountName !== accountName || checkpoint.modelId !== modelId) return false;
if (checkpoint.systemPromptHash !== fingerprint.systemPromptHash) return false;
if (checkpoint.toolsetHash !== fingerprint.toolsetHash) return false;
if (checkpoint.sentCount < 1 || hashes.length < checkpoint.sentCount) return false;
if (prefixDigest(hashes, checkpoint.sentCount) !== checkpoint.sentPrefixHash) return false;
rememberBinding({
senpiSessionId: sessionId,
sdkSessionId: checkpoint.sdkSessionId,
sentCount: checkpoint.sentCount,
sentHashes: hashes.slice(0, checkpoint.sentCount),
lastAssistantUuid: checkpoint.lastAssistantUuid,
accountName: checkpoint.accountName,
modelId: checkpoint.modelId,
systemPromptHash: fingerprint.systemPromptHash,
toolsetHash: fingerprint.toolsetHash,
});
return true;
}

async function createResidentAttempt(
input: ResidentSessionStreamInput,
auth: AuthenticatedAttemptInput,
Expand All @@ -139,6 +179,9 @@ async function createResidentAttempt(
const existing = getSession(sessionId);
const fingerprint = configFingerprint(auth.options, input.context, auth.authLane, auth.accountName);
const residentHashes = existing ? (sentHashesForEntry(existing) ?? hashes) : hashes;
if (!existing && !getBinding(sessionId)) {
rehydrateBindingFromCheckpoint(sessionId, hashes, fingerprint, auth.accountName, input.model.id);
}
const decision = decideNativeContinuity({
entry: existing ? entrySnapshot(existing, residentHashes) : undefined,
binding: getBinding(sessionId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function sentMessageHashes(messages: readonly SentMessage[]): string[] {
return hashes;
}

function prefixDigest(hashes: readonly string[], count = hashes.length): string {
export function prefixDigest(hashes: readonly string[], count = hashes.length): string {
return digest(hashes.slice(0, count));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
BINDING_ENTRY_TYPE,
forgetCheckpoint,
latestBindingOnBranch,
rememberCheckpoint,
} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts";
import { forgetBinding, getBinding } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts";
import type { ClaudeSdkOauthSessionEntry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts";
import { checkpointFromEntry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts";
import { rehydrateBindingFromCheckpoint } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts";
import { prefixDigest } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts";

const SESSION = "senpi-session-808";
const FINGERPRINT = { systemPromptHash: "sp-1", toolsetHash: "ts-1" };
const HASHES = ["h0", "h1", "h2", "h3"];

function entryLike(overrides: Partial<ClaudeSdkOauthSessionEntry> = {}): ClaudeSdkOauthSessionEntry {
return {
senpiSessionId: SESSION,
sdkSessionId: "sdk-808",
sentCount: 3,
syncedPrefixHash: prefixDigest(HASHES, 3),
assistantUuidByIndex: new Map([[3, "uuid-a3"]]),
accountName: "primary",
modelId: "claude-opus-5",
systemPromptHash: FINGERPRINT.systemPromptHash,
toolsetHash: FINGERPRINT.toolsetHash,
...overrides,
} as ClaudeSdkOauthSessionEntry;
}

function branchWith(data: unknown) {
return [{ type: "custom" as const, customType: BINDING_ENTRY_TYPE, data }];
}

/** Restores the pre-turn state: both stores are module-level singletons. */
function reset(): void {
forgetCheckpoint(SESSION);
forgetBinding(SESSION);
}

describe("issue #808 - continuity binding survives a restart", () => {
beforeEach(reset);

it("derives a checkpoint from a synced entry and recovers it from the branch", () => {
const checkpoint = checkpointFromEntry(entryLike());
expect(checkpoint).toMatchObject({
sdkSessionId: "sdk-808",
sentCount: 3,
sentPrefixHash: prefixDigest(HASHES, 3),
lastAssistantUuid: "uuid-a3",
systemPromptHash: "sp-1",
toolsetHash: "ts-1",
});

expect(latestBindingOnBranch(branchWith(checkpoint))).toMatchObject({ sdkSessionId: "sdk-808" });
});

it("writes no checkpoint before the first stream is synced", () => {
expect(checkpointFromEntry(entryLike({ syncedPrefixHash: null }))).toBeUndefined();
});

it("rebuilds the in-memory binding when the sent prefix still matches", () => {
const checkpoint = checkpointFromEntry(entryLike());
if (!checkpoint) throw new Error("checkpoint must exist");
rememberCheckpoint(SESSION, checkpoint);

expect(getBinding(SESSION)).toBeUndefined();
expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(true);
expect(getBinding(SESSION)).toMatchObject({
sdkSessionId: "sdk-808",
sentCount: 3,
sentHashes: ["h0", "h1", "h2"],
lastAssistantUuid: "uuid-a3",
});
});

it.each([
["a rewritten prefix", ["h0", "CHANGED", "h2", "h3"], FINGERPRINT, "primary", "claude-opus-5"],
["a truncated history", ["h0", "h1"], FINGERPRINT, "primary", "claude-opus-5"],
["a changed system prompt", HASHES, { ...FINGERPRINT, systemPromptHash: "sp-2" }, "primary", "claude-opus-5"],
["a changed toolset", HASHES, { ...FINGERPRINT, toolsetHash: "ts-2" }, "primary", "claude-opus-5"],
["a different account", HASHES, FINGERPRINT, "secondary", "claude-opus-5"],
["a different model", HASHES, FINGERPRINT, "primary", "claude-opus-4-5"],
])("refuses to rehydrate on %s", (_label, hashes, fingerprint, accountName, modelId) => {
const checkpoint = checkpointFromEntry(entryLike());
if (!checkpoint) throw new Error("checkpoint must exist");
rememberCheckpoint(SESSION, checkpoint);

expect(rehydrateBindingFromCheckpoint(SESSION, hashes, fingerprint, accountName, modelId)).toBe(false);
expect(getBinding(SESSION)).toBeUndefined();
});

it("refuses a checkpoint written before config identity was recorded", () => {
const checkpoint = checkpointFromEntry(entryLike());
if (!checkpoint) throw new Error("checkpoint must exist");
rememberCheckpoint(SESSION, { ...checkpoint, systemPromptHash: undefined, toolsetHash: undefined });

expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(false);
expect(getBinding(SESSION)).toBeUndefined();
});

it("consumes the checkpoint so a later turn cannot replay it", () => {
const checkpoint = checkpointFromEntry(entryLike());
if (!checkpoint) throw new Error("checkpoint must exist");
rememberCheckpoint(SESSION, checkpoint);

expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(true);
forgetBinding(SESSION);
expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(false);
});
});