Summary
changes.md documents a branch-local binding checkpoint that survives process restarts. It is not implemented. session-binding.ts — the module that reads it — has no writer and no production caller anywhere in the repo; the only binding that actually exists is a Map in process memory.
Two consequences:
- Restart-time resume never happens. After a restart senpi cannot see the session it was driving, so it bootstraps a brand-new Claude Code session and re-sends the whole conversation.
- No join key. The Claude Code session id senpi mints is never written to the senpi session log, so the transcripts a senpi session produces cannot be attributed back to it by anything reading the logs afterwards.
(Originally filed as "persist sdkSessionId"; verification showed the missing write is a symptom of the unimplemented checkpoint, and one number in the first version was wrong — see Data.)
Environment
|
|
@code-yeongyu/senpi |
2026.8.12-4 |
omo-ai |
5.0.0-0.beta.7 |
| provider |
claude-sdk-oauth (default) |
| platform |
macOS (darwin-arm64), Node 24.19.0 |
What the docs say ships
src/core/extensions/builtin/claude-sdk-oauth/changes.md:129:
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.
.../claude-sdk-oauth/AGENTS.md, file-role table:
session-binding.ts | Branch-local binding checkpoint for restart-time resume verification
What actually ships
session-binding.ts defines the reader:
// dist/.../claude-sdk-oauth/session-binding.js:1,8
export const BINDING_ENTRY_TYPE = "claude-sdk-oauth-binding";
export function latestBindingOnBranch(branch) { … } // scans for type==="custom" && customType===BINDING_ENTRY_TYPE
Nothing calls it. Code search over this repo:
| symbol |
files |
BINDING_ENTRY_TYPE / "claude-sdk-oauth-binding" |
src/.../session-binding.ts, test/claude-sdk-oauth-binding.test.ts |
latestBindingOnBranch |
same two |
verifyBindingAgainstTranscript |
same two |
The module is reachable only from its own unit test — in source, not just in the published dist.
The binding that is live is a different, incompatible one, and it is memory-only:
// dist/.../session-reattach.js:3
const bindings = new Map(); // never touches disk
// dist/.../session-stream.js:43 (write, in-process)
rememberBinding(bindingFromEntry(entry, hashes));
// dist/.../session-stream.js:87-89 (read, the only read)
const decision = decideNativeContinuity({
entry: existing ? entrySnapshot(existing, residentHashes) : undefined,
binding: getBinding(sessionId),
…
So after a restart existing and getBinding() are both undefined, and session-continuity.js:102-104 returns {kind:"bootstrap"} → flatten → createSessionUuid() mints a fresh id (session-registry.js:124,131) → the entire conversation is re-sent as a new Claude Code session. The Restart. paragraph above describes behaviour that cannot execute.
The id is kept on the in-memory registry entry (session-registry.js:143) and refreshed from the SDK stream on fork (session-registry-pump.js:77-78), but it is never written to ~/.senpi|.omo/sessions/**/*.jsonl. Since Claude Code names its transcript ~/.claude/projects/<project>/<sdkSessionId>.jsonl, the id senpi generated is the transcript filename — and it is dropped.
Data
Measured on ~/Documents/GitHub/unsloth: 6 senpi sessions, 111 Claude Code transcripts, of which 8 are my own direct claude sessions in the same repo and are excluded throughout — leaving 103 senpi-attributable transcripts.
How senpi-attributable transcripts were separated from my own direct Claude Code use
A transcript is counted as senpi's if any of:
- filename is a UUIDv7 — only
createSessionUuid() mints those; Claude Code itself uses UUIDv4. Sanity check: UUIDv7 transcripts exist in 2 of 74 project directories under ~/.claude/projects, and they are exactly the 2 directories where I ran omo.
- first user message is senpi's flatten payload (
<conversation_history> / <omo-senpi-*> blocks) — identifies SDK-minted fork ids whose filename is v4.
- uses
mcp__custom-tools__* — senpi's SDK MCP server.
The 8 excluded files match none of these: UUIDv4 names, a shell paste as the first message, native tools only (Bash, Read, Edit, AskUserQuestion). They account for 6.20M tokens (opus-5 3.61M, sonnet-5 2.56M) and are not part of any figure below.
No join key. All 103 senpi-attributable transcript ids checked against 4,429,562 bytes of ~/.omo/sessions/**/*.jsonl → 0 hits. The only occurrence of the string sdkSessionId anywhere under ~/.omo is inside codegraph/**/codegraph.db, i.e. an index of senpi's own source.
No usable substitute.
- Session ids differ. senpi's session id (
019ff6de-…) and the Claude session ids are unrelated values.
- Time-window correlation is ambiguous. Max 3 senpi sessions live simultaneously, ≥3 concurrent for 11.5 h, all in one cwd — interval containment cannot partition the transcripts.
- The closest thing to a key isn't one. senpi's task id
st_<8 hex> is the top 32 bits of a UUIDv7 millisecond timestamp, i.e. a 65.5-second bucket. Of 32 task ids on disk, 14 resolve to exactly one transcript, 12 to none, 6 to two or more.
Fan-out is fork-driven, and large. Of the 103, 45 carry senpi-minted UUIDv7 ids and 58 carry SDK-minted UUIDv4 fork ids. Grouping files by their first user message, 60 fall into 2 fork lineages — one of which is 56 files descended from a single senpi session (1 parent + 55 forks). Across all senpi sessions, 442 continuity decisions were logged; 159 of them mint a new SDK session id (fork/model_changed 84, fork/sent_stream_diverged 55, fork|flatten/registry_miss 13, other 7).
The spawned-task lane is recorded nowhere: 16.2% of senpi's own tokens. Splitting the 103 transcripts into the conversation lane and the task lane (33 transcripts whose id prefix matches an st_… task id in the senpi log):
| lane |
Claude transcripts (dedup) |
senpi log |
covered |
conversation (opus-4-8 39.44M, fable-5 36.40M, opus-5 0.19M) |
76.03M |
76.16M |
~100% |
spawned tasks (opus-5 13.57M, haiku-4-5 0.74M, sonnet-5 0.50M) |
14.82M |
~0 |
0% |
| total |
90.84M |
76.16M |
83.8% |
The conversation lane is recorded essentially perfectly. What is missing is exactly the work senpi spawned — 14.69M tokens, referenced in the senpi transcript by nothing but a Started task … tool result. In one session a single spawned advisory task consumed 336K tokens with no other trace.
(The first version of this issue claimed ~97.5% coverage. That is wrong; so was the 78.5% I posted while verifying, which had not yet excluded my own direct claude sessions. 83.8% is the corrected figure — the gap is 6.5x what I originally reported, and it is entirely the task lane.)
Bonus hazard for any external consumer: summing usage over those transcripts naively gives 557.6M tokens; deduped by message.id it is 101.8M. Forks copy the parent's prefix verbatim, so without a lineage key an external tool overcounts ~5.5x.
Proposal
Persist the binding the live path actually uses, and read it back when the in-memory map misses.
The two shapes are not interchangeable:
|
session-binding.ts BindingCheckpoint |
live binding (bindingFromEntry) |
| prefix |
sentPrefixHash: string |
sentHashes: string[] |
| boundaries |
lastAssistantUuid only |
assistantUuidByIndex: [number,string][] |
| identity |
accountName, claudeConfigDir, modelId |
accountName, modelId, systemPromptHash, toolsetHash |
decideFromBinding() computes commonPrefixLength(binding.sentHashes, input.currentHashes) (session-continuity.js:50,53), so a checkpoint carrying only sentPrefixHash cannot drive it. Writing the BindingCheckpoint shape would fix attribution but leave restart-resume dead.
So:
-
Write a custom entry carrying the reattach-shaped binding wherever rememberBinding() is called today (session-stream.js:43, session-registry-wiring.js:9, session-reattach.js:82):
{
"type": "custom",
"customType": "claude-sdk-oauth-binding",
"data": {
"schemaVersion": 2,
"sdkSessionId": "019ff407-34c8-738c-b5f4-ed57259cddd1",
"sentCount": 12,
"sentHashes": ["…"],
"lastAssistantUuid": "…",
"assistantUuidByIndex": [[11, "…"]],
"accountName": "…", "modelId": "…",
"systemPromptHash": "…", "toolsetHash": "…"
},
"timestamp": "…", "id": "…", "parentId": "…"
}
-
Wire the reader in: binding: getBinding(sessionId) ?? latestBindingOnBranch(branch) in createResidentAttempt (session-stream.js:89), reconciling latestBindingOnBranch's return type with the live shape.
That makes the documented Restart. behaviour real, gives verifyBindingAgainstTranscript() persisted state to work from, and as a side effect puts the transcript filename in the log. It changes no existing field and is backward compatible — older logs simply have none, and decideNativeContinuity already handles binding === undefined.
If persisting the full binding is unwanted, the narrower fix for attribution alone is to record sdkSessionId on each assistant message entry alongside model/provider — but that leaves restart-resume unimplemented and session-binding.ts dead, so the docs above should be corrected instead.
Reproduce
omo # run any turn on the default claude-sdk-oauth provider
ls ~/.claude/projects/<project>/ # a <uuid>.jsonl appears
grep -c sdkSessionId ~/.omo/sessions/*/*.jsonl # 0 — the id is nowhere in the senpi log
# restart-resume:
# quit omo, relaunch, resume the same senpi session, send one turn
ls -t ~/.claude/projects/<project>/ | head -1 # a NEW transcript, not the previous one
Happy to send a PR if the shape above looks right.
Summary
changes.mddocuments a branch-local binding checkpoint that survives process restarts. It is not implemented.session-binding.ts— the module that reads it — has no writer and no production caller anywhere in the repo; the only binding that actually exists is aMapin process memory.Two consequences:
(Originally filed as "persist
sdkSessionId"; verification showed the missing write is a symptom of the unimplemented checkpoint, and one number in the first version was wrong — see Data.)Environment
@code-yeongyu/senpiomo-aiclaude-sdk-oauth(default)What the docs say ships
src/core/extensions/builtin/claude-sdk-oauth/changes.md:129:.../claude-sdk-oauth/AGENTS.md, file-role table:What actually ships
session-binding.tsdefines the reader:Nothing calls it. Code search over this repo:
BINDING_ENTRY_TYPE/"claude-sdk-oauth-binding"src/.../session-binding.ts,test/claude-sdk-oauth-binding.test.tslatestBindingOnBranchverifyBindingAgainstTranscriptThe module is reachable only from its own unit test — in source, not just in the published
dist.The binding that is live is a different, incompatible one, and it is memory-only:
So after a restart
existingandgetBinding()are bothundefined, andsession-continuity.js:102-104returns{kind:"bootstrap"}→ flatten →createSessionUuid()mints a fresh id (session-registry.js:124,131) → the entire conversation is re-sent as a new Claude Code session. TheRestart.paragraph above describes behaviour that cannot execute.The id is kept on the in-memory registry entry (
session-registry.js:143) and refreshed from the SDK stream on fork (session-registry-pump.js:77-78), but it is never written to~/.senpi|.omo/sessions/**/*.jsonl. Since Claude Code names its transcript~/.claude/projects/<project>/<sdkSessionId>.jsonl, the id senpi generated is the transcript filename — and it is dropped.Data
Measured on
~/Documents/GitHub/unsloth: 6 senpi sessions, 111 Claude Code transcripts, of which 8 are my own directclaudesessions in the same repo and are excluded throughout — leaving 103 senpi-attributable transcripts.How senpi-attributable transcripts were separated from my own direct Claude Code use
A transcript is counted as senpi's if any of:
createSessionUuid()mints those; Claude Code itself uses UUIDv4. Sanity check: UUIDv7 transcripts exist in 2 of 74 project directories under~/.claude/projects, and they are exactly the 2 directories where I ranomo.<conversation_history>/<omo-senpi-*>blocks) — identifies SDK-minted fork ids whose filename is v4.mcp__custom-tools__*— senpi's SDK MCP server.The 8 excluded files match none of these: UUIDv4 names, a shell paste as the first message, native tools only (
Bash,Read,Edit,AskUserQuestion). They account for 6.20M tokens (opus-53.61M,sonnet-52.56M) and are not part of any figure below.No join key. All 103 senpi-attributable transcript ids checked against 4,429,562 bytes of
~/.omo/sessions/**/*.jsonl→ 0 hits. The only occurrence of the stringsdkSessionIdanywhere under~/.omois insidecodegraph/**/codegraph.db, i.e. an index of senpi's own source.No usable substitute.
019ff6de-…) and the Claude session ids are unrelated values.st_<8 hex>is the top 32 bits of a UUIDv7 millisecond timestamp, i.e. a 65.5-second bucket. Of 32 task ids on disk, 14 resolve to exactly one transcript, 12 to none, 6 to two or more.Fan-out is fork-driven, and large. Of the 103, 45 carry senpi-minted UUIDv7 ids and 58 carry SDK-minted UUIDv4 fork ids. Grouping files by their first user message, 60 fall into 2 fork lineages — one of which is 56 files descended from a single senpi session (1 parent + 55 forks). Across all senpi sessions, 442 continuity decisions were logged; 159 of them mint a new SDK session id (
fork/model_changed84,fork/sent_stream_diverged55,fork|flatten/registry_miss13, other 7).The spawned-task lane is recorded nowhere: 16.2% of senpi's own tokens. Splitting the 103 transcripts into the conversation lane and the task lane (33 transcripts whose id prefix matches an
st_…task id in the senpi log):opus-4-839.44M,fable-536.40M,opus-50.19M)opus-513.57M,haiku-4-50.74M,sonnet-50.50M)The conversation lane is recorded essentially perfectly. What is missing is exactly the work senpi spawned — 14.69M tokens, referenced in the senpi transcript by nothing but a
Started task …tool result. In one session a single spawned advisory task consumed 336K tokens with no other trace.(The first version of this issue claimed ~97.5% coverage. That is wrong; so was the 78.5% I posted while verifying, which had not yet excluded my own direct
claudesessions. 83.8% is the corrected figure — the gap is 6.5x what I originally reported, and it is entirely the task lane.)Bonus hazard for any external consumer: summing
usageover those transcripts naively gives 557.6M tokens; deduped bymessage.idit is 101.8M. Forks copy the parent's prefix verbatim, so without a lineage key an external tool overcounts ~5.5x.Proposal
Persist the binding the live path actually uses, and read it back when the in-memory map misses.
The two shapes are not interchangeable:
session-binding.tsBindingCheckpointbindingFromEntry)sentPrefixHash: stringsentHashes: string[]lastAssistantUuidonlyassistantUuidByIndex: [number,string][]accountName,claudeConfigDir,modelIdaccountName,modelId,systemPromptHash,toolsetHashdecideFromBinding()computescommonPrefixLength(binding.sentHashes, input.currentHashes)(session-continuity.js:50,53), so a checkpoint carrying onlysentPrefixHashcannot drive it. Writing theBindingCheckpointshape would fix attribution but leave restart-resume dead.So:
Write a
customentry carrying the reattach-shaped binding whereverrememberBinding()is called today (session-stream.js:43,session-registry-wiring.js:9,session-reattach.js:82):{ "type": "custom", "customType": "claude-sdk-oauth-binding", "data": { "schemaVersion": 2, "sdkSessionId": "019ff407-34c8-738c-b5f4-ed57259cddd1", "sentCount": 12, "sentHashes": ["…"], "lastAssistantUuid": "…", "assistantUuidByIndex": [[11, "…"]], "accountName": "…", "modelId": "…", "systemPromptHash": "…", "toolsetHash": "…" }, "timestamp": "…", "id": "…", "parentId": "…" }Wire the reader in:
binding: getBinding(sessionId) ?? latestBindingOnBranch(branch)increateResidentAttempt(session-stream.js:89), reconcilinglatestBindingOnBranch's return type with the live shape.That makes the documented
Restart.behaviour real, givesverifyBindingAgainstTranscript()persisted state to work from, and as a side effect puts the transcript filename in the log. It changes no existing field and is backward compatible — older logs simply have none, anddecideNativeContinuityalready handlesbinding === undefined.If persisting the full binding is unwanted, the narrower fix for attribution alone is to record
sdkSessionIdon each assistantmessageentry alongsidemodel/provider— but that leaves restart-resume unimplemented andsession-binding.tsdead, so the docs above should be corrected instead.Reproduce
Happy to send a PR if the shape above looks right.