@volter/supercode-interchange 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-diffy-MIT +23 -0
- package/README.md +422 -2
- package/channel-records.mjs +71 -0
- package/channel-session-counts.mjs +81 -0
- package/channels-read.mjs +90 -0
- package/corpus-audit-records.mjs +192 -0
- package/corpus-audit-render.mjs +47 -0
- package/corpus-audit-schema.mjs +89 -0
- package/corpus-audit.d.ts +16 -0
- package/corpus-audit.mjs +132 -0
- package/file-io.mjs +21 -2
- package/harness-settings.mjs +19 -0
- package/hermes-fire-links.mjs +135 -0
- package/hermes-fires.mjs +106 -0
- package/hermes-read-home.d.ts +3 -0
- package/hermes-read-home.mjs +235 -0
- package/hermes-read-store.mjs +49 -0
- package/hermes-read-validation.mjs +170 -0
- package/hermes-read-workflow.mjs +164 -0
- package/hermes-read-yaml.mjs +68 -0
- package/hermes-store.mjs +1 -1
- package/homes.mjs +2 -2
- package/index.d.ts +14 -1
- package/index.mjs +1 -1
- package/inspection-builtin-tools.json +261 -0
- package/inspection-config-fields.json +765 -0
- package/inspection-schemas.d.ts +4 -0
- package/inspection-schemas.mjs +117 -0
- package/job-notepad-read.mjs +97 -0
- package/jobs-control.mjs +250 -0
- package/jobs-read.mjs +198 -0
- package/jsonl.mjs +4 -3
- package/memory-show.mjs +187 -0
- package/metadata.mjs +45 -3
- package/native-json.mjs +46 -3
- package/ontology-contracts.d.ts +5 -0
- package/ontology-contracts.mjs +29 -0
- package/ontology-operations.json +198 -0
- package/ontology-schemas.json +7946 -0
- package/openclaw-jobs-read.mjs +117 -0
- package/openclaw-profile-home.mjs +142 -0
- package/orchestrator-read-records.mjs +112 -0
- package/orchestrator-store.mjs +21 -9
- package/package.json +59 -4
- package/parity-ledger.json +6984 -0
- package/parity-orchestration.json +5538 -0
- package/parity-report.d.ts +7 -0
- package/parity-report.mjs +46 -0
- package/preset-profiles.mjs +27 -0
- package/profile-command.mjs +32 -0
- package/profile-orchestrator-door.mjs +104 -0
- package/profile-paths.mjs +25 -0
- package/profiles-control.mjs +98 -0
- package/profiles-read.mjs +119 -0
- package/reduction-attribution.mjs +54 -0
- package/reduction-diff.mjs +165 -0
- package/reduction-log.mjs +177 -0
- package/reduction-view.mjs +145 -0
- package/retained-projection.mjs +9 -0
- package/routes-read.mjs +103 -0
- package/runs-read.mjs +224 -0
- package/session-activity.d.ts +23 -0
- package/session-activity.mjs +124 -0
- package/session-append-scan.mjs +27 -17
- package/session-branch-handoff.mjs +115 -0
- package/session-claude-graph.mjs +29 -7
- package/session-claude-index.mjs +732 -114
- package/session-claude-runtime.d.ts +28 -0
- package/session-claude-runtime.mjs +171 -6
- package/session-claude-scan-store.mjs +164 -0
- package/session-claude.mjs +9 -0
- package/session-codex-activity.d.ts +6 -0
- package/session-codex-activity.mjs +120 -0
- package/session-codex-window.mjs +24 -4
- package/session-config-scan.mjs +141 -0
- package/session-convert.d.ts +33 -0
- package/session-convert.mjs +179 -0
- package/session-emission-version.json +1 -0
- package/session-file.mjs +11 -1
- package/session-follower.mjs +17 -5
- package/session-fresh-id.mjs +15 -0
- package/session-hermes-import.mjs +61 -0
- package/session-hermes.mjs +1 -1
- package/session-import.mjs +37 -0
- package/session-inspect.d.ts +18 -0
- package/session-inspect.mjs +122 -0
- package/session-machine-tree.d.ts +33 -0
- package/session-machine-tree.mjs +274 -0
- package/session-materialize-files.mjs +78 -0
- package/session-materialize-opencode.mjs +137 -0
- package/session-materialize.mjs +186 -0
- package/session-message.mjs +85 -0
- package/session-model.d.ts +9 -0
- package/session-model.mjs +1 -0
- package/session-opencode-store.mjs +39 -3
- package/session-path.mjs +6 -3
- package/session-pi-content.mjs +7 -2
- package/session-read.d.ts +134 -2
- package/session-read.mjs +86 -9
- package/session-recorded-config.mjs +3 -2
- package/session-records.mjs +12 -6
- package/session-residue-store.mjs +90 -0
- package/session-residue.mjs +150 -0
- package/session-resume-instructions.mjs +1 -1
- package/session-saved-read.mjs +105 -0
- package/session-transfer.mjs +141 -0
- package/session-views.mjs +1 -0
- package/session-write-claude.mjs +135 -0
- package/session-write-codex.mjs +146 -0
- package/session-write-gemini.mjs +77 -0
- package/session-write-goose.mjs +99 -0
- package/session-write-grok.mjs +70 -0
- package/session-write-native.mjs +41 -0
- package/session-write-opencode.mjs +197 -0
- package/session-write-pi.mjs +130 -0
- package/session-write-residue.mjs +19 -0
- package/session-write-utils.mjs +77 -0
- package/session-write.d.ts +10 -0
- package/session-write.mjs +37 -0
- package/skill-toml.mjs +80 -0
- package/skills-control.mjs +193 -0
- package/skills-list.mjs +237 -0
- package/source-files.mjs +12 -2
- package/sqlite-reader.mjs +134 -18
- package/support-registry.json +402 -0
- package/support-report.mjs +69 -0
- package/trigger-records.mjs +77 -0
- package/triggers-read.mjs +67 -0
- package/workflow-read.mjs +165 -0
|
@@ -33,3 +33,31 @@ export interface ClaudeRuntimeManifest {
|
|
|
33
33
|
}
|
|
34
34
|
/** A pure strict fold. It never executes scheduled work or launches a child. */
|
|
35
35
|
export function claudeRuntimeManifest(session: Session): ClaudeRuntimeManifest;
|
|
36
|
+
|
|
37
|
+
/** One background agent of a Claude Code session, as its records say it (claudeBackgroundAgentFold). `running` means
|
|
38
|
+
* no notification has ended its latest run; whether its session still runs is the reader's to say. */
|
|
39
|
+
export interface ClaudeBackgroundAgent {
|
|
40
|
+
/** The agent id Claude Code gave it (`toolUseResult.agentId`), which its notifications and `SendMessage` name. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** The `Agent` call that launched it. */
|
|
43
|
+
tool_use_id: string;
|
|
44
|
+
description: string | null; agent_type: string | null;
|
|
45
|
+
state: 'running' | 'completed' | 'failed' | 'stopped';
|
|
46
|
+
/** The first lines of the latest run's notification `<summary>` and `<result>`. */
|
|
47
|
+
summary: string | null; result: string | null;
|
|
48
|
+
/** When its launch was recorded; `started_at` is its latest run's start (a resume's, when one resumed it). */
|
|
49
|
+
launched_at: string | null; started_at: string | null; finished_at: string | null;
|
|
50
|
+
}
|
|
51
|
+
export interface ClaudeTaskNotification {
|
|
52
|
+
task_ids: string[]; tool_use_id: string | null; status: string | null; summary: string | null; result: string | null;
|
|
53
|
+
}
|
|
54
|
+
export function claudeTaskNotifications(text: string): ClaudeTaskNotification[];
|
|
55
|
+
export interface ClaudeBackgroundAgentFold {
|
|
56
|
+
observe(value: unknown, line: number): void;
|
|
57
|
+
clone(): ClaudeBackgroundAgentFold;
|
|
58
|
+
readonly retainedEntries: number;
|
|
59
|
+
/** Every running agent, then at most `settled` (default 20) that ended, the latest-started first. `also` is a fold of
|
|
60
|
+
* records not yet in this one (a line still being written), read beside it without copying either. */
|
|
61
|
+
finish(options?: { settled?: number; also?: ClaudeBackgroundAgentFold | null }): ClaudeBackgroundAgent[];
|
|
62
|
+
}
|
|
63
|
+
export function claudeBackgroundAgentFold(): ClaudeBackgroundAgentFold;
|
|
@@ -151,18 +151,171 @@ function applyNotification({ notification: note, timestamp, line }, children) {
|
|
|
151
151
|
child.finished_at = timestamp; child.summary = note.summary;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// ---- Background agents --------------------------------------------------------------------------------------------
|
|
155
|
+
// Claude Code's background agents as its own records say them, for a reader that shows what the terminal shows under
|
|
156
|
+
// its prompt. Lenient where the manifest is strict: an unrelated malformed record never hides an agent, and the facts
|
|
157
|
+
// are joined only at `finish`, so the fold is the same whatever order the records arrive in.
|
|
158
|
+
const NOTIFICATION_OPEN = '<task-notification>';
|
|
159
|
+
const AGENT_TEXT = 200, AGENT_LINE = 300;
|
|
160
|
+
const opens = text => typeof text === 'string' && text.trimStart().startsWith(NOTIFICATION_OPEN);
|
|
161
|
+
const firstLine = (text, max = AGENT_LINE) => {
|
|
162
|
+
const line = typeof text === 'string' ? text.trim().split('\n', 1)[0].trim() : '';
|
|
163
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
164
|
+
};
|
|
165
|
+
const blockText = content => typeof content === 'string' ? content
|
|
166
|
+
: Array.isArray(content) ? content.filter(block => stringField(block, 'type') === 'text').map(block => stringField(block, 'text') ?? '').join('') : null;
|
|
167
|
+
const allTags = (block, name) => [...block.matchAll(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g'))].map(match => nativeTrim(match[1])).filter(Boolean);
|
|
168
|
+
/** Every task notification a text holds, each with all the tasks it names (an end-of-session summary names several). */
|
|
169
|
+
export function claudeTaskNotifications(text) {
|
|
170
|
+
if (typeof text !== 'string' || !text.includes(NOTIFICATION_OPEN)) return [];
|
|
171
|
+
return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].map(([, block]) => ({
|
|
172
|
+
task_ids: allTags(block, 'task-id'), tool_use_id: allTags(block, 'tool-use-id')[0] ?? null, status: allTags(block, 'status')[0] ?? null,
|
|
173
|
+
summary: firstLine(allTags(block, 'summary')[0]) || null, result: firstLine(allTags(block, 'result')[0]) || null,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
const agentState = status => status === 'completed' ? 'completed' : status === 'failed' || status === 'error' ? 'failed' : 'stopped';
|
|
177
|
+
// A queue entry carries no stamp: only one that is notifications and nothing else is the runtime's own.
|
|
178
|
+
const onlyNotifications = text => text.replace(/<task-notification>[\s\S]*?<\/task-notification>/g, '').trim() === '';
|
|
179
|
+
const emptyAgentState = () => ({ launches: new Map(), sends: new Map(), results: new Map(), notes: [], queued: [] });
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Claude Code's background agents, folded from a transcript's records (`observe` each parsed record with its 1-based
|
|
183
|
+
* line, in any order; `finish` answers):
|
|
184
|
+
* - an `Agent` (or `Task`) call starts an agent when it asked `run_in_background` or its result is async
|
|
185
|
+
* (`toolUseResult.isAsync`); the result's structured `agentId` names it;
|
|
186
|
+
* - a `SendMessage` whose result names the agent it resumed (`toolUseResult.resumedAgentId`) starts it again;
|
|
187
|
+
* - a task notification ends the run its `<tool-use-id>` names (else the agent's latest run before it), for every
|
|
188
|
+
* `<task-id>` it lists: `completed`, `failed`, or `stopped` (killed, stopped, or not finished when the session
|
|
189
|
+
* before it ended). Only the runtime's own notifications count: a user record Claude Code stamped
|
|
190
|
+
* `origin.kind: task-notification`, a queued command of `commandMode: task-notification`, or a queue entry that is
|
|
191
|
+
* notifications and nothing else, each naming by `<tool-use-id>` a run of the agent it names. A notification quoted
|
|
192
|
+
* in a tool result or written in anyone's message is text, not an ending.
|
|
193
|
+
* Sidechain records (a subagent's own transcript lines) are not the session's. An agent whose latest run has no ending
|
|
194
|
+
* is `running` as far as the transcript knows; whether its session still runs is the reader's to say.
|
|
195
|
+
*/
|
|
196
|
+
// What the fold keeps is copied out of the record it was read from: a part cut from a record's text holds the whole
|
|
197
|
+
// text in memory for as long as the part is kept, and the fold keeps its parts for as long as its transcript is read.
|
|
198
|
+
const detach = value => typeof value === 'string' ? JSON.parse(JSON.stringify(value)) : Array.isArray(value) ? value.map(detach) : value;
|
|
199
|
+
const owned = entry => Object.fromEntries(Object.entries(entry).map(([key, value]) => [key, detach(value)]));
|
|
200
|
+
|
|
201
|
+
export function claudeBackgroundAgentFold(saved = null) {
|
|
202
|
+
const state = saved ?? emptyAgentState();
|
|
203
|
+
const notes = (text, at, line, into = state.notes) => { for (const note of claudeTaskNotifications(text)) into.push(owned({ ...note, at, line })); };
|
|
204
|
+
return {
|
|
205
|
+
get state() { return state; },
|
|
206
|
+
get retainedEntries() { return state.launches.size + state.sends.size + state.results.size + state.notes.length + state.queued.length; },
|
|
207
|
+
clone() {
|
|
208
|
+
return claudeBackgroundAgentFold({ launches: new Map(state.launches), sends: new Map(state.sends), results: new Map(state.results),
|
|
209
|
+
notes: [...state.notes], queued: [...state.queued] });
|
|
210
|
+
},
|
|
211
|
+
observe(value, line) {
|
|
212
|
+
if (field(value, 'isSidechain') === true) return;
|
|
213
|
+
const kind = stringField(value, 'type'), at = stringField(value, 'timestamp');
|
|
214
|
+
const content = field(field(value, 'message'), 'content');
|
|
215
|
+
if (kind === 'assistant' && Array.isArray(content)) {
|
|
216
|
+
for (const block of content) {
|
|
217
|
+
if (stringField(block, 'type') !== 'tool_use') continue;
|
|
218
|
+
const name = stringField(block, 'name'), id = stringField(block, 'id');
|
|
219
|
+
if (id == null || !['Agent', 'Task', 'SendMessage'].includes(name)) continue;
|
|
220
|
+
const input = field(block, 'input');
|
|
221
|
+
if (name === 'SendMessage') state.sends.set(detach(id), { line });
|
|
222
|
+
else state.launches.set(detach(id), owned({ description: firstLine(stringField(input, 'description'), AGENT_TEXT) || null,
|
|
223
|
+
agent_type: firstLine(stringField(input, 'subagent_type'), AGENT_TEXT) || null, background: bool(input, 'run_in_background'), at, line }));
|
|
224
|
+
}
|
|
225
|
+
} else if (kind === 'user') {
|
|
226
|
+
const results = Array.isArray(content) ? content.filter(block => stringField(block, 'type') === 'tool_result') : [];
|
|
227
|
+
if (results.length) {
|
|
228
|
+
// Each tool result's own structured result: the record's one, or its place in a list of them.
|
|
229
|
+
const native = field(value, 'toolUseResult');
|
|
230
|
+
results.forEach((block, index) => {
|
|
231
|
+
const id = stringField(block, 'tool_use_id'), own = Array.isArray(native) ? (native.length === results.length ? native[index] : null) : results.length === 1 ? native : null;
|
|
232
|
+
if (id == null || own == null || typeof own !== 'object') return;
|
|
233
|
+
const agentId = stringField(own, 'agentId'), resumed = stringField(own, 'resumedAgentId');
|
|
234
|
+
if (agentId != null || resumed != null || bool(own, 'isAsync'))
|
|
235
|
+
state.results.set(detach(id), owned({ agent_id: agentId, resumed, async: bool(own, 'isAsync'), error: bool(block, 'is_error'), at, line }));
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (stringField(field(value, 'origin'), 'kind') === 'task-notification') notes(blockText(content), at, line);
|
|
240
|
+
} else if (kind === 'attachment') {
|
|
241
|
+
const attachment = field(value, 'attachment');
|
|
242
|
+
if (stringField(attachment, 'type') === 'queued_command' && stringField(attachment, 'commandMode') === 'task-notification')
|
|
243
|
+
notes(stringField(attachment, 'prompt'), at, line);
|
|
244
|
+
} else if (kind === 'queue-operation' && stringField(value, 'operation') === 'enqueue') {
|
|
245
|
+
const text = stringField(value, 'content');
|
|
246
|
+
if (opens(text) && onlyNotifications(text)) notes(text, at, line, state.queued);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
/** The agents, the latest-started first: every one running, then at most `settled` that ended. `also` is a fold of
|
|
250
|
+
* records not yet in this one (a line still being written), read beside it without copying either. */
|
|
251
|
+
finish({ settled = 20, also = null } = {}) {
|
|
252
|
+
const states = also ? [state, also.state] : [state];
|
|
253
|
+
const each = function* (key) { for (const held of states) yield* held[key]; };
|
|
254
|
+
const results = id => states.map(held => held.results.get(id)).find(Boolean);
|
|
255
|
+
const agents = new Map(), runs = new Map();
|
|
256
|
+
for (const [call, launch] of each('launches')) {
|
|
257
|
+
const result = results(call);
|
|
258
|
+
if (!result || result.error || result.agent_id == null || !(launch.background || result.async)) continue;
|
|
259
|
+
const run = { call, line: result.line, started_at: launch.at ?? result.at, outcome: null, summary: null, result: null, finished_at: null };
|
|
260
|
+
const known = agents.get(result.agent_id);
|
|
261
|
+
if (known) { known.runs.push(run); runs.set(call, { agent: known, run }); continue; }
|
|
262
|
+
const agent = { id: result.agent_id, tool_use_id: call, description: launch.description, agent_type: launch.agent_type, launched_at: run.started_at, runs: [run] };
|
|
263
|
+
agents.set(agent.id, agent); runs.set(call, { agent, run });
|
|
264
|
+
}
|
|
265
|
+
for (const [call] of each('sends')) {
|
|
266
|
+
const result = results(call), agent = result?.resumed == null || result.error ? null : agents.get(result.resumed);
|
|
267
|
+
if (!agent) continue;
|
|
268
|
+
const run = { call, line: result.line, started_at: result.at, outcome: null, summary: null, result: null, finished_at: null };
|
|
269
|
+
agent.runs.push(run); runs.set(call, { agent, run });
|
|
270
|
+
}
|
|
271
|
+
for (const agent of agents.values()) agent.runs.sort((left, right) => left.line - right.line);
|
|
272
|
+
const all = [...[...each('notes')].map(note => ({ note, stamped: true })), ...[...each('queued')].map(note => ({ note, stamped: false }))]
|
|
273
|
+
.sort((left, right) => left.note.line - right.note.line);
|
|
274
|
+
for (const { note, stamped } of all) for (const task of note.task_ids) {
|
|
275
|
+
const agent = agents.get(task); if (!agent) continue;
|
|
276
|
+
const named = note.tool_use_id == null ? null : runs.get(note.tool_use_id);
|
|
277
|
+
// An unstamped queue entry counts only for a run of this agent it names.
|
|
278
|
+
if (!stamped && named?.agent !== agent) continue;
|
|
279
|
+
const run = named?.agent === agent ? named.run : agent.runs.findLast(candidate => candidate.line < note.line) ?? agent.runs.at(-1);
|
|
280
|
+
// The first notice of a run ends it; the same notice written again (queued, then delivered) changes nothing.
|
|
281
|
+
if (run.outcome) continue;
|
|
282
|
+
Object.assign(run, { outcome: agentState(note.status), summary: note.summary, result: note.result, finished_at: note.at });
|
|
283
|
+
}
|
|
284
|
+
const answer = [...agents.values()].map(agent => {
|
|
285
|
+
const latest = agent.runs.at(-1);
|
|
286
|
+
return { id: agent.id, tool_use_id: agent.tool_use_id, description: agent.description, agent_type: agent.agent_type,
|
|
287
|
+
state: latest.outcome ?? 'running', summary: latest.summary, result: latest.result, launched_at: agent.launched_at, started_at: latest.started_at,
|
|
288
|
+
finished_at: latest.finished_at, line: latest.line };
|
|
289
|
+
}).sort((left, right) => right.line - left.line);
|
|
290
|
+
const running = answer.filter(agent => agent.state === 'running'), ended = answer.filter(agent => agent.state !== 'running').slice(0, settled);
|
|
291
|
+
return [...running, ...ended].map(({ line: _line, ...agent }) => agent);
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
154
296
|
/** The manifest's fold, one parsed record at a time: every record's calls
|
|
155
297
|
* first, then every record again in order, so physically earlier async
|
|
156
298
|
* results can resolve their origins. `raw` is kept only in the residue. */
|
|
157
|
-
export function claudeRuntimeFold() {
|
|
158
|
-
const state = { crons: new Map(), pending: new Map(), children: new Map(), wakeups: new Map(), notifications: [], residue: [] };
|
|
159
|
-
const posture = { permission_mode: null, last_prompt_leaf_uuid: null, last_prompt: null, timestamp: null, entrypoint: null, user_type: null, version: null, cwd: null };
|
|
160
|
-
const queue = { enqueued: 0, dequeued: 0, removed: 0, pending: [] };
|
|
161
|
-
let incoming = [], outgoing = [];
|
|
162
|
-
let reported = null;
|
|
299
|
+
export function claudeRuntimeFold(saved = null) {
|
|
300
|
+
const state = saved?.state ?? { crons: new Map(), pending: new Map(), children: new Map(), wakeups: new Map(), notifications: [], residue: [] };
|
|
301
|
+
const posture = saved?.posture ?? { permission_mode: null, last_prompt_leaf_uuid: null, last_prompt: null, timestamp: null, entrypoint: null, user_type: null, version: null, cwd: null };
|
|
302
|
+
const queue = saved?.queue ?? { enqueued: 0, dequeued: 0, removed: 0, pending: [] };
|
|
303
|
+
let incoming = saved?.incoming ?? [], outgoing = saved?.outgoing ?? [];
|
|
304
|
+
let reported = saved?.reported ?? null;
|
|
305
|
+
let permissionChecked = saved?.permissionChecked ?? 0;
|
|
306
|
+
// The background agents ride the same records, leniently: a refusal of the manifest never loses them.
|
|
307
|
+
const agents = claudeBackgroundAgentFold(saved?.agents ?? null);
|
|
163
308
|
return {
|
|
309
|
+
/** The session's background agents (claudeBackgroundAgentFold), from every record this fold has seen. */
|
|
310
|
+
backgroundAgents(options) { return agents.finish(options); },
|
|
311
|
+
// Runtime state contains strings, booleans, native big integers and maps,
|
|
312
|
+
// never decoded transcript objects. A snapshot's finish cannot consume the
|
|
313
|
+
// retained queue or apply its notifications twice on the next append.
|
|
314
|
+
get retainedEntries() { return agents.retainedEntries + state.crons.size + state.pending.size + state.children.size + state.wakeups.size + state.notifications.length + state.residue.length + incoming.length + outgoing.length; },
|
|
315
|
+
clone() { return claudeRuntimeFold(structuredClone({ state, posture, queue, incoming, outgoing, reported, permissionChecked, agents: agents.state })); },
|
|
164
316
|
call(value, line, raw) { if (stringField(value, 'type') === 'assistant') assistantCalls(value, line, raw, state); },
|
|
165
317
|
record(value, line, raw) {
|
|
318
|
+
agents.observe(value, line);
|
|
166
319
|
for (const [key, target] of [['timestamp', 'timestamp'], ['entrypoint', 'entrypoint'], ['userType', 'user_type'], ['version', 'version'], ['cwd', 'cwd']]) {
|
|
167
320
|
const text = stringField(value, key); if (text != null) posture[target] = text;
|
|
168
321
|
}
|
|
@@ -191,6 +344,18 @@ export function claudeRuntimeFold() {
|
|
|
191
344
|
else if (stringField(value, 'subtype') === 'scheduled_task_fire') residue(state, line, 'scheduled-task-fire', raw);
|
|
192
345
|
} else if (kind !== 'assistant' && runtimeType(kind)) residue(state, line, 'unknown-runtime-record', raw);
|
|
193
346
|
},
|
|
347
|
+
// Finishing the manifest changes child summaries and materializes queue
|
|
348
|
+
// output, but none of those changes affects the permission reading. Its
|
|
349
|
+
// only deferred refusal is a notification with neither reference id.
|
|
350
|
+
// Validate new notifications once without consuming retained fold state.
|
|
351
|
+
permissionMode() {
|
|
352
|
+
for (; permissionChecked < state.notifications.length; permissionChecked++) {
|
|
353
|
+
const { notification: note, line } = state.notifications[permissionChecked];
|
|
354
|
+
if (note.tool_use_id == null && note.task_id == null)
|
|
355
|
+
fail(`malformed Claude task notification at line ${line}: missing tool-use-id and task-id`);
|
|
356
|
+
}
|
|
357
|
+
return posture.permission_mode;
|
|
358
|
+
},
|
|
194
359
|
finish() {
|
|
195
360
|
for (const notification of state.notifications) applyNotification(notification, state.children);
|
|
196
361
|
queue.pending = outgoing.reverse().concat(incoming);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// What a Claude Code transcript's scan knows of each of its records, kept on disk rather than in the process: one
|
|
2
|
+
// SQLite file per scan, private to this process (a directory under the system's temporary directory, removed when the
|
|
3
|
+
// process exits, swept by the next process when it did not; a scan let go removes its own). The scan holds in memory only what its next read replays (the tail
|
|
4
|
+
// after its settled prefix) and the facts that do not grow with the transcript; everything per record, per message
|
|
5
|
+
// and per group is read from here when a read needs it.
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { threadId } from 'node:worker_threads';
|
|
12
|
+
import { parseNativeJson, stringifyNativeJson } from './native-json.mjs';
|
|
13
|
+
|
|
14
|
+
// A scan directory is named for the process and the thread that made it: `supercode-claude-scans-<pid>-<thread>-…`.
|
|
15
|
+
const SCANS = /^supercode-claude-scans-(\d+)-(\d+)-/;
|
|
16
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The scan directories of processes and threads that are gone, removed: every one whose process no longer runs (a
|
|
20
|
+
* process killed, aborted or crashed leaves its directory), or, named, those of one thread of this process (a reader
|
|
21
|
+
* worker stopped without running its exit hooks: its owner removes them, `session-read-client.mjs`).
|
|
22
|
+
*/
|
|
23
|
+
export function sweepScanDirectories({ thread = null } = {}) {
|
|
24
|
+
let names = [];
|
|
25
|
+
try { names = readdirSync(tmpdir()); } catch { return; }
|
|
26
|
+
for (const name of names) {
|
|
27
|
+
const owner = SCANS.exec(name);
|
|
28
|
+
if (!owner) continue;
|
|
29
|
+
const pid = Number(owner[1]), of = Number(owner[2]);
|
|
30
|
+
if (thread == null ? alive(pid) : pid !== process.pid || of !== thread) continue;
|
|
31
|
+
try { rmSync(join(tmpdir(), name), { recursive: true, force: true }); } catch { /* another sweep took it */ }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let directory = null;
|
|
36
|
+
function storeDirectory() {
|
|
37
|
+
if (directory != null) return directory;
|
|
38
|
+
sweepScanDirectories();
|
|
39
|
+
directory = mkdtempSync(join(tmpdir(), `supercode-claude-scans-${process.pid}-${threadId}-`));
|
|
40
|
+
process.once('exit', () => { try { rmSync(directory, { recursive: true, force: true }); } catch { /* the next sweep takes it */ } });
|
|
41
|
+
return directory;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SCHEMA = `
|
|
45
|
+
PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA locking_mode=EXCLUSIVE; PRAGMA temp_store=FILE; PRAGMA cache_size=-1024;
|
|
46
|
+
CREATE TABLE records(line INTEGER PRIMARY KEY, offset INTEGER NOT NULL, length INTEGER NOT NULL, assistant INTEGER NOT NULL,
|
|
47
|
+
error INTEGER NOT NULL, message_id TEXT, hash TEXT NOT NULL) STRICT;
|
|
48
|
+
CREATE TABLE nodes(line INTEGER PRIMARY KEY, uuid TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, parent TEXT, sidechain INTEGER NOT NULL,
|
|
49
|
+
message_id TEXT, tool_result INTEGER NOT NULL, compact TEXT) STRICT;
|
|
50
|
+
CREATE TABLE first_lines(message_id TEXT PRIMARY KEY, line INTEGER NOT NULL) STRICT, WITHOUT ROWID;
|
|
51
|
+
CREATE TABLE residue(line INTEGER PRIMARY KEY, offset INTEGER NOT NULL, length INTEGER NOT NULL, entry TEXT NOT NULL) STRICT;
|
|
52
|
+
CREATE TABLE made(mark TEXT PRIMARY KEY, body TEXT NOT NULL) STRICT, WITHOUT ROWID;
|
|
53
|
+
CREATE TABLE positions(position INTEGER PRIMARY KEY, grp INTEGER NOT NULL, ordinal INTEGER NOT NULL, mark TEXT NOT NULL, synthetic TEXT) STRICT;
|
|
54
|
+
CREATE TABLE groups(grp INTEGER PRIMARY KEY, lines TEXT NOT NULL, size INTEGER NOT NULL) STRICT;
|
|
55
|
+
CREATE TABLE tools(id TEXT PRIMARY KEY, call INTEGER NOT NULL, result INTEGER NOT NULL) STRICT, WITHOUT ROWID;`;
|
|
56
|
+
|
|
57
|
+
/** A duplicate uuid, as the replay graph names it (ClaudeReplayIndex.observe). */
|
|
58
|
+
const duplicate = (uuid) => Object.assign(new Error(`cannot reconstruct lossless Claude continuation: duplicate uuid \`${uuid}\` in Claude transcript`), { code: 'invalid_session' });
|
|
59
|
+
|
|
60
|
+
export class ScanStore {
|
|
61
|
+
constructor() {
|
|
62
|
+
this.path = join(storeDirectory(), `${randomUUID()}.sqlite`);
|
|
63
|
+
this.db = new DatabaseSync(this.path);
|
|
64
|
+
this.db.exec(SCHEMA);
|
|
65
|
+
const prepare = (sql) => this.db.prepare(sql);
|
|
66
|
+
this.statements = {
|
|
67
|
+
record: prepare('INSERT INTO records VALUES (?,?,?,?,?,?,?)'),
|
|
68
|
+
recordAt: prepare('SELECT offset,length,assistant,error,message_id,hash FROM records WHERE line=?'),
|
|
69
|
+
records: prepare('SELECT line,offset,length,assistant,error,message_id,hash FROM records ORDER BY line'),
|
|
70
|
+
node: prepare('INSERT INTO nodes VALUES (?,?,?,?,?,?,?,?)'),
|
|
71
|
+
nodeByUuid: prepare('SELECT line FROM nodes WHERE uuid=?'),
|
|
72
|
+
nodeLine: prepare('SELECT line,parent FROM nodes WHERE uuid=?'),
|
|
73
|
+
nodes: prepare('SELECT line,uuid,kind,parent,sidechain,message_id,tool_result,compact FROM nodes ORDER BY line'),
|
|
74
|
+
firstLine: prepare('INSERT OR IGNORE INTO first_lines VALUES (?,?)'),
|
|
75
|
+
firstLineOf: prepare('SELECT line FROM first_lines WHERE message_id=?'),
|
|
76
|
+
residue: prepare('INSERT INTO residue VALUES (?,?,?,?)'),
|
|
77
|
+
residues: prepare('SELECT line,offset,length,entry FROM residue ORDER BY line'),
|
|
78
|
+
residueCount: prepare('SELECT count(*) AS count FROM residue'),
|
|
79
|
+
made: prepare('INSERT OR REPLACE INTO made VALUES (?,?)'),
|
|
80
|
+
madeOf: prepare('SELECT body FROM made WHERE mark=?'),
|
|
81
|
+
position: prepare('INSERT INTO positions VALUES (?,?,?,?,?)'),
|
|
82
|
+
positionAt: prepare('SELECT grp,ordinal,mark,synthetic FROM positions WHERE position=?'),
|
|
83
|
+
marksFrom: prepare('SELECT mark FROM positions WHERE position>=? AND position<? ORDER BY position'),
|
|
84
|
+
group: prepare('INSERT INTO groups VALUES (?,?,?)'),
|
|
85
|
+
groupAt: prepare('SELECT lines,size FROM groups WHERE grp=?'),
|
|
86
|
+
tool: prepare('INSERT INTO tools VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET call=max(call,excluded.call),result=max(result,excluded.result)'),
|
|
87
|
+
toolOf: prepare('SELECT call,result FROM tools WHERE id=?'),
|
|
88
|
+
};
|
|
89
|
+
this.depth = 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A transaction across a pass's awaited reads: `end` commits it, or rolls it back when the pass failed. */
|
|
93
|
+
begin() { if (this.depth++ === 0) this.db.exec('BEGIN'); }
|
|
94
|
+
end(failed) { if (--this.depth === 0) this.db.exec(failed ? 'ROLLBACK' : 'COMMIT'); }
|
|
95
|
+
|
|
96
|
+
/** `work` in one transaction (nested calls join the outer one). */
|
|
97
|
+
batch(work) {
|
|
98
|
+
if (this.depth++ === 0) this.db.exec('BEGIN');
|
|
99
|
+
let failed = false;
|
|
100
|
+
try { return work(); } catch (error) { failed = true; throw error; }
|
|
101
|
+
finally { if (--this.depth === 0) this.db.exec(failed ? 'ROLLBACK' : 'COMMIT'); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
addRecord(line, facts) {
|
|
105
|
+
this.statements.record.run(line, facts.offset, facts.length, facts.assistant ? 1 : 0, facts.error ? 1 : 0, facts.messageId ?? null, facts.hash);
|
|
106
|
+
if (facts.assistant && facts.messageId != null) this.statements.firstLine.run(facts.messageId, line);
|
|
107
|
+
}
|
|
108
|
+
recordAt(line) {
|
|
109
|
+
const row = this.statements.recordAt.get(line);
|
|
110
|
+
return row == null ? undefined : { offset: row.offset, length: row.length, assistant: row.assistant === 1, error: row.error === 1, messageId: row.message_id, hash: row.hash };
|
|
111
|
+
}
|
|
112
|
+
/** Every record, by line: what a whole replay reads. */
|
|
113
|
+
allRecords() {
|
|
114
|
+
const records = new Map();
|
|
115
|
+
for (const row of this.statements.records.iterate()) records.set(row.line, { offset: row.offset, length: row.length, assistant: row.assistant === 1, error: row.error === 1, messageId: row.message_id, hash: row.hash });
|
|
116
|
+
return records;
|
|
117
|
+
}
|
|
118
|
+
addNode(node) {
|
|
119
|
+
try { this.statements.node.run(node.lineIndex, node.uuid, node.kind, node.parent ?? null, node.sidechain ? 1 : 0, node.messageId ?? null, node.toolResult ? 1 : 0, node.compact == null ? null : JSON.stringify(node.compact)); }
|
|
120
|
+
catch (error) { if (/UNIQUE/.test(error.message)) throw duplicate(node.uuid); throw error; }
|
|
121
|
+
}
|
|
122
|
+
hasUuid(uuid) { return this.statements.nodeByUuid.get(uuid) != null; }
|
|
123
|
+
/** The line of the record `uuid` names, and its parent's uuid, or null. */
|
|
124
|
+
nodeOf(uuid) { const row = this.statements.nodeLine.get(uuid); return row == null ? null : { line: row.line, parent: row.parent }; }
|
|
125
|
+
/** Every replayed node, by line: what a whole replay's graph is built from. */
|
|
126
|
+
*allNodes() {
|
|
127
|
+
for (const row of this.statements.nodes.iterate()) {
|
|
128
|
+
yield { lineIndex: row.line, uuid: row.uuid, kind: row.kind, parent: row.parent, sidechain: row.sidechain === 1, messageId: row.message_id,
|
|
129
|
+
toolResult: row.tool_result === 1, compact: row.compact == null ? null : JSON.parse(row.compact) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
firstLineOf(messageId) { return this.statements.firstLineOf.get(messageId)?.line; }
|
|
133
|
+
addResidue(line, offset, length, entry) { this.statements.residue.run(line, offset, length, stringifyNativeJson(entry)); }
|
|
134
|
+
*residues() { for (const row of this.statements.residues.iterate()) yield [row.line, { entry: parseNativeJson(row.entry), offset: row.offset, length: row.length }]; }
|
|
135
|
+
get residueCount() { return this.statements.residueCount.get().count; }
|
|
136
|
+
|
|
137
|
+
/** A group's decoded skeletons, kept by the group's mark. */
|
|
138
|
+
addMade(mark, decoded) { this.statements.made.run(mark, stringifyNativeJson(decoded)); }
|
|
139
|
+
madeOf(mark) {
|
|
140
|
+
const row = this.statements.madeOf.get(mark);
|
|
141
|
+
if (row == null) return undefined;
|
|
142
|
+
// a skeleton's metadata is a null-prototype object, as decoding leaves it
|
|
143
|
+
return parseNativeJson(row.body).map(({ model, message }) => ({ model, message: { ...message, metadata: Object.create(null) } }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The settled prefix: each message's place (its group and its place in it, or the message itself), its mark, each
|
|
147
|
+
* group's records and size, and the tool calls made and answered. */
|
|
148
|
+
clearSettled() { this.db.exec('DELETE FROM positions; DELETE FROM groups; DELETE FROM tools;'); }
|
|
149
|
+
addPosition(position, group, ordinal, mark, synthetic) { this.statements.position.run(position, group, ordinal, mark, synthetic == null ? null : stringifyNativeJson(synthetic)); }
|
|
150
|
+
positionAt(position) {
|
|
151
|
+
const row = this.statements.positionAt.get(position);
|
|
152
|
+
return { group: row.grp, ordinal: row.ordinal, mark: row.mark, synthetic: row.synthetic == null ? null : parseNativeJson(row.synthetic) };
|
|
153
|
+
}
|
|
154
|
+
*marksFrom(from, to) { for (const row of this.statements.marksFrom.iterate(from, to)) yield row.mark; }
|
|
155
|
+
addGroup(group, lines, size) { this.statements.group.run(group, JSON.stringify(lines), size); }
|
|
156
|
+
groupAt(group) { const row = this.statements.groupAt.get(group); return { lines: JSON.parse(row.lines), size: row.size }; }
|
|
157
|
+
addTool(id, call, result) { this.statements.tool.run(id, call ? 1 : 0, result ? 1 : 0); }
|
|
158
|
+
tool(id) { const row = this.statements.toolOf.get(id); return row == null ? null : { call: row.call === 1, result: row.result === 1 }; }
|
|
159
|
+
|
|
160
|
+
close() {
|
|
161
|
+
try { this.db.close(); } catch { /* already closed */ }
|
|
162
|
+
try { rmSync(this.path, { force: true }); } catch { /* the directory goes with the process */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
package/session-claude.mjs
CHANGED
|
@@ -12,6 +12,15 @@ export function captureClaudeMeta(value, meta, rawLine) {
|
|
|
12
12
|
meta.session_id ??= stringField(value, 'sessionId');
|
|
13
13
|
meta.cwd ??= stringField(value, 'cwd');
|
|
14
14
|
meta.model ??= stringField(field(value, 'message'), 'model');
|
|
15
|
+
// The native API-error assistant is deliberately absent from normalized
|
|
16
|
+
// conversation messages. Retain its bounded outcome separately: a live
|
|
17
|
+
// terminal is not evidence that its first request could run.
|
|
18
|
+
if (meta.first_response == null && stringField(value, 'type') === 'assistant' && field(value, 'isSidechain') === false
|
|
19
|
+
&& stringField(value, 'sessionId') === meta.session_id) {
|
|
20
|
+
const failed = field(value, 'isApiErrorMessage') === true, code = stringField(value, 'error');
|
|
21
|
+
meta.first_response = { outcome: failed ? 'error' : 'response', at: stringField(value, 'timestamp'),
|
|
22
|
+
record_id: stringField(value, 'uuid'), error_code: failed && /^[a-z][a-z0-9_]{0,79}$/.test(code ?? '') ? code : null };
|
|
23
|
+
}
|
|
15
24
|
if (stringField(value, 'type') === 'fork-context-ref' && !Object.hasOwn(meta.lineage, 'claude_fork_context_ref_raw')) meta.lineage.claude_fork_context_ref_raw = rawLine;
|
|
16
25
|
}
|
|
17
26
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type CodexActivityStatus = 'running' | 'idle' | 'busy' | 'waiting';
|
|
2
|
+
export class CodexLifecycleCursor {
|
|
3
|
+
read(path: string): 'busy' | 'idle' | null;
|
|
4
|
+
turnStart(): { trigger: string | null; started_at_ms: number | null } | null;
|
|
5
|
+
}
|
|
6
|
+
export function reconcileCodexOwnership(previous: Iterable<string>, observed: Iterable<string>, misses: Map<string, number>): string[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Incremental Codex lifecycle evidence. A cursor owns no process or file:
|
|
2
|
+
// every sample opens its named rollout read-only and closes it before return.
|
|
3
|
+
// A kept cursor reads only what was appended since its last sample, and answers
|
|
4
|
+
// what a fresh scan of the whole file answers: its offset stops at the end of the
|
|
5
|
+
// last complete line, so a record still being written is read whole next time; a
|
|
6
|
+
// replaced file (another identity) or one shorter than, or rewritten before, the
|
|
7
|
+
// offset is scanned again from its start.
|
|
8
|
+
import { openSync, fstatSync, readSync, closeSync } from 'node:fs';
|
|
9
|
+
import { parseNativeJson, nativeInteger, setNativeInteger, cloneNativeJson } from './native-json.mjs';
|
|
10
|
+
import { field, stringField } from './session-codec-utils.mjs';
|
|
11
|
+
|
|
12
|
+
const BLOCK_BYTES = 1024n * 1024n, OVERLAP_BYTES = 64n * 1024n, TAIL_BYTES = 64n * 1024n, MARK_BYTES = 64n;
|
|
13
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
14
|
+
const boundaries = ['"type":"task_started"', '"type":"task_complete"', '"type":"turn_aborted"'];
|
|
15
|
+
|
|
16
|
+
function lifecycleInTail(bytes, startsAtBoundary) {
|
|
17
|
+
const newline = bytes.indexOf(10), offset = startsAtBoundary ? 0 : newline < 0 ? bytes.length : newline + 1;
|
|
18
|
+
let text; try { text = decoder.decode(bytes.subarray(offset)); } catch { return null; }
|
|
19
|
+
let searchEnd = text.length;
|
|
20
|
+
while (searchEnd > 0) {
|
|
21
|
+
const before = text.slice(0, searchEnd), candidate = Math.max(...boundaries.map(boundary => before.lastIndexOf(boundary)));
|
|
22
|
+
if (candidate < 0) break;
|
|
23
|
+
const start = text.lastIndexOf('\n', candidate) + 1, next = text.indexOf('\n', candidate), end = next < 0 ? text.length : next;
|
|
24
|
+
try {
|
|
25
|
+
const event = parseNativeJson(text.slice(start, end));
|
|
26
|
+
if (stringField(event, 'type') === 'event_msg') {
|
|
27
|
+
const payload = field(event, 'payload'), kind = stringField(payload, 'type');
|
|
28
|
+
if (kind === 'task_started') {
|
|
29
|
+
const turn = { trigger: stringField(field(payload, 'turn_attribution'), 'turn_trigger'), started_at_ms: null };
|
|
30
|
+
const seconds = nativeInteger(payload, 'started_at');
|
|
31
|
+
if (seconds != null && seconds <= 0xffffffffffffffffn / 1000n) setNativeInteger(turn, 'started_at_ms', seconds * 1000n);
|
|
32
|
+
return { status: 'busy', turn };
|
|
33
|
+
}
|
|
34
|
+
if (kind === 'task_complete' || kind === 'turn_aborted') return { status: 'idle', turn: null };
|
|
35
|
+
}
|
|
36
|
+
} catch { /* malformed candidates establish no lifecycle boundary */ }
|
|
37
|
+
searchEnd = candidate;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readExact(fd, bytes, position) {
|
|
43
|
+
let read = 0;
|
|
44
|
+
while (read < bytes.length) {
|
|
45
|
+
const count = readSync(fd, bytes, read, bytes.length - read, position + BigInt(read));
|
|
46
|
+
// a file cut short under the read establishes nothing this sample
|
|
47
|
+
if (!count) throw new Error('rollout ended before its stated length');
|
|
48
|
+
read += count;
|
|
49
|
+
}
|
|
50
|
+
return bytes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function latestBetween(fd, floor, upper) {
|
|
54
|
+
let end = upper;
|
|
55
|
+
while (end > floor) {
|
|
56
|
+
const start = end - floor > BLOCK_BYTES ? end - BLOCK_BYTES : floor;
|
|
57
|
+
const status = lifecycleInTail(readExact(fd, Buffer.alloc(Number(end - start)), start), start === floor);
|
|
58
|
+
if (status != null) return status;
|
|
59
|
+
if (start === floor) break;
|
|
60
|
+
end = start + OVERLAP_BYTES;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The end of the last complete line in [floor, upper), or `floor` when it holds none. */
|
|
66
|
+
function completeEnd(fd, floor, upper) {
|
|
67
|
+
let end = upper;
|
|
68
|
+
while (end > floor) {
|
|
69
|
+
const start = end - floor > TAIL_BYTES ? end - TAIL_BYTES : floor, bytes = readExact(fd, Buffer.alloc(Number(end - start)), start);
|
|
70
|
+
const newline = bytes.lastIndexOf(10);
|
|
71
|
+
if (newline >= 0) return start + BigInt(newline) + 1n;
|
|
72
|
+
end = start;
|
|
73
|
+
}
|
|
74
|
+
return floor;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Keep a proven boundary through appends without re-reading earlier records. */
|
|
78
|
+
export class CodexLifecycleCursor {
|
|
79
|
+
#identity = null; #offset = 0n; #mark = null; #status = null; #turn = null; #tail = null;
|
|
80
|
+
#reset() { this.#offset = 0n; this.#mark = null; this.#status = null; this.#turn = null; }
|
|
81
|
+
/** The latest boundary's open turn, from the same incremental read. */
|
|
82
|
+
turnStart() { return cloneNativeJson(this.#tail ? this.#tail.turn : this.#turn); }
|
|
83
|
+
read(path) {
|
|
84
|
+
let fd;
|
|
85
|
+
try {
|
|
86
|
+
fd = openSync(path, 'r'); const stat = fstatSync(fd, { bigint: true }), length = stat.size, identity = `${stat.dev}:${stat.ino}`;
|
|
87
|
+
if (identity !== this.#identity || length < this.#offset) { this.#reset(); this.#identity = identity; }
|
|
88
|
+
// the bytes just before the offset, read again: a file rewritten in place (same identity, as long or longer) differs there
|
|
89
|
+
if (this.#mark != null && !readExact(fd, Buffer.alloc(this.#mark.length), this.#offset - BigInt(this.#mark.length)).equals(this.#mark)) this.#reset();
|
|
90
|
+
this.#tail = null;
|
|
91
|
+
if (length > this.#offset) {
|
|
92
|
+
const complete = completeEnd(fd, this.#offset, length);
|
|
93
|
+
const found = complete > this.#offset ? latestBetween(fd, this.#offset, complete) : null;
|
|
94
|
+
// a line not yet ended is read with the next sample too: its boundary answers this one but is not kept
|
|
95
|
+
const tail = complete < length ? lifecycleInTail(readExact(fd, Buffer.alloc(Number(length - complete)), complete), true) : null;
|
|
96
|
+
if (found != null) { this.#status = found.status; this.#turn = found.turn; }
|
|
97
|
+
if (complete > this.#offset) {
|
|
98
|
+
const kept = complete < MARK_BYTES ? complete : MARK_BYTES;
|
|
99
|
+
this.#mark = readExact(fd, Buffer.alloc(Number(kept)), complete - kept); this.#offset = complete;
|
|
100
|
+
}
|
|
101
|
+
this.#tail = tail;
|
|
102
|
+
}
|
|
103
|
+
return this.#tail ? this.#tail.status : this.#status;
|
|
104
|
+
} catch { this.#tail = null; return null; }
|
|
105
|
+
finally { if (fd != null) { try { closeSync(fd); } catch { /* the cursor keeps no descriptor */ } } }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One missed process snapshot retains ownership; the second retires it. */
|
|
110
|
+
export function reconcileCodexOwnership(previous, observed, misses) {
|
|
111
|
+
const current = new Set(observed), before = new Set(previous), retained = new Set(current);
|
|
112
|
+
for (const path of current) misses.delete(path);
|
|
113
|
+
for (const path of before) {
|
|
114
|
+
if (current.has(path)) continue;
|
|
115
|
+
const count = Math.min(255, (misses.get(path) ?? 0) + 1);
|
|
116
|
+
if (count < 2) { misses.set(path, count); retained.add(path); } else misses.delete(path);
|
|
117
|
+
}
|
|
118
|
+
for (const path of misses.keys()) if (!before.has(path) || current.has(path)) misses.delete(path);
|
|
119
|
+
return [...retained].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
120
|
+
}
|
package/session-codex-window.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { detectSessionSource } from './session-source.mjs';
|
|
|
11
11
|
import { codexDisplayFold } from './session-codex-display.mjs';
|
|
12
12
|
import { clearCodexBookkeeping } from './session-codex-items.mjs';
|
|
13
13
|
import { AppendScan, shortHash } from './session-append-scan.mjs';
|
|
14
|
+
import { MARK_CHECKPOINT, chainMark, markedFrom } from './session-claude-index.mjs';
|
|
14
15
|
|
|
15
16
|
// What a message of the fold is made of, for a message held whole and for one kept without what it says: the records
|
|
16
17
|
// that made it (where each stands: the one that made it, then each whose call joined its open turn), its place among
|
|
@@ -129,7 +130,7 @@ class CodexDisplayScan extends AppendScan {
|
|
|
129
130
|
}
|
|
130
131
|
for await (const line of sessionFileLines(reader, this.bytes)) {
|
|
131
132
|
if (!line.terminated) break;
|
|
132
|
-
await this.#take(this.state, line, reader); this.
|
|
133
|
+
await this.#take(this.state, line, reader); this.took(line.bytes, line.offset + line.length); this.bytes = line.offset + line.length;
|
|
133
134
|
}
|
|
134
135
|
await this.settle(reader, from);
|
|
135
136
|
}
|
|
@@ -168,15 +169,34 @@ async function scanOf(path, reader, anchor, first) {
|
|
|
168
169
|
return scan;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
/** The marks a read lists after the first place the reader's (`known`) may differ: all of them when it names none. */
|
|
173
|
+
function listed(marks, known) {
|
|
174
|
+
const from = known == null ? 0 : markedFrom(markChainOf(marks), known);
|
|
175
|
+
return { marks_from: from, tail_marks: from ? marks.slice(from) : marks };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A read's marks as their chain (chainMark), for markedFrom: how many, and the digest of the first `count`. */
|
|
179
|
+
function markChainOf(marks) {
|
|
180
|
+
const checkpoints = []; let digest = '';
|
|
181
|
+
for (let at = 0; at < marks.length; at++) { digest = chainMark(digest, marks[at]); if ((at + 1) % MARK_CHECKPOINT === 0) checkpoints.push(digest); }
|
|
182
|
+
return { total: marks.length, digestAt(count) {
|
|
183
|
+
let at = Math.floor(count / MARK_CHECKPOINT) * MARK_CHECKPOINT, chain = at ? checkpoints[at / MARK_CHECKPOINT - 1] : '';
|
|
184
|
+
for (; at < count; at++) chain = chainMark(chain, marks[at]);
|
|
185
|
+
return chain;
|
|
186
|
+
} };
|
|
187
|
+
}
|
|
188
|
+
|
|
171
189
|
/**
|
|
172
190
|
* A window of a Codex rollout's display history as read from the byte `anchor` (loadSessionDisplayFrom), from the
|
|
173
191
|
* message at place `from`: at most `messages` of them and `bytes` of rollout (one message at least), with each one's
|
|
174
|
-
* mark and, when `marks` is asked, the
|
|
192
|
+
* mark and, when `marks` is asked, the marks after the first place where those the reader holds (`known`, as
|
|
193
|
+
* loadClaudeRecordWindow names them) differ from these: `marks_from` and `tail_marks` (from the first when nothing is
|
|
194
|
+
* named), so a pass over a long rollout lists what changed, not every message. The rollout is read a line at a time and
|
|
175
195
|
* only as far as it was not read before; a message is the same one the whole read answers at that place, and its
|
|
176
196
|
* mark is the digest of it: while it stands at a place, so does what a reader took of that message there. Null when
|
|
177
197
|
* the locator names anything else, which is loaded as its document.
|
|
178
198
|
*/
|
|
179
|
-
export async function loadCodexRecordWindow(locator, anchor, { from, bytes = null, messages = null, marks = false }, { metrics } = {}) {
|
|
199
|
+
export async function loadCodexRecordWindow(locator, anchor, { from, bytes = null, messages = null, marks = false, known = null }, { metrics } = {}) {
|
|
180
200
|
const { path, kind } = locator.storage;
|
|
181
201
|
if (kind !== 'file' || locator.harness === 'hermes' || anchor === 0 || isCompressedSessionPath(path)) return null;
|
|
182
202
|
if ((await sourceStat(path))?.isDirectory() || await looksLikeSessionSqlite(path, metrics)) return null;
|
|
@@ -206,7 +226,7 @@ export async function loadCodexRecordWindow(locator, anchor, { from, bytes = nul
|
|
|
206
226
|
session.load_residue = ['display history is a bounded native-record projection, not resumable model context', `older native records remain before this display anchor (${anchor})`];
|
|
207
227
|
return { session: { ...normalizedSessionJson(session), raw_record_count: state.fold.recordCount,
|
|
208
228
|
resolved_profile: recordedSessionProfile(session, { model: view.model, version: null }), resolved_profile_history: recordedSessionProfileHistory(session),
|
|
209
|
-
window: { offset, returned: end - offset, total_messages: total, marks: view.marks.slice(offset, end), ...(marks ?
|
|
229
|
+
window: { offset, returned: end - offset, total_messages: total, marks: view.marks.slice(offset, end), ...(marks ? listed(view.marks, known) : {}) } } };
|
|
210
230
|
} catch (error) { scans.delete(path); throw error; }
|
|
211
231
|
}, metrics)).result;
|
|
212
232
|
}
|