@cjhyy/code-shell-core 0.9.5 → 0.9.7

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.
Files changed (89) hide show
  1. package/dist/automation/scheduler.d.ts +3 -0
  2. package/dist/automation/scheduler.js +21 -0
  3. package/dist/context/manager.d.ts +8 -2
  4. package/dist/context/manager.js +20 -4
  5. package/dist/context/notes.d.ts +39 -0
  6. package/dist/context/notes.js +314 -0
  7. package/dist/credentials/access.d.ts +9 -1
  8. package/dist/credentials/access.js +15 -4
  9. package/dist/credentials/store.d.ts +11 -0
  10. package/dist/credentials/store.js +44 -9
  11. package/dist/credentials/types.d.ts +4 -0
  12. package/dist/credentials/types.js +4 -0
  13. package/dist/credentials/use-credential-tool.js +12 -2
  14. package/dist/engine/engine-workspace-authority.js +10 -3
  15. package/dist/engine/engine.d.ts +8 -4
  16. package/dist/engine/engine.js +115 -15
  17. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  18. package/dist/engine/run-goal.js +7 -3
  19. package/dist/engine/run-tooling.d.ts +3 -0
  20. package/dist/engine/run-tooling.js +25 -23
  21. package/dist/engine/run-types.d.ts +20 -0
  22. package/dist/engine/run-workspace.js +5 -21
  23. package/dist/engine/subagent-spawner.d.ts +3 -0
  24. package/dist/engine/subagent-spawner.js +48 -17
  25. package/dist/engine/turn-loop.d.ts +14 -4
  26. package/dist/engine/turn-loop.js +120 -28
  27. package/dist/engine/types.d.ts +19 -0
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +2 -2
  30. package/dist/links/index.d.ts +1 -0
  31. package/dist/links/index.js +1 -0
  32. package/dist/links/link-action-tool.d.ts +2 -1
  33. package/dist/links/link-action-tool.js +80 -44
  34. package/dist/links/status.d.ts +53 -0
  35. package/dist/links/status.js +175 -0
  36. package/dist/llm/prompt-cache.d.ts +35 -3
  37. package/dist/llm/prompt-cache.js +63 -3
  38. package/dist/llm/providers/openai.d.ts +3 -0
  39. package/dist/llm/providers/openai.js +57 -14
  40. package/dist/prompt/section-loader.js +1 -0
  41. package/dist/prompt/sections/browser.md +4 -2
  42. package/dist/prompt/sections/context-notes.md +9 -0
  43. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  44. package/dist/protocol/background-result-wakeup.js +76 -38
  45. package/dist/protocol/chat-session-manager.d.ts +7 -1
  46. package/dist/protocol/chat-session-manager.js +44 -8
  47. package/dist/protocol/chat-session.d.ts +2 -0
  48. package/dist/protocol/chat-session.js +4 -1
  49. package/dist/protocol/server.d.ts +4 -0
  50. package/dist/protocol/server.js +355 -62
  51. package/dist/protocol/session-message-result.d.ts +12 -0
  52. package/dist/protocol/session-message-result.js +42 -0
  53. package/dist/protocol/session-message-workspace.d.ts +21 -0
  54. package/dist/protocol/session-message-workspace.js +57 -0
  55. package/dist/protocol/types.d.ts +4 -0
  56. package/dist/session/session-manager.js +8 -7
  57. package/dist/session/session-message.d.ts +17 -2
  58. package/dist/session/transcript.d.ts +17 -0
  59. package/dist/session/transcript.js +271 -16
  60. package/dist/settings/schema.d.ts +9 -0
  61. package/dist/settings/schema.js +4 -0
  62. package/dist/themes/paths.js +20 -1
  63. package/dist/tool-system/browser-bridge.d.ts +21 -1
  64. package/dist/tool-system/browser-discovery.d.ts +6 -0
  65. package/dist/tool-system/browser-discovery.js +17 -0
  66. package/dist/tool-system/builtin/agent.js +23 -9
  67. package/dist/tool-system/builtin/browser-tools.js +30 -9
  68. package/dist/tool-system/builtin/context-notes.d.ts +12 -0
  69. package/dist/tool-system/builtin/context-notes.js +188 -0
  70. package/dist/tool-system/builtin/index.js +50 -3
  71. package/dist/tool-system/builtin/mcp-tools.d.ts +5 -3
  72. package/dist/tool-system/builtin/mcp-tools.js +10 -10
  73. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  74. package/dist/tool-system/builtin/tool-search.js +15 -3
  75. package/dist/tool-system/context.d.ts +9 -0
  76. package/dist/tool-system/executor.js +5 -3
  77. package/dist/tool-system/mcp-compat.d.ts +3 -0
  78. package/dist/tool-system/mcp-compat.js +51 -0
  79. package/dist/tool-system/mcp-manager.d.ts +27 -26
  80. package/dist/tool-system/mcp-manager.js +273 -111
  81. package/dist/tool-system/mcp-workspace.d.ts +18 -0
  82. package/dist/tool-system/mcp-workspace.js +56 -0
  83. package/dist/tool-system/permission.d.ts +6 -0
  84. package/dist/tool-system/permission.js +45 -9
  85. package/dist/tool-system/plan-mode-allowlist.js +5 -0
  86. package/dist/tool-system/sandbox/seatbelt.js +71 -2
  87. package/dist/tool-system/session-tool-host.js +9 -1
  88. package/dist/types.d.ts +4 -2
  89. package/package.json +1 -1
@@ -0,0 +1,12 @@
1
+ import type { EngineResult } from "../engine/types.js";
2
+ import type { RouteSessionMessageInput } from "../session/session-message.js";
3
+ import type { ResultEnvelopeDraft } from "../tool-system/builtin/agent-notifications.js";
4
+ export interface SessionMessageOutcome {
5
+ status: "completed" | "failed" | "cancelled";
6
+ text: string;
7
+ error?: string;
8
+ }
9
+ export declare function sessionMessageFailure(error: unknown): SessionMessageOutcome;
10
+ /** A resolved Engine promise can still represent a refusal or failed run. */
11
+ export declare function sessionMessageOutcome(result: EngineResult): SessionMessageOutcome;
12
+ export declare function sessionMessageResultNotification(input: RouteSessionMessageInput, messageId: string, outcome: SessionMessageOutcome): ResultEnvelopeDraft;
@@ -0,0 +1,42 @@
1
+ export function sessionMessageFailure(error) {
2
+ return {
3
+ status: error instanceof Error && error.name === "AbortError" ? "cancelled" : "failed",
4
+ text: "",
5
+ error: error instanceof Error ? error.message : String(error),
6
+ };
7
+ }
8
+ /** A resolved Engine promise can still represent a refusal or failed run. */
9
+ export function sessionMessageOutcome(result) {
10
+ if (result.reason === "aborted_streaming" || result.reason === "aborted_tools") {
11
+ return {
12
+ status: "cancelled",
13
+ text: result.text,
14
+ error: result.text || "Target turn was cancelled.",
15
+ };
16
+ }
17
+ if (result.reason === "completed" && result.turnCount > 0) {
18
+ return { status: "completed", text: result.text };
19
+ }
20
+ const detail = result.text ||
21
+ (result.turnCount === 0 ? "Target turn did not start." : "Target turn did not complete.");
22
+ return { status: "failed", text: result.text, error: `${detail} (reason: ${result.reason})` };
23
+ }
24
+ export function sessionMessageResultNotification(input, messageId, outcome) {
25
+ return {
26
+ kind: "result",
27
+ from: { sessionId: input.target.sessionId, authority: "agent" },
28
+ to: { sessionId: input.sourceSessionId, authority: "system" },
29
+ correlationId: messageId,
30
+ delivery: "idle-drain",
31
+ payload: {
32
+ workId: messageId,
33
+ name: input.target.title,
34
+ description: `Reply to message ${messageId} from Session ${input.target.sessionId}: ${input.message.slice(0, 240)}`,
35
+ workKind: "agent",
36
+ status: outcome.status,
37
+ finalText: outcome.text,
38
+ ...(outcome.error ? { error: outcome.error } : {}),
39
+ finishedAt: Date.now(),
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,21 @@
1
+ import type { SessionManager } from "../session/session-manager.js";
2
+ import { type WorkspaceContext } from "../workspace/workspace-context.js";
3
+ export interface SessionMessageWorkspace {
4
+ cwd: string;
5
+ workspaceContext?: WorkspaceContext;
6
+ projectTrusted?: boolean;
7
+ }
8
+ /**
9
+ * Protocol-only hosts can reuse already trusted mounted roots. Desktop should
10
+ * ask its project registry for fresh authority instead. Persisted Session ids,
11
+ * bindings and runtime roots always take precedence over a caller's catalog.
12
+ */
13
+ export declare function resolveSessionMessageWorkspace(input: {
14
+ sessionManager: Pick<SessionManager, "readSessionMainRoot" | "readSessionProjectBinding" | "getSessionWorkspace">;
15
+ sourceSessionId: string;
16
+ targetSessionId: string;
17
+ sourceWorkspace: SessionMessageWorkspace;
18
+ targetWorkspace?: SessionMessageWorkspace;
19
+ }): SessionMessageWorkspace & {
20
+ projectTrusted: boolean;
21
+ };
@@ -0,0 +1,57 @@
1
+ import { canonicalKey } from "../workspace/canonical-key.js";
2
+ import { createWorkspaceContext, validateWorkspaceContext, } from "../workspace/workspace-context.js";
3
+ /**
4
+ * Protocol-only hosts can reuse already trusted mounted roots. Desktop should
5
+ * ask its project registry for fresh authority instead. Persisted Session ids,
6
+ * bindings and runtime roots always take precedence over a caller's catalog.
7
+ */
8
+ export function resolveSessionMessageWorkspace(input) {
9
+ const { sessionManager, sourceSessionId, targetSessionId, sourceWorkspace, targetWorkspace } = input;
10
+ const sourceMain = sessionManager.readSessionMainRoot(sourceSessionId) ?? sourceWorkspace.cwd;
11
+ const targetMain = sessionManager.readSessionMainRoot(targetSessionId);
12
+ const sourceBinding = sessionManager.readSessionProjectBinding(sourceSessionId);
13
+ const targetBinding = sessionManager.readSessionProjectBinding(targetSessionId);
14
+ const cwd = sessionManager.getSessionWorkspace(targetSessionId)?.root ?? targetMain ?? sourceMain;
15
+ const sameMainRoot = !targetMain || canonicalKey(sourceMain) === canonicalKey(targetMain);
16
+ const projectTrusted = targetWorkspace?.projectTrusted ?? (sameMainRoot && sourceWorkspace.projectTrusted === true);
17
+ if (targetMain && !targetBinding) {
18
+ if (!sameMainRoot)
19
+ throw new Error("target Session does not share the source's authorized main root");
20
+ return { cwd, projectTrusted };
21
+ }
22
+ if (!targetBinding && !sourceBinding)
23
+ return { cwd, projectTrusted };
24
+ if (targetBinding && (!sourceBinding || targetBinding.projectId !== sourceBinding.projectId)) {
25
+ throw new Error("target Session project binding is not authorized by the source Session");
26
+ }
27
+ const binding = targetBinding ?? sourceBinding;
28
+ if (!sourceWorkspace.workspaceContext) {
29
+ throw new Error("cross-Session run requires an authoritative WorkspaceContext from the host");
30
+ }
31
+ const context = validateWorkspaceContext(sourceWorkspace.workspaceContext);
32
+ if (context.projectId !== sourceBinding?.projectId ||
33
+ context.sessionMainRootId !== sourceBinding.mainRootId) {
34
+ throw new Error("source WorkspaceContext does not match its persisted project binding");
35
+ }
36
+ // A source worktree is a runtime substitution, not a project mount that may
37
+ // be propagated into another Session's secondary roots.
38
+ const mountedRoots = context.roots.map((root) => root.id === sourceBinding.mainRootId ? { ...root, path: sourceMain } : { ...root });
39
+ const targetRoot = mountedRoots.find((root) => root.id === binding.mainRootId);
40
+ if (!targetRoot || (targetMain && canonicalKey(targetRoot.path) !== canonicalKey(targetMain))) {
41
+ throw new Error("target Session main root is not present in the host-authorized project roots");
42
+ }
43
+ return {
44
+ cwd,
45
+ projectTrusted,
46
+ workspaceContext: createWorkspaceContext({
47
+ projectId: context.projectId,
48
+ projectRevision: context.projectRevision,
49
+ sessionMainRootId: binding.mainRootId,
50
+ roots: mountedRoots.map((root) => ({
51
+ ...root,
52
+ path: root.id === binding.mainRootId ? cwd : root.path,
53
+ role: root.id === binding.mainRootId ? "primary" : "secondary",
54
+ })),
55
+ }),
56
+ };
57
+ }
@@ -13,6 +13,8 @@ import type { SessionMessageTarget } from "../session/session-message.js";
13
13
  export type PendingApprovalKind = "tool_approval" | "ask_user" | "internal";
14
14
  /** Resolver-free metadata retained beside a pending approval callback. */
15
15
  export interface PendingApprovalMetadata {
16
+ /** Child task that originated this request; sessionId remains its parent UI route. */
17
+ sourceSessionId?: string;
16
18
  sessionId: string;
17
19
  requestId: string;
18
20
  routeGeneration?: number;
@@ -198,6 +200,8 @@ export interface RunParams {
198
200
  * optional token/time budgets). Normalized at the engine run boundary.
199
201
  */
200
202
  goal?: string | import("../goal/lifecycle.js").GoalConfig;
203
+ /** Disable explicit, persisted, and configured Goal mode for this standalone turn. */
204
+ disableGoal?: boolean;
201
205
  }
202
206
  export interface RunResult {
203
207
  text: string;
@@ -160,6 +160,8 @@ const FORK_EVENT_POLICY = {
160
160
  tool_result: "copy",
161
161
  summary: "copy",
162
162
  context_transfer: "copy",
163
+ context_note: "copy",
164
+ context_checkpoint: "copy",
163
165
  range_archive: "copy",
164
166
  content_replace: "copy",
165
167
  file_history: "skip",
@@ -1922,13 +1924,12 @@ export function buildForkTranscript(sourceEvents, state) {
1922
1924
  forkedFrom: structuredClone(state.forkedFrom),
1923
1925
  },
1924
1926
  };
1925
- return [
1926
- meta,
1927
- ...sourceEvents.map((source) => ({
1928
- ...structuredClone(source),
1929
- id: nanoid(12),
1930
- })),
1931
- ];
1927
+ const copied = sourceEvents.map((source) => ({
1928
+ ...structuredClone(source),
1929
+ id: nanoid(12),
1930
+ }));
1931
+ Transcript.remapContextForkReferences(sourceEvents, copied);
1932
+ return [meta, ...copied];
1932
1933
  }
1933
1934
  function validateForkToolPairs(events) {
1934
1935
  const projectedUses = [];
@@ -9,14 +9,29 @@ export interface RouteSessionMessageInput {
9
9
  sourceSessionId: string;
10
10
  target: SessionMessageTarget;
11
11
  message: string;
12
+ /** Sender tool cancellation; never serialized or supplied by the model. */
13
+ signal?: AbortSignal;
12
14
  /** Full same-project catalog so the target can send a later message onward. */
13
15
  catalog: readonly SessionMessageTarget[];
14
16
  }
15
- export type SessionMessageRouter = (input: RouteSessionMessageInput) => Promise<void>;
17
+ /** One dispatch acknowledgement, not a subscription to future target turns. */
18
+ export interface SessionMessageReceipt {
19
+ messageId: string;
20
+ status: "queued" | "started" | "completed";
21
+ /** Present when the target finished before the dispatch returned. */
22
+ result?: {
23
+ text: string;
24
+ reason: import("../types.js").TerminalReason;
25
+ };
26
+ }
27
+ export type SessionMessageRouter = (input: RouteSessionMessageInput) => Promise<SessionMessageReceipt | void>;
16
28
  export interface SessionMessageToolService {
17
29
  targets: readonly SessionMessageTarget[];
18
30
  send(input: {
19
31
  targetSessionId: string;
20
32
  message: string;
21
- }): Promise<SessionMessageTarget>;
33
+ signal?: AbortSignal;
34
+ }): Promise<SessionMessageTarget & {
35
+ receipt?: SessionMessageReceipt;
36
+ }>;
22
37
  }
@@ -27,6 +27,16 @@ export interface SelectedContextRange {
27
27
  messages: Message[];
28
28
  sourceEventCount: number;
29
29
  }
30
+ /** Host-generated replay state. Models supply the note text, never this snapshot. */
31
+ export interface ContextCheckpointSnapshot {
32
+ version: 1;
33
+ noteId: string;
34
+ coveredThroughEventId: string;
35
+ messages: Message[];
36
+ clientMessageIds: Array<[string, number]>;
37
+ }
38
+ /** Complete, ordered provider pairs are required before a checkpoint can commit. */
39
+ export declare function hasCompleteContextToolPairs(messages: readonly Message[]): boolean;
30
40
  export type SummaryAppendMetadata = {
31
41
  fromTurn: number;
32
42
  toTurn: number;
@@ -59,8 +69,14 @@ export declare class Transcript {
59
69
  static inMemory(label: string): Transcript;
60
70
  /** Rehydrate a process-local fork without serializing its copied history. */
61
71
  static fromMemoryEvents(label: string, events: readonly TranscriptEvent[]): Transcript;
72
+ /** Preserve checkpoint provenance when a fork assigns fresh event ids. */
73
+ static remapContextForkReferences(sourceEvents: readonly TranscriptEvent[], copiedEvents: TranscriptEvent[]): void;
62
74
  isPersistent(): boolean;
63
75
  append(type: TranscriptEventType, data: Record<string, unknown>): TranscriptEvent;
76
+ /** Failed note/checkpoint writes must never change the active replay. */
77
+ appendContextNote(text: string, coveredThroughEventId: string): TranscriptEvent | undefined;
78
+ appendContextCheckpoint(snapshot: ContextCheckpointSnapshot): TranscriptEvent | undefined;
79
+ private appendDurableContextEvent;
64
80
  /**
65
81
  * Append a chat message to the transcript.
66
82
  *
@@ -148,6 +164,7 @@ export declare class Transcript {
148
164
  messages: Message[];
149
165
  liveIndexByClientMessageId: Map<string, number>;
150
166
  };
167
+ private contextReplayEvents;
151
168
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
152
169
  get turnNumber(): number;
153
170
  get eventCount(): number;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync, } from "node:fs";
6
6
  import { dirname } from "node:path";
7
+ import { createHash } from "node:crypto";
7
8
  import { nanoid } from "nanoid";
8
9
  import { logger } from "../logging/logger.js";
9
10
  const DEFAULT_CONTEXT_TAIL_SCAN_BYTES = 32 * 1024 * 1024;
@@ -33,6 +34,131 @@ function appendTranscriptLine(filePath, data) {
33
34
  const defaultTranscriptWriter = (filePath, data) => {
34
35
  appendTranscriptLine(filePath, data);
35
36
  };
37
+ function checkpointHash(snapshot) {
38
+ return createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
39
+ }
40
+ /** Complete, ordered provider pairs are required before a checkpoint can commit. */
41
+ export function hasCompleteContextToolPairs(messages) {
42
+ const pending = new Set();
43
+ const used = new Set();
44
+ for (const message of messages) {
45
+ if (pending.size > 0 &&
46
+ (message.role !== "user" ||
47
+ !Array.isArray(message.content) ||
48
+ message.content.length === 0 ||
49
+ message.content.some((block) => block.type !== "tool_result")))
50
+ return false;
51
+ if (!Array.isArray(message.content))
52
+ continue;
53
+ for (const block of message.content) {
54
+ if (block.type === "tool_use") {
55
+ if (message.role !== "assistant" || !block.id || used.has(block.id))
56
+ return false;
57
+ used.add(block.id);
58
+ pending.add(block.id);
59
+ }
60
+ else if (block.type === "tool_result") {
61
+ if (message.role !== "user" || !block.tool_use_id || !pending.delete(block.tool_use_id)) {
62
+ return false;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return pending.size === 0;
68
+ }
69
+ function readCheckpoint(event, precedingEvents) {
70
+ if (event.type !== "context_checkpoint")
71
+ return undefined;
72
+ const data = event.data;
73
+ if (!data ||
74
+ typeof data !== "object" ||
75
+ data.version !== 1 ||
76
+ typeof data.noteId !== "string" ||
77
+ typeof data.coveredThroughEventId !== "string" ||
78
+ !Array.isArray(data.messages) ||
79
+ data.messages.length === 0 ||
80
+ !Array.isArray(data.clientMessageIds))
81
+ return undefined;
82
+ const noteIndex = precedingEvents.findIndex((candidate) => candidate.id === data.noteId);
83
+ const cursorIndex = precedingEvents.findIndex((candidate) => candidate.id === data.coveredThroughEventId);
84
+ const note = precedingEvents[noteIndex];
85
+ if (cursorIndex < 0 ||
86
+ noteIndex <= cursorIndex ||
87
+ note?.type !== "context_note" ||
88
+ !note.data ||
89
+ typeof note.data.text !== "string" ||
90
+ note.data.text.trim().length === 0 ||
91
+ note.data.coveredThroughEventId !== data.coveredThroughEventId)
92
+ return undefined;
93
+ for (const value of data.messages) {
94
+ if (!value || typeof value !== "object")
95
+ return undefined;
96
+ const message = value;
97
+ if (!["user", "assistant", "system", "tool"].includes(message.role))
98
+ return undefined;
99
+ if (!validCheckpointContent(message.content))
100
+ return undefined;
101
+ }
102
+ const seen = new Set();
103
+ for (const pair of data.clientMessageIds) {
104
+ if (!Array.isArray(pair) ||
105
+ pair.length !== 2 ||
106
+ typeof pair[0] !== "string" ||
107
+ seen.has(pair[0]) ||
108
+ !Number.isSafeInteger(pair[1]) ||
109
+ pair[1] < 0 ||
110
+ pair[1] >= data.messages.length)
111
+ return undefined;
112
+ seen.add(pair[0]);
113
+ }
114
+ const snapshot = {
115
+ version: 1,
116
+ noteId: data.noteId,
117
+ coveredThroughEventId: data.coveredThroughEventId,
118
+ messages: data.messages,
119
+ clientMessageIds: data.clientMessageIds,
120
+ };
121
+ if (data.checksum !== checkpointHash(snapshot) ||
122
+ !hasCompleteContextToolPairs(snapshot.messages)) {
123
+ return undefined;
124
+ }
125
+ return snapshot;
126
+ }
127
+ function validCheckpointContent(content, depth = 0) {
128
+ if (typeof content === "string")
129
+ return true;
130
+ if (!Array.isArray(content) || depth > 8)
131
+ return false;
132
+ return content.every((value) => {
133
+ if (!value || typeof value !== "object" || Array.isArray(value))
134
+ return false;
135
+ const block = value;
136
+ switch (block.type) {
137
+ case "text":
138
+ case "reasoning":
139
+ return ((block.text === undefined || typeof block.text === "string") &&
140
+ (block.reasoningContent === undefined || typeof block.reasoningContent === "string"));
141
+ case "image":
142
+ return (block.source?.type === "base64" &&
143
+ typeof block.source.media_type === "string" &&
144
+ typeof block.source.data === "string");
145
+ case "tool_use":
146
+ return (depth === 0 &&
147
+ typeof block.id === "string" &&
148
+ (block.name === undefined || typeof block.name === "string") &&
149
+ (block.input === undefined ||
150
+ (block.input !== null &&
151
+ typeof block.input === "object" &&
152
+ !Array.isArray(block.input))));
153
+ case "tool_result":
154
+ return (depth === 0 &&
155
+ typeof block.tool_use_id === "string" &&
156
+ (block.content === undefined || validCheckpointContent(block.content, depth + 1)));
157
+ default:
158
+ return false;
159
+ }
160
+ });
161
+ }
36
162
  const CONTEXT_EVENT_TYPES = new Set([
37
163
  "message",
38
164
  "tool_use",
@@ -47,6 +173,18 @@ function isSyntheticInterruptedToolResult(event) {
47
173
  event.data.toolName === "unknown" &&
48
174
  event.data.error === INTERRUPTED_TOOL_RESULT_ERROR);
49
175
  }
176
+ function toolResultContentBlock(event) {
177
+ const { toolCallId, result, error, contentBlocks } = event.data;
178
+ return {
179
+ type: "tool_result",
180
+ tool_use_id: toolCallId,
181
+ content: error
182
+ ? `Error: ${error}`
183
+ : Array.isArray(contentBlocks) && contentBlocks.length > 0
184
+ ? structuredClone(contentBlocks)
185
+ : (result ?? "(no output)"),
186
+ };
187
+ }
50
188
  /**
51
189
  * Choose at most one result for every declared tool call. A real late result
52
190
  * wins over the legacy synthetic "interrupted" placeholder that an older
@@ -120,6 +258,40 @@ export class Transcript {
120
258
  transcript.loadEvents(events);
121
259
  return transcript;
122
260
  }
261
+ /** Preserve checkpoint provenance when a fork assigns fresh event ids. */
262
+ static remapContextForkReferences(sourceEvents, copiedEvents) {
263
+ if (!sourceEvents.some((event) => event.type === "context_note"))
264
+ return;
265
+ const eventIds = new Map(sourceEvents.map((event, index) => [event.id, copiedEvents[index].id]));
266
+ for (const [index, source] of sourceEvents.entries()) {
267
+ const copied = copiedEvents[index];
268
+ // An inherited note can cite old event ids. Resolve those aliases only
269
+ // against the copied prefix, never by reaching into the parent session.
270
+ copied.data.contextHistorySourceIds = [
271
+ ...(Array.isArray(source.data.contextHistorySourceIds)
272
+ ? source.data.contextHistorySourceIds.filter((id) => typeof id === "string")
273
+ : []),
274
+ source.id,
275
+ ];
276
+ if (source.type === "context_note") {
277
+ const cursor = source.data.coveredThroughEventId;
278
+ if (typeof cursor === "string" && eventIds.has(cursor)) {
279
+ copied.data.coveredThroughEventId = eventIds.get(cursor);
280
+ }
281
+ }
282
+ if (source.type !== "context_checkpoint")
283
+ continue;
284
+ const snapshot = readCheckpoint(source, sourceEvents.slice(0, index));
285
+ if (!snapshot)
286
+ continue; // Never turn a corrupt source checkpoint into a valid one.
287
+ const noteId = eventIds.get(snapshot.noteId);
288
+ const coveredThroughEventId = eventIds.get(snapshot.coveredThroughEventId);
289
+ if (!noteId || !coveredThroughEventId)
290
+ continue;
291
+ const remapped = { ...snapshot, noteId, coveredThroughEventId };
292
+ Object.assign(copied.data, remapped, { checksum: checkpointHash(remapped) });
293
+ }
294
+ }
123
295
  isPersistent() {
124
296
  return this.persistent;
125
297
  }
@@ -135,6 +307,41 @@ export class Transcript {
135
307
  this.flush(event);
136
308
  return event;
137
309
  }
310
+ /** Failed note/checkpoint writes must never change the active replay. */
311
+ appendContextNote(text, coveredThroughEventId) {
312
+ return this.appendDurableContextEvent("context_note", { text, coveredThroughEventId });
313
+ }
314
+ appendContextCheckpoint(snapshot) {
315
+ const frozen = structuredClone(snapshot);
316
+ const data = { ...frozen, checksum: checkpointHash(frozen) };
317
+ const candidate = {
318
+ id: "validation",
319
+ type: "context_checkpoint",
320
+ timestamp: Date.now(),
321
+ turnNumber: this.currentTurn,
322
+ data,
323
+ };
324
+ if (!readCheckpoint(candidate, this.events))
325
+ return undefined;
326
+ return this.appendDurableContextEvent("context_checkpoint", data);
327
+ }
328
+ appendDurableContextEvent(type, data) {
329
+ // A missing earlier event makes a durable cursor unreliable, even when
330
+ // this particular append would succeed.
331
+ if (this.dirty)
332
+ return undefined;
333
+ const event = {
334
+ id: nanoid(12),
335
+ type,
336
+ timestamp: Date.now(),
337
+ turnNumber: this.currentTurn,
338
+ data,
339
+ };
340
+ if (!this.flush(event))
341
+ return undefined;
342
+ this.events.push(event);
343
+ return event;
344
+ }
138
345
  /**
139
346
  * Append a chat message to the transcript.
140
347
  *
@@ -288,10 +495,11 @@ export class Transcript {
288
495
  * payloads, and transport metadata must not leak into them.
289
496
  */
290
497
  toMessagesWithIndex() {
498
+ const events = this.contextReplayEvents();
291
499
  const messages = [];
292
500
  const liveIndexByClientMessageId = new Map();
293
- const selectedToolResults = preferredToolResults(this.events);
294
- const hasRangeArchive = this.events.some((e) => e.type === "range_archive");
501
+ const selectedToolResults = preferredToolResults(events);
502
+ const hasRangeArchive = events.some((e) => e.type === "range_archive");
295
503
  const spansByFromId = new Map();
296
504
  let openingSpan;
297
505
  if (hasRangeArchive) {
@@ -302,7 +510,7 @@ export class Transcript {
302
510
  // while scanning forward, so it would never close — silently swallowing
303
511
  // the rest of the conversation. Fail open instead: ignore the marker.
304
512
  const firstIndexByClientId = new Map();
305
- for (const [index, event] of this.events.entries()) {
513
+ for (const [index, event] of events.entries()) {
306
514
  if (event.type === "message" && typeof event.data.clientMessageId === "string") {
307
515
  if (!firstIndexByClientId.has(event.data.clientMessageId)) {
308
516
  firstIndexByClientId.set(event.data.clientMessageId, index);
@@ -310,7 +518,7 @@ export class Transcript {
310
518
  }
311
519
  }
312
520
  const presentClientIds = new Set(firstIndexByClientId.keys());
313
- for (const event of this.events) {
521
+ for (const event of events) {
314
522
  if (event.type !== "range_archive")
315
523
  continue;
316
524
  const { summary, toClientMessageId, fromClientMessageId } = event.data;
@@ -351,7 +559,7 @@ export class Transcript {
351
559
  // preferred) real result landed outside it — would be an orphaned block
352
560
  // that breaks provider validation; skip it instead of emitting it.
353
561
  const emittedToolUseIds = new Set();
354
- for (const event of this.events) {
562
+ for (const event of events) {
355
563
  // Span bookkeeping runs on message events only: exit before entry so
356
564
  // adjacent spans (A.to === B.from) hand over on the boundary message.
357
565
  if (event.type === "message") {
@@ -387,7 +595,9 @@ export class Transcript {
387
595
  }
388
596
  }
389
597
  }
390
- messages.push({ role: role, content });
598
+ // Tool results can be merged into this array below. Never mutate
599
+ // the source event (or a checkpoint) during a read-only replay.
600
+ messages.push({ role: role, content: structuredClone(content) });
391
601
  break;
392
602
  }
393
603
  case "tool_use": {
@@ -402,18 +612,9 @@ export class Transcript {
402
612
  !emittedToolUseIds.has(eventToolCallId)) {
403
613
  break;
404
614
  }
405
- const { toolCallId, result, error, contentBlocks } = event.data;
406
615
  // Find if there's already a user message with tool_results to append to
407
616
  const lastMsg = messages[messages.length - 1];
408
- const block = {
409
- type: "tool_result",
410
- tool_use_id: toolCallId,
411
- content: error
412
- ? `Error: ${error}`
413
- : Array.isArray(contentBlocks) && contentBlocks.length > 0
414
- ? contentBlocks
415
- : (result ?? "(no output)"),
416
- };
617
+ const block = toolResultContentBlock(event);
417
618
  if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
418
619
  lastMsg.content.push(block);
419
620
  }
@@ -449,6 +650,55 @@ export class Transcript {
449
650
  }
450
651
  return { messages, liveIndexByClientMessageId };
451
652
  }
653
+ contextReplayEvents() {
654
+ for (let index = this.events.length - 1; index >= 0; index -= 1) {
655
+ const event = this.events[index];
656
+ if (event.type !== "context_checkpoint")
657
+ continue;
658
+ const snapshot = readCheckpoint(event, this.events.slice(0, index));
659
+ if (!snapshot)
660
+ continue; // Corrupt or truncated checkpoints fail open.
661
+ const clientIdByIndex = new Map(snapshot.clientMessageIds.map(([id, i]) => [i, id]));
662
+ const snapshotEvents = snapshot.messages.map((message, messageIndex) => ({
663
+ id: `${event.id}:${messageIndex}`,
664
+ type: "message",
665
+ timestamp: event.timestamp,
666
+ turnNumber: event.turnNumber,
667
+ data: {
668
+ role: message.role,
669
+ content: structuredClone(message.content),
670
+ ...(clientIdByIndex.has(messageIndex)
671
+ ? { clientMessageId: clientIdByIndex.get(messageIndex) }
672
+ : {}),
673
+ },
674
+ }));
675
+ const tail = this.events.slice(index + 1);
676
+ const lateResults = preferredToolResults([...snapshotEvents, ...tail]);
677
+ const completedIds = new Set();
678
+ for (const snapshotEvent of snapshotEvents) {
679
+ const content = snapshotEvent.data.content;
680
+ if (!Array.isArray(content))
681
+ continue;
682
+ snapshotEvent.data.content = content.map((block) => {
683
+ if (block.type !== "tool_result" || !block.tool_use_id)
684
+ return block;
685
+ completedIds.add(block.tool_use_id);
686
+ const late = lateResults.get(block.tool_use_id);
687
+ // Reconcile late duplicates in the original result position, so a
688
+ // result cannot become orphaned or split an unrelated later batch.
689
+ return late && !isSyntheticInterruptedToolResult(late)
690
+ ? toolResultContentBlock(late)
691
+ : block;
692
+ });
693
+ }
694
+ return [
695
+ ...snapshotEvents,
696
+ ...tail.filter((candidate) => candidate.type !== "tool_result" ||
697
+ !completedIds.has(candidate.data.toolCallId)),
698
+ ];
699
+ }
700
+ return this.events;
701
+ }
452
702
  getEvents(type) {
453
703
  if (!type)
454
704
  return [...this.events];
@@ -727,6 +977,11 @@ export class Transcript {
727
977
  return Transcript.loadFromFile(filePath);
728
978
  }
729
979
  }
980
+ // Checkpoint validity references its note and covered cursor. A bounded
981
+ // range-archive tail may omit either, so use the full loader here.
982
+ if (events.some((event) => event.type === "context_checkpoint")) {
983
+ return Transcript.loadFromFile(filePath);
984
+ }
730
985
  let markerIndex = -1;
731
986
  let toClientMessageId;
732
987
  for (const [index, event] of events.entries()) {