@gajae-code/coding-agent 0.11.11 → 0.12.0

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 (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -3,7 +3,7 @@ import { Container, Text } from "@gajae-code/tui";
3
3
  import { InternalUrlRouter } from "../../internal-urls";
4
4
  import { getLanguageFromPath, theme } from "../../modes/theme/theme";
5
5
  import { splitPathAndSel } from "../../tools/path-utils";
6
- import { PREVIEW_LIMITS, shortenPath } from "../../tools/render-utils";
6
+ import { PREVIEW_LIMITS, replaceTabs, shortenPath } from "../../tools/render-utils";
7
7
  import { renderCodeCell } from "../../tui";
8
8
  import type { ToolExecutionHandle } from "./tool-execution";
9
9
 
@@ -53,9 +53,16 @@ type ReadToolResultDetails = {
53
53
  conflictCount?: number;
54
54
  };
55
55
 
56
- type ReadToolGroupOptions = {
57
- showContentPreview?: boolean;
58
- };
56
+ type ReadToolVisibleProjection = ReadonlyArray<{
57
+ path: string;
58
+ status: ReadEntry["status"];
59
+ correctedFrom?: string;
60
+ conflictCount?: number;
61
+ preview?: {
62
+ lines: readonly string[];
63
+ hiddenLineCount: number;
64
+ };
65
+ }>;
59
66
 
60
67
  function getSuffixResolution(details: ReadToolResultDetails | undefined): ReadToolSuffixResolution | undefined {
61
68
  if (typeof details?.suffixResolution?.from !== "string" || typeof details.suffixResolution.to !== "string") {
@@ -82,13 +89,16 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
82
89
  #expanded = false;
83
90
  #manuallyExpanded: boolean | undefined;
84
91
  #showContentPreview: boolean;
92
+ #visibleTranscriptChanged = false;
93
+ #lastVisibleProjection: ReadToolVisibleProjection = [];
85
94
 
86
- constructor(options: ReadToolGroupOptions = {}) {
95
+ constructor(options: { showContentPreview?: boolean } = {}) {
87
96
  super();
88
97
  this.#showContentPreview = options.showContentPreview ?? false;
89
98
  this.#text = new Text("", 0, 0);
90
99
  this.addChild(this.#text);
91
100
  this.#updateDisplay();
101
+ this.#lastVisibleProjection = this.#visibleProjection();
92
102
  }
93
103
 
94
104
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
@@ -102,7 +112,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
102
112
  };
103
113
  entry.path = rawPath;
104
114
  this.#entries.set(toolCallId, entry);
105
- this.#updateDisplay();
115
+ this.#updateDisplayAndNotify();
106
116
  }
107
117
 
108
118
  updateResult(
@@ -126,23 +136,20 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
126
136
  typeof details?.conflictCount === "number" && details.conflictCount > 0 ? details.conflictCount : undefined;
127
137
  entry.conflictCount = conflictCount;
128
138
  entry.status = result.isError ? "error" : suffixResolution ? "warning" : "success";
129
- // Store the text content for preview/expanded display
130
139
  const textContent = result.content?.find(c => c.type === "text")?.text;
131
- if (textContent !== undefined) {
132
- entry.contentText = textContent;
133
- }
134
- this.#updateDisplay();
140
+ entry.contentText = textContent;
141
+ this.#updateDisplayAndNotify();
135
142
  }
136
143
 
137
144
  setArgsComplete(_toolCallId?: string): void {
138
- this.#updateDisplay();
145
+ this.#updateDisplayAndNotify();
139
146
  }
140
147
 
141
148
  /** Applies automatic expansion unless this renderer instance has an explicit fold choice. */
142
149
  setExpanded(expanded: boolean): void {
143
150
  if (this.#manuallyExpanded !== undefined) return;
144
151
  this.#expanded = expanded;
145
- this.#updateDisplay();
152
+ this.#updateDisplayAndNotify();
146
153
  }
147
154
 
148
155
  /**
@@ -152,13 +159,74 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
152
159
  setManuallyExpanded(expanded: boolean): void {
153
160
  this.#manuallyExpanded = expanded;
154
161
  this.#expanded = expanded;
155
- this.#updateDisplay();
162
+ this.#updateDisplayAndNotify();
156
163
  }
157
164
 
158
165
  getComponent(): Component {
159
166
  return this;
160
167
  }
161
168
 
169
+ consumeVisibleTranscriptChange(): boolean {
170
+ const changed = this.#visibleTranscriptChanged;
171
+ this.#visibleTranscriptChanged = false;
172
+ return changed;
173
+ }
174
+
175
+ #visibleProjection(): ReadToolVisibleProjection {
176
+ return [...this.#entries.values()].map(entry => {
177
+ if (!this.#shouldRenderPreview(entry)) {
178
+ return {
179
+ path: entry.path,
180
+ status: entry.status,
181
+ correctedFrom: entry.correctedFrom,
182
+ conflictCount: entry.conflictCount,
183
+ };
184
+ }
185
+ const lines = replaceTabs(entry.contentText ?? "")
186
+ .split(/\r?\n/)
187
+ .map(line => line.slice(line.lastIndexOf("\r") + 1));
188
+ const visibleLineCount = this.#expanded ? lines.length : Math.min(lines.length, COLLAPSED_PREVIEW_LINES);
189
+ return {
190
+ path: entry.path,
191
+ status: entry.status,
192
+ correctedFrom: entry.correctedFrom,
193
+ preview: {
194
+ lines: lines.slice(0, visibleLineCount),
195
+ hiddenLineCount: lines.length - visibleLineCount,
196
+ },
197
+ };
198
+ });
199
+ }
200
+
201
+ #sameVisibleProjection(left: ReadToolVisibleProjection, right: ReadToolVisibleProjection): boolean {
202
+ if (left.length !== right.length) return false;
203
+ return left.every((entry, index) => {
204
+ const other = right[index];
205
+ if (
206
+ !other ||
207
+ entry.path !== other.path ||
208
+ entry.status !== other.status ||
209
+ entry.correctedFrom !== other.correctedFrom ||
210
+ (!entry.preview && entry.conflictCount !== other.conflictCount)
211
+ )
212
+ return false;
213
+ if (!entry.preview || !other.preview) return entry.preview === other.preview;
214
+ return (
215
+ entry.preview.hiddenLineCount === other.preview.hiddenLineCount &&
216
+ entry.preview.lines.length === other.preview.lines.length &&
217
+ entry.preview.lines.every((line, lineIndex) => line === other.preview!.lines[lineIndex])
218
+ );
219
+ });
220
+ }
221
+
222
+ #updateDisplayAndNotify(): void {
223
+ this.#updateDisplay();
224
+ const after = this.#visibleProjection();
225
+ if (this.#sameVisibleProjection(after, this.#lastVisibleProjection)) return;
226
+ this.#lastVisibleProjection = after;
227
+ this.#visibleTranscriptChanged = true;
228
+ }
229
+
162
230
  #updateDisplay(): void {
163
231
  const entries = [...this.#entries.values()];
164
232
 
@@ -1,10 +1,11 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import type { ToolResultMessage } from "@gajae-code/ai";
3
4
  import { matchesKey } from "@gajae-code/tui";
4
5
  import { formatDuration, formatNumber } from "@gajae-code/utils";
5
6
  import type { KeyId } from "../../config/keybindings";
6
7
  import { isSilentAbort } from "../../session/messages";
7
- import type { SessionMessageEntry } from "../../session/session-manager";
8
+ import type { FileEntry, SessionMessageEntry } from "../../session/session-manager";
8
9
  import { parseSessionEntries } from "../../session/session-manager";
9
10
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
10
11
  import { theme } from "../theme/theme";
@@ -15,13 +16,26 @@ import {
15
16
  } from "./tool-transcript-format";
16
17
  import { type TranscriptViewerEntry, TranscriptViewerOverlay } from "./transcript-viewer-overlay";
17
18
 
19
+ const FATAL_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
20
+
21
+ type FileIdentity = { dev: bigint; ino: bigint };
22
+ type StableSnapshot = { identity: FileIdentity; size: number; prefix: Buffer; tail: Buffer };
23
+ type ObserverCache = {
24
+ path: string;
25
+ identity: FileIdentity;
26
+ completeOffset: number;
27
+ prefixDigest: string;
28
+ entries: SessionMessageEntry[];
29
+ model?: string;
30
+ };
31
+
18
32
  /** Session-observer adapter. The shared viewer owns navigation and fold state. */
19
33
  export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
20
34
  #registry: SessionObserverRegistry;
21
35
  #onDone: () => void;
22
36
  #observeKeys: readonly KeyId[];
23
37
  #selectedSessionId?: string;
24
- #cache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
38
+ #cache?: ObserverCache;
25
39
 
26
40
  constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
27
41
  // The option closures run during the base constructor's initial refresh,
@@ -126,32 +140,81 @@ export class SessionObserverOverlayComponent extends TranscriptViewerOverlay {
126
140
  return stats.length ? [theme.fg("dim", stats.join(theme.sep.dot))] : [];
127
141
  }
128
142
  #load(filePath: string): SessionMessageEntry[] {
129
- if (this.#cache?.path !== filePath) this.#cache = undefined;
130
- const fromByte = this.#cache?.bytesRead ?? 0;
131
- const result = readFileIncremental(filePath, fromByte);
132
- if (!result) return this.#cache?.entries ?? [];
133
- if (result.newSize < fromByte) {
143
+ if (this.#cache && this.#cache.path !== filePath) {
134
144
  this.#cache = undefined;
135
- return this.#load(filePath);
145
+ this.resetSourceState();
136
146
  }
137
- if (!this.#cache) this.#cache = { path: filePath, bytesRead: 0, entries: [] };
138
- if (result.text) {
139
- const lastNewline = result.text.lastIndexOf("\n");
140
- if (lastNewline >= 0) {
141
- const complete = result.text.slice(0, lastNewline + 1);
142
- for (const entry of parseSessionEntries(complete)) {
143
- if (entry.type === "message") {
144
- this.#cache.entries.push(entry);
145
- if (!this.#cache.model && entry.message.role === "assistant") this.#cache.model = entry.message.model;
146
- } else if (entry.type === "model_change") this.#cache.model = entry.model;
147
- }
148
- this.#cache.bytesRead = fromByte + Buffer.byteLength(complete, "utf-8");
147
+
148
+ 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.
152
+ if (cache) this.#clearSource();
153
+ return [];
154
+ }
155
+
156
+ const canAppend =
157
+ cache &&
158
+ sameIdentity(cache.identity, snapshot.identity) &&
159
+ snapshot.size >= cache.completeOffset &&
160
+ digest(snapshot.prefix) === cache.prefixDigest;
161
+ if (canAppend) return this.#appendStable(cache, snapshot);
162
+
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);
171
+ if (!candidate) {
172
+ this.#clearSource();
173
+ return [];
174
+ }
175
+ this.#cache = candidate;
176
+ if (cache) this.resetSourceState();
177
+ return candidate.entries;
178
+ }
179
+ #appendStable(cache: ObserverCache, snapshot: StableSnapshot): SessionMessageEntry[] {
180
+ const complete = completePrefix(snapshot.tail);
181
+ if (!complete) {
182
+ if (!isValidUtf8Prefix(snapshot.tail)) {
183
+ this.#clearSource();
184
+ return [];
149
185
  }
186
+ return cache.entries;
187
+ }
188
+ const remainder = snapshot.tail.subarray(complete.bytes.length);
189
+ if (!isValidUtf8Prefix(remainder)) {
190
+ this.#clearSource();
191
+ return [];
150
192
  }
151
- return this.#cache.entries;
193
+ const parsed = parseCompleteEntries(complete.bytes);
194
+ if (!parsed) {
195
+ this.#clearSource();
196
+ return [];
197
+ }
198
+ const entries = [...cache.entries, ...parsed.entries];
199
+ this.#cache = {
200
+ ...cache,
201
+ completeOffset: cache.completeOffset + complete.bytes.length,
202
+ prefixDigest: digest(Buffer.concat([snapshot.prefix, complete.bytes])),
203
+ entries,
204
+ model: parsed.model ?? cache.model,
205
+ };
206
+ return entries;
207
+ }
208
+ #clearSource(): void {
209
+ if (this.#cache) this.resetSourceState();
210
+ this.#cache = undefined;
152
211
  }
153
212
  }
154
213
 
214
+ /**
215
+ * This deliberately remains the eager full-history projection. PR2 owns projection
216
+ * virtualization/incrementalization; source acquisition above only controls snapshot safety.
217
+ */
155
218
  export function entriesFromMessages(entries: readonly SessionMessageEntry[]): TranscriptViewerEntry[] {
156
219
  const results = new Map<string, ToolResultMessage>();
157
220
  for (const entry of entries)
@@ -259,19 +322,111 @@ function truncateThinking(text: string, expanded: boolean): string {
259
322
  return text.length > limit ? `${text.slice(0, limit)}...` : text;
260
323
  }
261
324
 
262
- function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
325
+ function readStableSnapshot(filePath: string, completeOffset: number): StableSnapshot | null {
326
+ let fd: number | undefined;
263
327
  try {
264
- const stat = fs.statSync(filePath);
265
- if (stat.size <= fromByte) return { text: "", newSize: stat.size };
266
- const buffer = Buffer.alloc(stat.size - fromByte);
267
- const fd = fs.openSync(filePath, "r");
268
- try {
269
- fs.readSync(fd, buffer, 0, buffer.length, fromByte);
270
- } finally {
271
- fs.closeSync(fd);
272
- }
273
- return { text: buffer.toString("utf-8"), newSize: stat.size };
328
+ fd = fs.openSync(filePath, "r");
329
+ const before = fs.fstatSync(fd, { bigint: true });
330
+ if (before.size > BigInt(Number.MAX_SAFE_INTEGER) || completeOffset > Number(before.size)) return null;
331
+ const size = Number(before.size);
332
+ const prefix = readExactly(fd, 0, completeOffset);
333
+ const tail = readExactly(fd, completeOffset, size - completeOffset);
334
+ const after = fs.fstatSync(fd, { bigint: true });
335
+ if (
336
+ before.dev !== after.dev ||
337
+ before.ino !== after.ino ||
338
+ before.size !== after.size ||
339
+ before.mtimeNs !== after.mtimeNs ||
340
+ before.ctimeNs !== after.ctimeNs
341
+ )
342
+ return null;
343
+ return { identity: { dev: before.dev, ino: before.ino }, size, prefix, tail };
344
+ } catch {
345
+ return null;
346
+ } finally {
347
+ if (fd !== undefined) fs.closeSync(fd);
348
+ }
349
+ }
350
+
351
+ function readExactly(fd: number, position: number, length: number): Buffer {
352
+ const buffer = Buffer.alloc(length);
353
+ let offset = 0;
354
+ while (offset < length) {
355
+ const bytesRead = fs.readSync(fd, buffer, offset, length - offset, position + offset);
356
+ if (bytesRead === 0) throw new Error("Short session file read");
357
+ offset += bytesRead;
358
+ }
359
+ return buffer;
360
+ }
361
+
362
+ function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
363
+ return left.dev === right.dev && left.ino === right.ino;
364
+ }
365
+
366
+ function digest(bytes: Buffer): string {
367
+ return crypto.createHash("sha256").update(bytes).digest("hex");
368
+ }
369
+
370
+ function completePrefix(bytes: Buffer): { bytes: Buffer } | undefined {
371
+ const newline = bytes.lastIndexOf(0x0a);
372
+ return newline < 0 ? undefined : { bytes: bytes.subarray(0, newline + 1) };
373
+ }
374
+
375
+ function parseCompleteEntries(bytes: Buffer): { entries: SessionMessageEntry[]; model?: string } | null {
376
+ try {
377
+ const text = FATAL_UTF8_DECODER.decode(bytes);
378
+ for (const line of text.split("\n")) if (line.trim()) JSON.parse(line);
379
+ const parsed = parseSessionEntries(text);
380
+ return {
381
+ entries: parsed.filter((entry): entry is SessionMessageEntry => entry.type === "message"),
382
+ model: modelFromEntries(parsed),
383
+ };
274
384
  } catch {
275
385
  return null;
276
386
  }
277
387
  }
388
+
389
+ function modelFromEntries(entries: readonly FileEntry[]): string | undefined {
390
+ let model: string | undefined;
391
+ for (const entry of entries) {
392
+ if (entry.type === "model_change") model = entry.model;
393
+ else if (entry.type === "message" && entry.message.role === "assistant" && entry.message.model)
394
+ model = entry.message.model;
395
+ }
396
+ return model;
397
+ }
398
+
399
+ function isValidUtf8Prefix(bytes: Buffer): boolean {
400
+ try {
401
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true });
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ function cacheEntries(filePath: string, snapshot: StableSnapshot): ObserverCache | null {
409
+ const complete = completePrefix(snapshot.tail);
410
+ if (!complete) {
411
+ if (!isValidUtf8Prefix(snapshot.tail)) return null;
412
+ return {
413
+ path: filePath,
414
+ identity: snapshot.identity,
415
+ completeOffset: 0,
416
+ prefixDigest: digest(Buffer.alloc(0)),
417
+ entries: [],
418
+ };
419
+ }
420
+ const remainder = snapshot.tail.subarray(complete.bytes.length);
421
+ if (!isValidUtf8Prefix(remainder)) return null;
422
+ const parsed = parseCompleteEntries(complete.bytes);
423
+ if (!parsed) return null;
424
+ return {
425
+ path: filePath,
426
+ identity: snapshot.identity,
427
+ completeOffset: complete.bytes.length,
428
+ prefixDigest: digest(complete.bytes),
429
+ entries: parsed.entries,
430
+ model: parsed.model,
431
+ };
432
+ }
@@ -235,18 +235,16 @@ function getSavedUsageMode(): UsageMode {
235
235
  return segmentOptions.usage?.mode === "remaining" ? "remaining" : "used";
236
236
  }
237
237
 
238
- function setSavedUsageMode(mode: string): StatusLineSegmentOptions {
238
+ function getUsageModeSettings(mode: string): StatusLineSegmentOptions {
239
239
  const normalizedMode: UsageMode = mode === "remaining" ? "remaining" : "used";
240
240
  const segmentOptions = settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions;
241
- const nextOptions: StatusLineSegmentOptions = {
241
+ return {
242
242
  ...segmentOptions,
243
243
  usage: {
244
244
  ...(segmentOptions.usage ?? {}),
245
245
  mode: normalizedMode,
246
246
  },
247
247
  };
248
- settings.set("statusLine.segmentOptions", nextOptions as Record<string, unknown>);
249
- return nextOptions;
250
248
  }
251
249
 
252
250
  function statusSegmentLabel(id: StatusLineSegmentId): string {
@@ -613,14 +611,17 @@ class StatusLineCustomEditor extends Container {
613
611
  }
614
612
 
615
613
  #save(): void {
616
- settings.set("statusLine.preset", "custom");
617
- settings.set("statusLine.leftSegments", [...this.#draft.leftSegments]);
618
- settings.set("statusLine.rightSegments", [...this.#draft.rightSegments]);
619
- settings.set("statusLine.separator", this.#draft.separator);
620
- settings.set(
621
- "statusLine.segmentOptions",
622
- cloneSegmentOptions(this.#draft.segmentOptions) as Record<string, unknown>,
623
- );
614
+ const saved = commitInteractiveSettings(this.callbacks, () => {
615
+ settings.set("statusLine.preset", "custom");
616
+ settings.set("statusLine.leftSegments", [...this.#draft.leftSegments]);
617
+ settings.set("statusLine.rightSegments", [...this.#draft.rightSegments]);
618
+ settings.set("statusLine.separator", this.#draft.separator);
619
+ settings.set(
620
+ "statusLine.segmentOptions",
621
+ cloneSegmentOptions(this.#draft.segmentOptions) as Record<string, unknown>,
622
+ );
623
+ });
624
+ if (!saved) return;
624
625
  this.callbacks.onChange("statusLine.preset", "custom");
625
626
  this.callbacks.onChange("statusLine.leftSegments", [...this.#draft.leftSegments]);
626
627
  this.callbacks.onChange("statusLine.rightSegments", [...this.#draft.rightSegments]);
@@ -707,9 +708,29 @@ export interface SettingsCallbacks {
707
708
  getStatusLinePreview?: (width?: number) => string;
708
709
  /** Called when plugins change */
709
710
  onPluginsChanged?: () => void;
711
+ /** Called when an interactive setting cannot be committed. */
712
+ onError?: (message: string) => void;
710
713
  /** Called when settings panel is closed */
711
714
  onCancel: () => void;
712
715
  }
716
+ function commitInteractiveSettings(callbacks: SettingsCallbacks, commit: () => void): boolean {
717
+ if (!settings.canWriteDurableConfig()) {
718
+ callbacks.onError?.(
719
+ "Cannot change settings while config.yml has invalid YAML syntax. Repair config.yml and reload settings.",
720
+ );
721
+ return false;
722
+ }
723
+ try {
724
+ commit();
725
+ return true;
726
+ } catch (error) {
727
+ if (!settings.canWriteDurableConfig()) {
728
+ callbacks.onError?.(error instanceof Error ? error.message : String(error));
729
+ return false;
730
+ }
731
+ throw error;
732
+ }
733
+ }
713
734
 
714
735
  /**
715
736
  * Main tabbed settings selector component.
@@ -994,11 +1015,18 @@ export class SettingsSelectorComponent extends Container {
994
1015
  // The shared pet commit policy rechecks capability immediately
995
1016
  // before mutation and persists only on acceptance; the settings
996
1017
  // surface must not persist ahead of that result.
997
- const accepted = this.callbacks.onPetCommit?.(value) ?? false;
1018
+ let accepted = false;
1019
+ if (
1020
+ !commitInteractiveSettings(this.callbacks, () => {
1021
+ accepted = this.callbacks.onPetCommit?.(value) ?? false;
1022
+ })
1023
+ ) {
1024
+ return;
1025
+ }
998
1026
  done(accepted ? value : undefined);
999
1027
  return;
1000
1028
  }
1001
- this.#setSettingValue(def.path, value);
1029
+ if (!commitInteractiveSettings(this.callbacks, () => this.#setSettingValue(def.path, value))) return;
1002
1030
  this.callbacks.onChange(def.path, value);
1003
1031
  done(value);
1004
1032
  },
@@ -1031,7 +1059,7 @@ export class SettingsSelectorComponent extends Container {
1031
1059
  value => {
1032
1060
  // Empty string clears the setting; undefined-typed string settings
1033
1061
  // store "" which the browser.ts expandPath ignores (no-op fallback).
1034
- this.#setSettingValue(def.path, value);
1062
+ if (!commitInteractiveSettings(this.callbacks, () => this.#setSettingValue(def.path, value))) return;
1035
1063
  this.callbacks.onChange(def.path, value);
1036
1064
  wrappedDone(value);
1037
1065
  },
@@ -1089,7 +1117,15 @@ export class SettingsSelectorComponent extends Container {
1089
1117
  getSettingsListTheme(),
1090
1118
  (id, newValue) => {
1091
1119
  if (id === STATUS_LINE_USAGE_MODE_ID) {
1092
- const segmentOptions = setSavedUsageMode(newValue);
1120
+ const segmentOptions = getUsageModeSettings(newValue);
1121
+ if (
1122
+ !commitInteractiveSettings(this.callbacks, () => {
1123
+ settings.set("statusLine.segmentOptions", segmentOptions as Record<string, unknown>);
1124
+ })
1125
+ ) {
1126
+ this.#refreshCurrentTabItems(defs);
1127
+ return;
1128
+ }
1093
1129
  this.callbacks.onChange("statusLine.segmentOptions", segmentOptions);
1094
1130
  if (tabId === "appearance") {
1095
1131
  this.#triggerStatusLinePreview();
@@ -1105,14 +1141,20 @@ export class SettingsSelectorComponent extends Container {
1105
1141
 
1106
1142
  if (def.type === "boolean") {
1107
1143
  const boolValue = newValue === "true";
1108
- settings.set(path, boolValue as never);
1144
+ if (!commitInteractiveSettings(this.callbacks, () => settings.set(path, boolValue as never))) {
1145
+ this.#refreshCurrentTabItems(defs);
1146
+ return;
1147
+ }
1109
1148
  this.callbacks.onChange(path, boolValue);
1110
1149
 
1111
1150
  if (tabId === "appearance") {
1112
1151
  this.#triggerStatusLinePreview();
1113
1152
  }
1114
1153
  } else if (def.type === "enum") {
1115
- settings.set(path, newValue as never);
1154
+ if (!commitInteractiveSettings(this.callbacks, () => settings.set(path, newValue as never))) {
1155
+ this.#refreshCurrentTabItems(defs);
1156
+ return;
1157
+ }
1116
1158
  this.callbacks.onChange(path, newValue);
1117
1159
  }
1118
1160
  // Submenu/text types already persisted the value inside their own