@gajae-code/coding-agent 0.12.0 → 0.12.2

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 (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -1,6 +1,6 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
- import type { ToolResultMessage } from "@gajae-code/ai";
3
+ import type { ToolCall, ToolResultMessage } from "@gajae-code/ai";
4
4
  import { matchesKey } from "@gajae-code/tui";
5
5
  import { formatDuration, formatNumber } from "@gajae-code/utils";
6
6
  import type { KeyId } from "../../config/keybindings";
@@ -19,7 +19,19 @@ import { type TranscriptViewerEntry, TranscriptViewerOverlay } from "./transcrip
19
19
  const FATAL_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
20
20
 
21
21
  type FileIdentity = { dev: bigint; ino: bigint };
22
- type StableSnapshot = { identity: FileIdentity; size: number; prefix: Buffer; tail: Buffer };
22
+ type StableSnapshot = {
23
+ identity: FileIdentity;
24
+ size: number;
25
+ prefix: Buffer;
26
+ tail: Buffer;
27
+ offsetReset: boolean;
28
+ };
29
+ type SnapshotReadResult =
30
+ | { status: "stable"; snapshot: StableSnapshot }
31
+ | { status: "unstable" }
32
+ | { status: "unavailable" };
33
+
34
+ class UnstableSnapshotError extends Error {}
23
35
  type ObserverCache = {
24
36
  path: string;
25
37
  identity: FileIdentity;
@@ -27,8 +39,56 @@ type ObserverCache = {
27
39
  prefixDigest: string;
28
40
  entries: SessionMessageEntry[];
29
41
  model?: string;
42
+ projectedCount: number;
43
+ projectedOutput: TranscriptViewerEntry[];
44
+ toolCallOutputIndex: Map<string, number>;
45
+ projectionSafe: boolean;
30
46
  };
31
47
 
48
+ type ProjectionState = Pick<
49
+ ObserverCache,
50
+ "projectedCount" | "projectedOutput" | "toolCallOutputIndex" | "projectionSafe"
51
+ >;
52
+ type ProjectionUpdate = { state: ProjectionState; preserveLayoutCache: boolean };
53
+
54
+ export const __sessionObserverProjectionCounters = {
55
+ enabled: false,
56
+ projectionFullRuns: 0,
57
+ projectionIncrementalRuns: 0,
58
+ sourceMessagesProjected: 0,
59
+ projectedEntriesAppended: 0,
60
+ resultPatchCount: 0,
61
+ snapshot() {
62
+ return {
63
+ projectionFullRuns: this.projectionFullRuns,
64
+ projectionIncrementalRuns: this.projectionIncrementalRuns,
65
+ sourceMessagesProjected: this.sourceMessagesProjected,
66
+ projectedEntriesAppended: this.projectedEntriesAppended,
67
+ resultPatchCount: this.resultPatchCount,
68
+ };
69
+ },
70
+ enable(): void {
71
+ this.enabled = true;
72
+ },
73
+ disable(): void {
74
+ this.enabled = false;
75
+ },
76
+ reset(): void {
77
+ this.projectionFullRuns = 0;
78
+ this.projectionIncrementalRuns = 0;
79
+ this.sourceMessagesProjected = 0;
80
+ this.projectedEntriesAppended = 0;
81
+ this.resultPatchCount = 0;
82
+ },
83
+ };
84
+
85
+ function recordProjectionCounter(
86
+ counter: keyof ReturnType<typeof __sessionObserverProjectionCounters.snapshot>,
87
+ amount = 1,
88
+ ): void {
89
+ if (__sessionObserverProjectionCounters.enabled) __sessionObserverProjectionCounters[counter] += amount;
90
+ }
91
+
32
92
  /** Session-observer adapter. The shared viewer owns navigation and fold state. */
33
93
  export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
34
94
  #registry: SessionObserverRegistry;
@@ -62,11 +122,21 @@ export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
62
122
  this.#observeKeys = observeKeys;
63
123
  this.#selectedSessionId = this.#mostRecent()?.id;
64
124
  if (!this.#selectedSessionId) queueMicrotask(onDone);
65
- this.refresh();
125
+ this.#refreshCurrentSource();
66
126
  }
67
127
 
68
128
  refreshFromRegistry(): void {
69
- this.refresh();
129
+ this.#refreshCurrentSource();
130
+ }
131
+ #refreshCurrentSource(): void {
132
+ const session = this.#registry.getSessions().find(candidate => candidate.id === this.#selectedSessionId);
133
+ if (!session?.sessionFile) {
134
+ this.#clearSource();
135
+ this.refresh(undefined, false);
136
+ return;
137
+ }
138
+ const preserveLayoutCache = this.#load(session.sessionFile);
139
+ this.refresh(undefined, preserveLayoutCache);
70
140
  }
71
141
  override handleInput(keyData: string): void {
72
142
  if (this.#observeKeys.some(key => matchesKey(keyData, key))) {
@@ -100,12 +170,10 @@ export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
100
170
  this.#selectedSessionId = ids[(current + direction + ids.length) % ids.length];
101
171
  this.#cache = undefined;
102
172
  this.resetSourceState();
103
- this.refresh();
173
+ this.#refreshCurrentSource();
104
174
  }
105
175
  #entries(): readonly TranscriptViewerEntry[] {
106
- const session = this.#registry.getSessions().find(candidate => candidate.id === this.#selectedSessionId);
107
- if (!session?.sessionFile) return [];
108
- return entriesFromMessages(this.#load(session.sessionFile));
176
+ return this.#cache?.projectedOutput ?? [];
109
177
  }
110
178
  #headerLines(): string[] {
111
179
  const session = this.#registry.getSessions().find(candidate => candidate.id === this.#selectedSessionId);
@@ -139,71 +207,146 @@ export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
139
207
  if (progress.cost > 0) stats.push(`$${progress.cost.toFixed(2)}`);
140
208
  return stats.length ? [theme.fg("dim", stats.join(theme.sep.dot))] : [];
141
209
  }
142
- #load(filePath: string): SessionMessageEntry[] {
210
+ #load(filePath: string): boolean {
143
211
  if (this.#cache && this.#cache.path !== filePath) {
144
212
  this.#cache = undefined;
145
213
  this.resetSourceState();
146
214
  }
147
215
 
148
216
  const cache = this.#cache;
149
- const snapshot = readStableSnapshot(filePath, cache?.completeOffset ?? 0);
150
- if (!snapshot) {
151
- // An unreadable source is never allowed to keep presenting a prior file as current.
217
+ const read = readStableSnapshot(filePath, cache?.completeOffset ?? 0);
218
+ if (read.status === "unstable") return cache !== undefined;
219
+ if (read.status === "unavailable") {
152
220
  if (cache) this.#clearSource();
153
- return [];
221
+ return false;
154
222
  }
155
-
223
+ const snapshot = read.snapshot;
156
224
  const canAppend =
157
225
  cache &&
226
+ !snapshot.offsetReset &&
158
227
  sameIdentity(cache.identity, snapshot.identity) &&
159
228
  snapshot.size >= cache.completeOffset &&
160
229
  digest(snapshot.prefix) === cache.prefixDigest;
161
230
  if (canAppend) return this.#appendStable(cache, snapshot);
162
231
 
163
- // A changed fd identity, changed committed bytes, or a shrink is a replacement.
164
- // Validate the entire candidate before it replaces the visible transcript.
165
- const replacement = readStableSnapshot(filePath, 0);
166
- if (!replacement) {
167
- this.#clearSource();
168
- return [];
169
- }
170
- const candidate = cacheEntries(filePath, replacement);
232
+ const candidate = cacheEntries(filePath, asFullSnapshot(snapshot));
171
233
  if (!candidate) {
172
234
  this.#clearSource();
173
- return [];
235
+ return false;
174
236
  }
175
237
  this.#cache = candidate;
176
238
  if (cache) this.resetSourceState();
177
- return candidate.entries;
239
+ return false;
178
240
  }
179
- #appendStable(cache: ObserverCache, snapshot: StableSnapshot): SessionMessageEntry[] {
241
+ #appendStable(cache: ObserverCache, snapshot: StableSnapshot): boolean {
180
242
  const complete = completePrefix(snapshot.tail);
181
243
  if (!complete) {
182
244
  if (!isValidUtf8Prefix(snapshot.tail)) {
183
245
  this.#clearSource();
184
- return [];
246
+ return false;
185
247
  }
186
- return cache.entries;
248
+ return true;
187
249
  }
188
250
  const remainder = snapshot.tail.subarray(complete.bytes.length);
189
251
  if (!isValidUtf8Prefix(remainder)) {
190
252
  this.#clearSource();
191
- return [];
253
+ return false;
192
254
  }
193
255
  const parsed = parseCompleteEntries(complete.bytes);
194
256
  if (!parsed) {
195
257
  this.#clearSource();
196
- return [];
258
+ return false;
197
259
  }
260
+ if (parsed.hasPatchRecords) {
261
+ const candidate = cacheEntries(cache.path, asFullSnapshot(snapshot));
262
+ if (!candidate) {
263
+ this.#clearSource();
264
+ return false;
265
+ }
266
+ this.#cache = candidate;
267
+ return false;
268
+ }
269
+
198
270
  const entries = [...cache.entries, ...parsed.entries];
271
+ const projection = this.#appendProjection(cache, parsed.entries);
199
272
  this.#cache = {
200
273
  ...cache,
201
274
  completeOffset: cache.completeOffset + complete.bytes.length,
202
275
  prefixDigest: digest(Buffer.concat([snapshot.prefix, complete.bytes])),
203
276
  entries,
204
277
  model: parsed.model ?? cache.model,
278
+ ...projection.state,
279
+ };
280
+ return projection.preserveLayoutCache;
281
+ }
282
+ #appendProjection(cache: ObserverCache, suffix: readonly SessionMessageEntry[]): ProjectionUpdate {
283
+ if (suffix.length === 0) {
284
+ return {
285
+ state: projectionState(cache),
286
+ preserveLayoutCache: true,
287
+ };
288
+ }
289
+ if (!cache.projectionSafe || cache.projectedCount !== cache.entries.length) {
290
+ return {
291
+ state: fullProjection([...cache.entries, ...suffix]),
292
+ preserveLayoutCache: false,
293
+ };
294
+ }
295
+
296
+ const projectedOutput = [...cache.projectedOutput];
297
+ const toolCallOutputIndex = new Map(cache.toolCallOutputIndex);
298
+ const patchedIds: string[] = [];
299
+ let appendedCount = 0;
300
+ let patchCount = 0;
301
+ for (const entry of suffix) {
302
+ const message = entry.message;
303
+ if (message.role === "toolResult") {
304
+ const index = toolCallOutputIndex.get(message.toolCallId);
305
+ const existing = index === undefined ? undefined : projectedOutput[index];
306
+ const source = existing?.payload.source;
307
+ const call = isToolProjectionSource(source) ? source.call : undefined;
308
+ if (index === undefined || !call) {
309
+ return {
310
+ state: fullProjection([...cache.entries, ...suffix]),
311
+ preserveLayoutCache: false,
312
+ };
313
+ }
314
+ projectedOutput[index] = toolEntry(call, message);
315
+ patchedIds.push(projectedOutput[index].id);
316
+ patchCount++;
317
+ continue;
318
+ }
319
+
320
+ const appended = entriesFromMessages([entry]);
321
+ for (const outputEntry of appended) {
322
+ if (outputEntry.id.startsWith("tool:")) {
323
+ const callId = outputEntry.id.slice("tool:".length);
324
+ if (toolCallOutputIndex.has(callId)) {
325
+ return {
326
+ state: fullProjection([...cache.entries, ...suffix]),
327
+ preserveLayoutCache: false,
328
+ };
329
+ }
330
+ toolCallOutputIndex.set(callId, projectedOutput.length);
331
+ }
332
+ projectedOutput.push(outputEntry);
333
+ }
334
+ appendedCount += appended.length;
335
+ }
336
+ if (patchedIds.length > 0) this.invalidateLayoutEntries(patchedIds);
337
+ recordProjectionCounter("projectionIncrementalRuns");
338
+ recordProjectionCounter("sourceMessagesProjected", suffix.length);
339
+ recordProjectionCounter("projectedEntriesAppended", appendedCount);
340
+ recordProjectionCounter("resultPatchCount", patchCount);
341
+ return {
342
+ state: {
343
+ projectedCount: cache.projectedCount + suffix.length,
344
+ projectedOutput,
345
+ toolCallOutputIndex,
346
+ projectionSafe: true,
347
+ },
348
+ preserveLayoutCache: true,
205
349
  };
206
- return entries;
207
350
  }
208
351
  #clearSource(): void {
209
352
  if (this.#cache) this.resetSourceState();
@@ -211,6 +354,95 @@ export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
211
354
  }
212
355
  }
213
356
 
357
+ type ToolProjectionSource = { call: ToolCall; result?: ToolResultMessage };
358
+
359
+ function isToolProjectionSource(source: unknown): source is ToolProjectionSource {
360
+ if (!source || typeof source !== "object" || !("call" in source)) return false;
361
+ const call = source.call;
362
+ return Boolean(call && typeof call === "object" && "type" in call && call.type === "toolCall");
363
+ }
364
+
365
+ function projectionState(cache: ObserverCache): ProjectionState {
366
+ return {
367
+ projectedCount: cache.projectedCount,
368
+ projectedOutput: cache.projectedOutput,
369
+ toolCallOutputIndex: cache.toolCallOutputIndex,
370
+ projectionSafe: cache.projectionSafe,
371
+ };
372
+ }
373
+
374
+ function fullProjection(
375
+ entries: readonly SessionMessageEntry[],
376
+ ): Pick<ObserverCache, "projectedCount" | "projectedOutput" | "toolCallOutputIndex" | "projectionSafe"> {
377
+ const projectedOutput = entriesFromMessages(entries);
378
+ const toolCallOutputIndex = new Map<string, number>();
379
+ let projectionSafe = true;
380
+ const knownToolCallIds = new Set<string>();
381
+ const unresolvedToolResultIds = new Set<string>();
382
+ for (const entry of entries) {
383
+ const message = entry.message;
384
+ if (message.role === "assistant") {
385
+ for (const content of message.content) if (content.type === "toolCall") knownToolCallIds.add(content.id);
386
+ } else if (message.role === "toolResult") unresolvedToolResultIds.add(message.toolCallId);
387
+ }
388
+ for (const callId of knownToolCallIds) unresolvedToolResultIds.delete(callId);
389
+ if (unresolvedToolResultIds.size > 0) projectionSafe = false;
390
+ for (let index = 0; index < projectedOutput.length; index++) {
391
+ const id = projectedOutput[index].id;
392
+ if (!id.startsWith("tool:")) continue;
393
+ const callId = id.slice("tool:".length);
394
+ if (toolCallOutputIndex.has(callId)) projectionSafe = false;
395
+ else toolCallOutputIndex.set(callId, index);
396
+ }
397
+ recordProjectionCounter("projectionFullRuns");
398
+ recordProjectionCounter("sourceMessagesProjected", entries.length);
399
+ return { projectedCount: entries.length, projectedOutput, toolCallOutputIndex, projectionSafe };
400
+ }
401
+
402
+ function toolEntry(call: ToolCall, result?: ToolResultMessage): TranscriptViewerEntry {
403
+ const resultText =
404
+ result?.content
405
+ .filter(part => part.type === "text")
406
+ .map(part => part.text)
407
+ .join("\n")
408
+ .trim() ?? "";
409
+ const hasResult = result !== undefined;
410
+ const canonicalPayload = {
411
+ text: composeToolText({
412
+ name: call.name,
413
+ args: call.arguments,
414
+ intent: call.intent,
415
+ resultText,
416
+ isError: result?.isError ?? false,
417
+ hasResult,
418
+ }),
419
+ metadata: {
420
+ name: call.name,
421
+ arguments: call.arguments,
422
+ intent: call.intent,
423
+ resultText,
424
+ isError: result?.isError ?? false,
425
+ hasResult,
426
+ detailsData: result?.details,
427
+ },
428
+ source: { call, result },
429
+ };
430
+ return buildToolTranscriptEntry({
431
+ canonicalPayload,
432
+ renderDescriptor: createToolTranscriptRenderDescriptor({
433
+ name: call.name,
434
+ args: call.arguments,
435
+ intent: call.intent,
436
+ resultContent: resultText,
437
+ isError: result?.isError,
438
+ hasResult,
439
+ detailsData: result?.details,
440
+ }),
441
+ capabilities: { copyable: true, foldable: true, rawViewable: true },
442
+ identity: { id: `tool:${call.id}`, label: call.name, display: "full" },
443
+ });
444
+ }
445
+
214
446
  /**
215
447
  * This deliberately remains the eager full-history projection. PR2 owns projection
216
448
  * virtualization/incrementalization; source acquisition above only controls snapshot safety.
@@ -249,51 +481,7 @@ export function entriesFromMessages(entries: readonly SessionMessageEntry[]): Tr
249
481
  payload: { text: content.text, metadata: {}, source: content },
250
482
  foldable: true,
251
483
  });
252
- if (content.type === "toolCall") {
253
- const result = results.get(content.id);
254
- const resultText =
255
- result?.content
256
- .filter(part => part.type === "text")
257
- .map(part => part.text)
258
- .join("\n")
259
- .trim() ?? "";
260
- const canonicalPayload = {
261
- text: composeToolText({
262
- name: content.name,
263
- args: content.arguments,
264
- intent: content.intent,
265
- resultText,
266
- isError: result?.isError ?? false,
267
- hasResult: results.has(content.id),
268
- }),
269
- metadata: {
270
- name: content.name,
271
- arguments: content.arguments,
272
- intent: content.intent,
273
- resultText,
274
- isError: result?.isError ?? false,
275
- hasResult: results.has(content.id),
276
- detailsData: result?.details,
277
- },
278
- source: { call: content, result },
279
- };
280
- output.push(
281
- buildToolTranscriptEntry({
282
- canonicalPayload,
283
- renderDescriptor: createToolTranscriptRenderDescriptor({
284
- name: content.name,
285
- args: content.arguments,
286
- intent: content.intent,
287
- resultContent: resultText,
288
- isError: result?.isError,
289
- hasResult: results.has(content.id),
290
- detailsData: result?.details,
291
- }),
292
- capabilities: { copyable: true, foldable: true, rawViewable: true },
293
- identity: { id: `tool:${content.id}`, label: content.name, display: "full" },
294
- }),
295
- );
296
- }
484
+ if (content.type === "toolCall") output.push(toolEntry(content, results.get(content.id)));
297
485
  });
298
486
  }
299
487
  if (message.role === "user" || message.role === "developer") {
@@ -322,15 +510,17 @@ function truncateThinking(text: string, expanded: boolean): string {
322
510
  return text.length > limit ? `${text.slice(0, limit)}...` : text;
323
511
  }
324
512
 
325
- function readStableSnapshot(filePath: string, completeOffset: number): StableSnapshot | null {
513
+ function readStableSnapshot(filePath: string, completeOffset: number): SnapshotReadResult {
326
514
  let fd: number | undefined;
327
515
  try {
328
516
  fd = fs.openSync(filePath, "r");
329
517
  const before = fs.fstatSync(fd, { bigint: true });
330
- if (before.size > BigInt(Number.MAX_SAFE_INTEGER) || completeOffset > Number(before.size)) return null;
518
+ if (before.size > BigInt(Number.MAX_SAFE_INTEGER)) return { status: "unavailable" };
331
519
  const size = Number(before.size);
332
- const prefix = readExactly(fd, 0, completeOffset);
333
- const tail = readExactly(fd, completeOffset, size - completeOffset);
520
+ const offsetReset = completeOffset > size;
521
+ const offset = offsetReset ? 0 : completeOffset;
522
+ const prefix = readExactly(fd, 0, offset);
523
+ const tail = readExactly(fd, offset, size - offset);
334
524
  const after = fs.fstatSync(fd, { bigint: true });
335
525
  if (
336
526
  before.dev !== after.dev ||
@@ -339,21 +529,34 @@ function readStableSnapshot(filePath: string, completeOffset: number): StableSna
339
529
  before.mtimeNs !== after.mtimeNs ||
340
530
  before.ctimeNs !== after.ctimeNs
341
531
  )
342
- return null;
343
- return { identity: { dev: before.dev, ino: before.ino }, size, prefix, tail };
344
- } catch {
345
- return null;
532
+ return { status: "unstable" };
533
+ return {
534
+ status: "stable",
535
+ snapshot: { identity: { dev: before.dev, ino: before.ino }, size, prefix, tail, offsetReset },
536
+ };
537
+ } catch (err) {
538
+ return err instanceof UnstableSnapshotError ? { status: "unstable" } : { status: "unavailable" };
346
539
  } finally {
347
540
  if (fd !== undefined) fs.closeSync(fd);
348
541
  }
349
542
  }
350
543
 
544
+ function asFullSnapshot(snapshot: StableSnapshot): StableSnapshot {
545
+ if (snapshot.prefix.length === 0) return snapshot;
546
+ return {
547
+ ...snapshot,
548
+ prefix: Buffer.alloc(0),
549
+ tail: Buffer.concat([snapshot.prefix, snapshot.tail]),
550
+ offsetReset: true,
551
+ };
552
+ }
553
+
351
554
  function readExactly(fd: number, position: number, length: number): Buffer {
352
555
  const buffer = Buffer.alloc(length);
353
556
  let offset = 0;
354
557
  while (offset < length) {
355
558
  const bytesRead = fs.readSync(fd, buffer, offset, length - offset, position + offset);
356
- if (bytesRead === 0) throw new Error("Short session file read");
559
+ if (bytesRead === 0) throw new UnstableSnapshotError("Short session file read");
357
560
  offset += bytesRead;
358
561
  }
359
562
  return buffer;
@@ -372,14 +575,28 @@ function completePrefix(bytes: Buffer): { bytes: Buffer } | undefined {
372
575
  return newline < 0 ? undefined : { bytes: bytes.subarray(0, newline + 1) };
373
576
  }
374
577
 
375
- function parseCompleteEntries(bytes: Buffer): { entries: SessionMessageEntry[]; model?: string } | null {
578
+ function parseCompleteEntries(
579
+ bytes: Buffer,
580
+ ): { entries: SessionMessageEntry[]; model?: string; hasPatchRecords: boolean } | null {
376
581
  try {
377
582
  const text = FATAL_UTF8_DECODER.decode(bytes);
378
- for (const line of text.split("\n")) if (line.trim()) JSON.parse(line);
583
+ let hasPatchRecords = false;
584
+ for (const line of text.split("\n")) {
585
+ if (!line.trim()) continue;
586
+ const record: unknown = JSON.parse(line);
587
+ if (
588
+ record !== null &&
589
+ typeof record === "object" &&
590
+ "type" in record &&
591
+ (record.type === "entry_patch" || record.type === "header_patch")
592
+ )
593
+ hasPatchRecords = true;
594
+ }
379
595
  const parsed = parseSessionEntries(text);
380
596
  return {
381
597
  entries: parsed.filter((entry): entry is SessionMessageEntry => entry.type === "message"),
382
598
  model: modelFromEntries(parsed),
599
+ hasPatchRecords,
383
600
  };
384
601
  } catch {
385
602
  return null;
@@ -415,6 +632,7 @@ function cacheEntries(filePath: string, snapshot: StableSnapshot): ObserverCache
415
632
  completeOffset: 0,
416
633
  prefixDigest: digest(Buffer.alloc(0)),
417
634
  entries: [],
635
+ ...fullProjection([]),
418
636
  };
419
637
  }
420
638
  const remainder = snapshot.tail.subarray(complete.bytes.length);
@@ -428,5 +646,6 @@ function cacheEntries(filePath: string, snapshot: StableSnapshot): ObserverCache
428
646
  prefixDigest: digest(complete.bytes),
429
647
  entries: parsed.entries,
430
648
  model: parsed.model,
649
+ ...fullProjection(parsed.entries),
431
650
  };
432
651
  }