@gajae-code/coding-agent 0.11.10 → 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 (249) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/cli.d.ts +8 -1
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-profile-activation.d.ts +1 -0
  8. package/dist/types/config/model-profile-contract.d.ts +2 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/provider-auth-health.d.ts +14 -0
  11. package/dist/types/config/provider-ranking.d.ts +59 -0
  12. package/dist/types/config/settings-schema.d.ts +86 -3
  13. package/dist/types/config/settings.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/types.d.ts +43 -2
  15. package/dist/types/extensibility/shared-events.d.ts +6 -0
  16. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  17. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  18. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  19. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  20. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  21. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  22. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  23. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +112 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  25. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  26. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  28. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  29. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  30. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  31. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  32. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  33. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  34. package/dist/types/main.d.ts +1 -1
  35. package/dist/types/memories/index.d.ts +2 -0
  36. package/dist/types/modes/acp/acp-agent.d.ts +22 -9
  37. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  38. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  39. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  40. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  41. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  42. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  43. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  44. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  45. package/dist/types/modes/components/welcome.d.ts +1 -0
  46. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  47. package/dist/types/modes/interactive-mode.d.ts +7 -1
  48. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  49. package/dist/types/modes/types.d.ts +2 -0
  50. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  51. package/dist/types/runtime/memory-domain.d.ts +9 -0
  52. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  53. package/dist/types/runtime/memory-guard.d.ts +39 -0
  54. package/dist/types/runtime/memory-limit.d.ts +11 -0
  55. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  56. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  57. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  58. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  59. package/dist/types/sdk/broker/lifecycle.d.ts +7 -0
  60. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  61. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  62. package/dist/types/sdk/bus/index.d.ts +18 -2
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  65. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  66. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  67. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  68. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  69. package/dist/types/sdk/host/control/operations.d.ts +2 -1
  70. package/dist/types/sdk/host/host.d.ts +2 -0
  71. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  72. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  73. package/dist/types/sdk/startup-capability.d.ts +6 -0
  74. package/dist/types/session/agent-session.d.ts +51 -19
  75. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  76. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  77. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  78. package/dist/types/session/session-manager.d.ts +59 -13
  79. package/dist/types/session/streaming-output.d.ts +60 -5
  80. package/dist/types/skill-state/active-state.d.ts +0 -4
  81. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  82. package/dist/types/task/executor.d.ts +6 -0
  83. package/dist/types/task/types.d.ts +36 -0
  84. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  85. package/dist/types/tools/browser/launch.d.ts +7 -0
  86. package/dist/types/tools/fetch.d.ts +17 -1
  87. package/dist/types/tools/output-meta.d.ts +21 -1
  88. package/dist/types/tools/read-internals.d.ts +12 -0
  89. package/dist/types/tools/read.d.ts +45 -1
  90. package/dist/types/tools/resource-gc.d.ts +23 -0
  91. package/dist/types/tools/tool-result.d.ts +7 -1
  92. package/package.json +9 -13
  93. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  94. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  95. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  96. package/scripts/run-test-manifest.ts +6 -4
  97. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  98. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  99. package/src/capability/index.ts +8 -0
  100. package/src/cli/args.ts +14 -8
  101. package/src/cli/fast-help.ts +1 -1
  102. package/src/cli/read-cli.ts +2 -1
  103. package/src/cli.ts +49 -2
  104. package/src/commands/deep-interview.ts +29 -9
  105. package/src/commands/harness.ts +35 -12
  106. package/src/commands/read.ts +11 -2
  107. package/src/commands/sdk.ts +91 -7
  108. package/src/commands/team.ts +1 -1
  109. package/src/config/model-profile-activation.ts +1 -0
  110. package/src/config/model-profile-contract.ts +13 -0
  111. package/src/config/model-profiles.ts +24 -0
  112. package/src/config/model-registry.ts +21 -2
  113. package/src/config/provider-auth-health.ts +42 -0
  114. package/src/config/provider-ranking.ts +119 -0
  115. package/src/config/settings-schema.ts +84 -3
  116. package/src/config/settings.ts +254 -108
  117. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  118. package/src/defaults/gjc/skills/ralplan/SKILL.md +78 -26
  119. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  120. package/src/defaults/gjc/skills/ultragoal/SKILL.md +26 -16
  121. package/src/eval/py/executor.ts +7 -2
  122. package/src/extensibility/extensions/runner.ts +4 -0
  123. package/src/extensibility/extensions/types.ts +42 -4
  124. package/src/extensibility/shared-events.ts +5 -0
  125. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -68
  126. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  127. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  128. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  129. package/src/gjc-runtime/deep-interview-state.ts +12 -921
  130. package/src/gjc-runtime/gc-render.ts +1 -0
  131. package/src/gjc-runtime/gc-runtime.ts +11 -1
  132. package/src/gjc-runtime/linux-proc.ts +70 -36
  133. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  134. package/src/gjc-runtime/ralplan-runtime.ts +979 -71
  135. package/src/gjc-runtime/state-runtime.ts +50 -12
  136. package/src/gjc-runtime/state-writer.ts +70 -222
  137. package/src/gjc-runtime/team-launch.ts +34 -5
  138. package/src/gjc-runtime/team-runtime.ts +970 -33
  139. package/src/gjc-runtime/team-store.ts +32 -0
  140. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  141. package/src/gjc-runtime/team-workers.ts +1 -0
  142. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  143. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  144. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  145. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  146. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  147. package/src/hooks/native-skill-hook.ts +17 -4
  148. package/src/hooks/skill-state.ts +1 -23
  149. package/src/internal-urls/docs-index.generated.ts +10 -11
  150. package/src/internal-urls/local-protocol.ts +23 -4
  151. package/src/internal-urls/local-root-gc.ts +127 -0
  152. package/src/main.ts +9 -5
  153. package/src/memories/index.ts +10 -0
  154. package/src/modes/DESIGN.md +47 -0
  155. package/src/modes/acp/acp-agent.ts +597 -67
  156. package/src/modes/acp/acp-event-mapper.ts +106 -12
  157. package/src/modes/acp/acp-mode.ts +39 -3
  158. package/src/modes/components/assistant-message.ts +123 -22
  159. package/src/modes/components/irc-sidebar.ts +176 -62
  160. package/src/modes/components/model-selector.ts +76 -5
  161. package/src/modes/components/notifications-settings-editor.ts +1 -1
  162. package/src/modes/components/oauth-selector.ts +42 -12
  163. package/src/modes/components/queue-pane.ts +55 -9
  164. package/src/modes/components/queued-message-selector.ts +53 -10
  165. package/src/modes/components/read-tool-group.ts +82 -14
  166. package/src/modes/components/session-observer-overlay.ts +187 -32
  167. package/src/modes/components/settings-selector.ts +60 -18
  168. package/src/modes/components/tool-execution.ts +138 -27
  169. package/src/modes/components/tool-status-header.ts +1 -4
  170. package/src/modes/components/welcome.ts +18 -3
  171. package/src/modes/controllers/command-controller.ts +4 -0
  172. package/src/modes/controllers/event-controller.ts +109 -27
  173. package/src/modes/controllers/extension-ui-controller.ts +23 -12
  174. package/src/modes/controllers/input-controller.ts +62 -6
  175. package/src/modes/controllers/selector-controller.ts +83 -37
  176. package/src/modes/interactive-mode.ts +127 -29
  177. package/src/modes/irc-observation-ledger.ts +71 -15
  178. package/src/modes/runtime-init.ts +20 -2
  179. package/src/modes/types.ts +2 -0
  180. package/src/modes/utils/ui-helpers.ts +94 -1
  181. package/src/prompts/agents/architect.md +9 -1
  182. package/src/prompts/agents/critic.md +9 -0
  183. package/src/prompts/agents/executor.md +1 -1
  184. package/src/prompts/system/system-prompt.md +0 -1
  185. package/src/prompts/tools/read.md +4 -2
  186. package/src/runtime/memory-domain.ts +66 -0
  187. package/src/runtime/memory-guard-contract.ts +73 -0
  188. package/src/runtime/memory-guard.ts +292 -0
  189. package/src/runtime/memory-limit.ts +50 -0
  190. package/src/runtime-mcp/manager.ts +26 -6
  191. package/src/runtime-mcp/smithery-auth.ts +17 -2
  192. package/src/runtime-mcp/smithery-connect.ts +11 -1
  193. package/src/sdk/acp/adapter.ts +8 -1
  194. package/src/sdk/acp/final-text.ts +40 -0
  195. package/src/sdk/acp/mcp.ts +26 -0
  196. package/src/sdk/broker/lifecycle.ts +107 -7
  197. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  198. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  199. package/src/sdk/bus/config.ts +1 -1
  200. package/src/sdk/bus/control-drain-lease.ts +104 -0
  201. package/src/sdk/bus/index.ts +503 -60
  202. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  203. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  204. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  205. package/src/sdk/bus/reconciliation-store.ts +224 -0
  206. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  207. package/src/sdk/bus/telegram-daemon.ts +531 -53
  208. package/src/sdk/host/control/dispatch.ts +7 -1
  209. package/src/sdk/host/control/operations.ts +2 -1
  210. package/src/sdk/host/host.ts +25 -3
  211. package/src/sdk/host/query/handlers.ts +55 -0
  212. package/src/sdk/lifecycle-session.ts +19 -3
  213. package/src/sdk/mcp/server.ts +46 -18
  214. package/src/sdk/protocol/operation-inventory.generated.json +76 -0
  215. package/src/sdk/protocol/operation-registry.ts +8 -2
  216. package/src/sdk/session.ts +25 -2
  217. package/src/sdk/startup-capability.ts +6 -0
  218. package/src/session/agent-session.ts +720 -190
  219. package/src/session/fallback-chain-controller.ts +47 -8
  220. package/src/session/internal/managed-session-scope.ts +40 -10
  221. package/src/session/internal/native-publish-outcome.ts +8 -3
  222. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  223. package/src/session/session-manager.ts +1372 -42
  224. package/src/session/streaming-output.ts +262 -43
  225. package/src/setup/provider-onboarding.ts +19 -4
  226. package/src/skill-state/active-state.ts +0 -10
  227. package/src/skill-state/workflow-hud.ts +25 -2
  228. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  229. package/src/task/executor.ts +11 -3
  230. package/src/task/index.ts +2 -0
  231. package/src/task/types.ts +22 -11
  232. package/src/task/ultragoal-redteam-activation.ts +75 -0
  233. package/src/tools/ask.ts +89 -69
  234. package/src/tools/browser/launch.ts +41 -6
  235. package/src/tools/fetch.ts +114 -23
  236. package/src/tools/output-meta.ts +176 -9
  237. package/src/tools/path-utils.ts +5 -1
  238. package/src/tools/read-internals.ts +44 -0
  239. package/src/tools/read.ts +1542 -310
  240. package/src/tools/resource-gc.ts +665 -125
  241. package/src/tools/tool-result.ts +14 -1
  242. package/src/web/search/providers/codex.ts +1 -1
  243. package/src/workflow/workflow-intent-diff.ts +4 -1
  244. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  245. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  246. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  247. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  248. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  249. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -1,11 +1,12 @@
1
1
  import { Container, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@gajae-code/tui";
2
+ import { formatKeyHint } from "../../config/keybindings";
2
3
  import type { QueuedMessageEditEntry } from "../../session/agent-session";
3
4
  import { getSelectListTheme, theme } from "../theme/theme";
4
5
  import { DynamicBorder } from "./dynamic-border";
5
6
 
6
7
  const MAX_VISIBLE_QUEUE_MESSAGES = 8;
7
- const RAW_UP = "\x1b[A";
8
- const RAW_DOWN = "\x1b[B";
8
+ type QueueSelectorAction = "tui.select.confirm" | "tui.select.cancel";
9
+ type QueueSelectorNavigationAction = "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown";
9
10
 
10
11
  export class QueuePaneComponent extends Container {
11
12
  #selectList: SelectList;
@@ -13,11 +14,20 @@ export class QueuePaneComponent extends Container {
13
14
  #selectedIndex: number;
14
15
  #onDelete: (entry: QueuedMessageEditEntry, index: number) => void;
15
16
  #onMove: (entry: QueuedMessageEditEntry, index: number, direction: "up" | "down") => void;
16
-
17
+ #onSelect: (entry: QueuedMessageEditEntry) => void;
18
+ #onClose: () => void;
19
+ #entries: QueuedMessageEditEntry[];
20
+ #matchesSelectAction: (keyData: string, action: QueueSelectorAction) => boolean;
21
+ #resolveSelectNavigation: (keyData: string) => QueueSelectorNavigationAction | undefined;
17
22
  constructor(
18
23
  entries: QueuedMessageEditEntry[],
19
24
  options: {
20
25
  selectedIndex?: number;
26
+ formatKeyHint?: (key: string) => string;
27
+ formatSelectAction?: (action: QueueSelectorAction) => string;
28
+ matchesSelectAction?: (keyData: string, action: QueueSelectorAction) => boolean;
29
+ resolveSelectNavigation?: (keyData: string) => QueueSelectorNavigationAction | undefined;
30
+ onSelect: (entry: QueuedMessageEditEntry) => void;
21
31
  onDelete: (entry: QueuedMessageEditEntry, index: number) => void;
22
32
  onMove: (entry: QueuedMessageEditEntry, index: number, direction: "up" | "down") => void;
23
33
  onClose: () => void;
@@ -25,23 +35,46 @@ export class QueuePaneComponent extends Container {
25
35
  ) {
26
36
  super();
27
37
  this.#onDelete = options.onDelete;
38
+ this.#onSelect = options.onSelect;
28
39
  this.#onMove = options.onMove;
40
+ this.#onClose = options.onClose;
41
+ this.#entries = entries;
42
+ this.#matchesSelectAction =
43
+ options.matchesSelectAction ??
44
+ ((keyData, action) => matchesKey(keyData, action === "tui.select.confirm" ? "enter" : "escape"));
45
+ this.#resolveSelectNavigation = options.resolveSelectNavigation ?? (() => undefined);
29
46
  this.#selectedIndex = Math.max(0, Math.min(options.selectedIndex ?? 0, entries.length - 1));
30
47
  this.#selectedEntry = entries[this.#selectedIndex];
31
48
  const byId = new Map(entries.map(entry => [entry.id, entry]));
49
+ const displayKey = options.formatKeyHint ?? formatKeyHint;
50
+ const selectKeys = `${displayKey("alt+up")}/${displayKey("alt+down")}`;
51
+ const editKey = options.formatSelectAction
52
+ ? options.formatSelectAction("tui.select.confirm") || "Disabled"
53
+ : displayKey("enter");
54
+ const deleteKey = displayKey("delete");
55
+ const moveKeys = `${displayKey("ctrl+up")}/${displayKey("ctrl+down")}`;
56
+ const closeKey = options.formatSelectAction
57
+ ? options.formatSelectAction("tui.select.cancel") || "Disabled"
58
+ : displayKey("escape");
59
+ const itemHint = `${editKey} edit · ${deleteKey} remove · ${moveKeys} move`;
60
+ const controlsHint = `${selectKeys} select · ${itemHint} · ${closeKey} close`;
32
61
  const items: SelectItem[] = entries.map((entry, index) => ({
33
62
  value: entry.id,
34
63
  label: `${entry.label} ${index + 1}`,
35
64
  description: entry.text,
36
- hint: "Del remove · Ctrl+↑/↓ move",
65
+ hint: itemHint,
37
66
  }));
38
67
 
39
68
  this.addChild(new Spacer(1));
40
69
  this.addChild(new Text(theme.bold("Message queue"), 1, 0));
41
- this.addChild(new Text(theme.fg("muted", "Del remove · Ctrl+↑/↓ move · Esc close"), 1, 0));
70
+ this.addChild(new Text(theme.fg("muted", controlsHint), 1, 0));
42
71
  this.addChild(new Spacer(1));
43
72
  this.addChild(new DynamicBorder());
44
73
  this.#selectList = new SelectList(items, MAX_VISIBLE_QUEUE_MESSAGES, getSelectListTheme());
74
+ this.#selectList.onSelect = item => {
75
+ const entry = byId.get(item.value);
76
+ if (entry) this.#onSelect(entry);
77
+ };
45
78
  this.#selectList.onSelectionChange = item => {
46
79
  const index = entries.findIndex(entry => entry.id === item.value);
47
80
  this.#selectedIndex = index === -1 ? this.#selectedIndex : index;
@@ -54,12 +87,25 @@ export class QueuePaneComponent extends Container {
54
87
  }
55
88
 
56
89
  handleInput(keyData: string): void {
57
- if (matchesKey(keyData, "alt+up")) {
58
- this.#selectList.handleInput(RAW_UP);
90
+ if (this.#matchesSelectAction(keyData, "tui.select.confirm")) {
91
+ if (this.#selectedEntry) this.#onSelect(this.#selectedEntry);
92
+ return;
93
+ }
94
+ if (this.#matchesSelectAction(keyData, "tui.select.cancel")) {
95
+ this.#onClose();
96
+ return;
97
+ }
98
+ const navigation = this.#resolveSelectNavigation(keyData);
99
+ if (navigation) {
100
+ this.#selectList.handleNavigation(navigation);
59
101
  return;
60
102
  }
61
- if (matchesKey(keyData, "alt+down")) {
62
- this.#selectList.handleInput(RAW_DOWN);
103
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "escape")) return;
104
+ if (matchesKey(keyData, "alt+up") || matchesKey(keyData, "alt+down")) {
105
+ const direction = matchesKey(keyData, "alt+up") ? -1 : 1;
106
+ this.#selectedIndex = (this.#selectedIndex + direction + this.#entries.length) % this.#entries.length;
107
+ this.#selectedEntry = this.#entries[this.#selectedIndex];
108
+ this.#selectList.setSelectedIndex(this.#selectedIndex);
63
109
  return;
64
110
  }
65
111
  if (matchesKey(keyData, "ctrl+up") || matchesKey(keyData, "ctrl+shift+up")) {
@@ -1,12 +1,13 @@
1
1
  import { Container, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@gajae-code/tui";
2
+ import { formatKeyHint } from "../../config/keybindings";
2
3
  import type { QueuedMessageEditEntry } from "../../session/agent-session";
3
4
  import { getSelectListTheme, theme } from "../theme/theme";
4
5
  import { DynamicBorder } from "./dynamic-border";
5
6
 
6
7
  const MAX_VISIBLE_QUEUED_MESSAGES = 8;
7
- const RAW_UP = "\x1b[A";
8
- const RAW_DOWN = "\x1b[B";
9
8
  type QueuedMessageMoveDirection = "up" | "down";
9
+ type QueueSelectorAction = "tui.select.confirm" | "tui.select.cancel";
10
+ type QueueSelectorNavigationAction = "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown";
10
11
 
11
12
  export type { QueuedMessageMoveDirection };
12
13
 
@@ -16,32 +17,61 @@ export class QueuedMessageSelectorComponent extends Container {
16
17
  #selectedIndex = 0;
17
18
  #onDelete: (entry: QueuedMessageEditEntry, selectedIndex: number) => void;
18
19
  #onMove: (entry: QueuedMessageEditEntry, selectedIndex: number, direction: QueuedMessageMoveDirection) => void;
19
-
20
+ #onSelect: (entry: QueuedMessageEditEntry) => void;
21
+ #onCancel: () => void;
22
+ #entries: QueuedMessageEditEntry[];
23
+ #matchesSelectAction: (keyData: string, action: QueueSelectorAction) => boolean;
24
+ #resolveSelectNavigation: (keyData: string) => QueueSelectorNavigationAction | undefined;
20
25
  constructor(
21
26
  entries: QueuedMessageEditEntry[],
22
27
  onSelect: (entry: QueuedMessageEditEntry) => void,
23
28
  onDelete: (entry: QueuedMessageEditEntry, selectedIndex: number) => void,
24
29
  onMove: (entry: QueuedMessageEditEntry, selectedIndex: number, direction: QueuedMessageMoveDirection) => void,
25
30
  onCancel: () => void,
26
- options?: { selectedIndex?: number },
31
+ options?: {
32
+ selectedIndex?: number;
33
+ formatKeyHint?: (key: string) => string;
34
+ formatSelectAction?: (action: QueueSelectorAction) => string;
35
+ matchesSelectAction?: (keyData: string, action: QueueSelectorAction) => boolean;
36
+ resolveSelectNavigation?: (keyData: string) => QueueSelectorNavigationAction | undefined;
37
+ },
27
38
  ) {
28
39
  super();
29
40
 
30
41
  this.#onDelete = onDelete;
31
42
  this.#onMove = onMove;
43
+ this.#onSelect = onSelect;
44
+ this.#onCancel = onCancel;
45
+ this.#entries = entries;
46
+ this.#matchesSelectAction =
47
+ options?.matchesSelectAction ??
48
+ ((keyData, action) => matchesKey(keyData, action === "tui.select.confirm" ? "enter" : "escape"));
49
+ this.#resolveSelectNavigation = options?.resolveSelectNavigation ?? (() => undefined);
32
50
  const byId = new Map(entries.map(entry => [entry.id, entry]));
33
51
  this.#selectedIndex = Math.max(0, Math.min(options?.selectedIndex ?? 0, entries.length - 1));
34
52
  this.#selectedEntry = entries[this.#selectedIndex];
53
+ const displayKey = options?.formatKeyHint ?? formatKeyHint;
54
+ const selectKeys = `${displayKey("alt+up")}/${displayKey("alt+down")}`;
55
+ const editKey = options?.formatSelectAction
56
+ ? options.formatSelectAction("tui.select.confirm") || "Disabled"
57
+ : displayKey("enter");
58
+ const deleteKey = displayKey("delete");
59
+ const moveKeys = `${displayKey("ctrl+up")}/${displayKey("ctrl+down")}`;
60
+ const cancelKey = options?.formatSelectAction
61
+ ? options.formatSelectAction("tui.select.cancel") || "Disabled"
62
+ : displayKey("escape");
63
+ const itemHint = `${editKey} edit · ${deleteKey} remove · ${moveKeys} move`;
64
+ const controlsHint = `${selectKeys} select · ${itemHint} · ${cancelKey} cancel`;
35
65
  const items: SelectItem[] = entries.map((entry, index) => ({
36
66
  value: entry.id,
37
67
  label: `${entry.label} ${index + 1}`,
38
68
  description: entry.text,
39
- hint: "Enter edit · Del remove · Ctrl+↑/↓ move",
69
+ hint: itemHint,
40
70
  }));
41
71
 
42
72
  this.addChild(new Spacer(1));
43
73
  this.addChild(new Text(theme.bold("Queued messages"), 1, 0));
44
- this.addChild(new Text(theme.fg("muted", "Enter edit · Del remove · Ctrl+↑/↓ move · Esc cancel"), 1, 0));
74
+ this.addChild(new Text(theme.fg("muted", controlsHint), 1, 0));
45
75
  this.addChild(new Spacer(1));
46
76
  this.addChild(new DynamicBorder());
47
77
 
@@ -63,12 +93,25 @@ export class QueuedMessageSelectorComponent extends Container {
63
93
  }
64
94
 
65
95
  handleInput(keyData: string): void {
66
- if (matchesKey(keyData, "alt+up")) {
67
- this.#selectList.handleInput(RAW_UP);
96
+ if (this.#matchesSelectAction(keyData, "tui.select.confirm")) {
97
+ if (this.#selectedEntry) this.#onSelect(this.#selectedEntry);
98
+ return;
99
+ }
100
+ if (this.#matchesSelectAction(keyData, "tui.select.cancel")) {
101
+ this.#onCancel();
102
+ return;
103
+ }
104
+ const navigation = this.#resolveSelectNavigation(keyData);
105
+ if (navigation) {
106
+ this.#selectList.handleNavigation(navigation);
68
107
  return;
69
108
  }
70
- if (matchesKey(keyData, "alt+down")) {
71
- this.#selectList.handleInput(RAW_DOWN);
109
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "escape")) return;
110
+ if (matchesKey(keyData, "alt+up") || matchesKey(keyData, "alt+down")) {
111
+ const direction = matchesKey(keyData, "alt+up") ? -1 : 1;
112
+ this.#selectedIndex = (this.#selectedIndex + direction + this.#entries.length) % this.#entries.length;
113
+ this.#selectedEntry = this.#entries[this.#selectedIndex];
114
+ this.#selectList.setSelectedIndex(this.#selectedIndex);
72
115
  return;
73
116
  }
74
117
  if (matchesKey(keyData, "ctrl+up") || matchesKey(keyData, "ctrl+shift+up")) {
@@ -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
+ }