@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
@@ -0,0 +1,127 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { isEnoent } from "@gajae-code/utils";
5
+ import type { GcCollectResult, GcContext, GcError, GcRecord, GcStoreAdapter } from "../gjc-runtime/gc-runtime";
6
+
7
+ /**
8
+ * GC for `local://` session roots.
9
+ *
10
+ * `resolveLocalRoot` gives every session its own `<tmp>/gjc-local/<session-id>`
11
+ * directory, and the first `local://` resolution seeds it with a migration
12
+ * marker. Nothing ever removes those directories, so a machine accumulates one
13
+ * per session indefinitely — and the overwhelming majority only ever hold the
14
+ * marker, because most sessions never write a `local://` file.
15
+ *
16
+ * Only marker-only directories are eligible, and only after a grace period.
17
+ * That keeps the rule provably safe without needing a session-liveness oracle:
18
+ * a directory holding real content is never touched, and a session that just
19
+ * started is still inside its grace window.
20
+ */
21
+
22
+ const LOCAL_ROOT_PARENT = "gjc-local";
23
+ const LEGACY_MIGRATION_MARKER = ".gjc-local-legacy-migrated-v1";
24
+
25
+ /** Directories younger than this are left alone so a live session is never raced. */
26
+ const GRACE_MS = 24 * 60 * 60 * 1000;
27
+
28
+ /** Bound the scan so a pathological directory cannot stall `gjc gc`. */
29
+ const MAX_ENTRIES = 20_000;
30
+
31
+ function localRootParent(env: NodeJS.ProcessEnv): string {
32
+ return path.join(env.TMPDIR?.trim() || os.tmpdir(), LOCAL_ROOT_PARENT);
33
+ }
34
+
35
+ /** A root is prunable only when the marker is the single thing inside it. */
36
+ async function classify(dir: string, now: number): Promise<{ status: string; stale: boolean; reason: string } | null> {
37
+ let entries: string[];
38
+ try {
39
+ entries = await fs.readdir(dir);
40
+ } catch (error) {
41
+ if (isEnoent(error)) return null;
42
+ throw error;
43
+ }
44
+
45
+ const extra = entries.filter(name => name !== LEGACY_MIGRATION_MARKER);
46
+ if (extra.length > 0)
47
+ return {
48
+ status: "in_use",
49
+ stale: false,
50
+ reason: `Holds ${extra.length} entr${extra.length === 1 ? "y" : "ies"} beyond the marker`,
51
+ };
52
+
53
+ const stat = await fs.stat(dir);
54
+ if (now - stat.mtimeMs < GRACE_MS) return { status: "recent", stale: false, reason: "Inside the grace window" };
55
+ return { status: "marker_only", stale: true, reason: "Only the migration marker, past the grace window" };
56
+ }
57
+
58
+ export const localRootsGcAdapter: GcStoreAdapter = {
59
+ store: "local_roots",
60
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
61
+ const records: GcRecord[] = [];
62
+ const errors: GcError[] = [];
63
+ const parent = localRootParent(ctx.env);
64
+ const now = Date.now();
65
+
66
+ let dirents: string[];
67
+ try {
68
+ dirents = await fs.readdir(parent);
69
+ } catch (error) {
70
+ if (isEnoent(error)) return { records, errors };
71
+ errors.push({ store: "local_roots", scope: parent, message: (error as Error).message });
72
+ return { records, errors };
73
+ }
74
+
75
+ for (const name of dirents.slice(0, MAX_ENTRIES)) {
76
+ const dir = path.join(parent, name);
77
+ try {
78
+ const classified = await classify(dir, now);
79
+ if (!classified) continue;
80
+ records.push({
81
+ store: "local_roots",
82
+ id: name,
83
+ path: dir,
84
+ status: classified.status,
85
+ stale: classified.stale,
86
+ removable: classified.stale,
87
+ action: classified.stale ? "would_remove" : "none",
88
+ reason: classified.reason,
89
+ });
90
+ } catch (error) {
91
+ errors.push({ store: "local_roots", scope: dir, message: (error as Error).message });
92
+ }
93
+ }
94
+
95
+ if (dirents.length > MAX_ENTRIES) {
96
+ errors.push({
97
+ store: "local_roots",
98
+ scope: parent,
99
+ message: `Scan truncated at ${MAX_ENTRIES} entries`,
100
+ });
101
+ }
102
+
103
+ return { records, errors };
104
+ },
105
+ async prune(record: GcRecord) {
106
+ const dir = record.path;
107
+ if (!dir) return { removed: false, error: "Missing local root path" };
108
+
109
+ // Re-validate immediately before removing: the session may have written a
110
+ // file between collect and prune.
111
+ let classified: Awaited<ReturnType<typeof classify>>;
112
+ try {
113
+ classified = await classify(dir, Date.now());
114
+ } catch (error) {
115
+ return { removed: false, error: (error as Error).message };
116
+ }
117
+ if (!classified) return { removed: false, skipped: "Local root disappeared" };
118
+ if (!classified.stale) return { removed: false, skipped: `Local root no longer eligible (${classified.status})` };
119
+
120
+ try {
121
+ await fs.rm(dir, { recursive: true, force: true });
122
+ return { removed: true };
123
+ } catch (error) {
124
+ return { removed: false, error: (error as Error).message };
125
+ }
126
+ },
127
+ };
package/src/main.ts CHANGED
@@ -698,7 +698,7 @@ async function promptForkSession(session: SessionInfo): Promise<boolean> {
698
698
  }
699
699
  }
700
700
 
701
- async function getChangelogForDisplay(parsed: Args): Promise<string | undefined> {
701
+ export async function getChangelogForDisplay(parsed: Args): Promise<string | undefined> {
702
702
  if (parsed.continue || parsed.resume) {
703
703
  return undefined;
704
704
  }
@@ -710,15 +710,19 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
710
710
 
711
711
  const lastVersion = settings.get("lastChangelogVersion");
712
712
  if (!lastVersion) {
713
- settings.set("lastChangelogVersion", VERSION);
714
- await flushChangelogVersion();
713
+ if (settings.canWriteDurableConfig()) {
714
+ settings.set("lastChangelogVersion", VERSION);
715
+ await flushChangelogVersion();
716
+ }
715
717
  return getInstalledVersionChangelogEntry(entries, VERSION)?.content;
716
718
  }
717
719
 
718
720
  if (lastVersion !== VERSION) {
719
721
  const newEntries = getNewEntries(entries, lastVersion);
720
- settings.set("lastChangelogVersion", VERSION);
721
- await flushChangelogVersion();
722
+ if (settings.canWriteDurableConfig()) {
723
+ settings.set("lastChangelogVersion", VERSION);
724
+ await flushChangelogVersion();
725
+ }
722
726
  if (newEntries.length > 0) {
723
727
  return newEntries.map(e => e.content).join("\n\n");
724
728
  }
@@ -976,6 +976,11 @@ function redactSecrets(input: string): string {
976
976
  /(?:sk|pk|rk|tok|key|secret|token|password)[-_A-Za-z0-9]{12,}/g,
977
977
  /[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g,
978
978
  /(?:AKIA|ASIA)[A-Z0-9]{16}/g,
979
+ // GitHub tokens carry no keyword the first pattern recognizes, so without
980
+ // this they reached MEMORY.md and memory_summary.md verbatim — and the
981
+ // summary is injected into every later session. Same shapes the
982
+ // contribution-prep scrubber already covers.
983
+ /\b(?:gh[opsur]_[A-Za-z0-9_]{12,}|github_pat_[A-Za-z0-9_]{12,})\b/g,
979
984
  ];
980
985
  for (const pattern of patterns) {
981
986
  out = out.replace(pattern, "[REDACTED]");
@@ -983,6 +988,11 @@ function redactSecrets(input: string): string {
983
988
  return out;
984
989
  }
985
990
 
991
+ /** Secret scrubbing applied to every phase-2 consolidation output. Test seam. */
992
+ export function redactMemorySecretsForTesting(input: string): string {
993
+ return redactSecrets(input);
994
+ }
995
+
986
996
  function sanitizeSkillName(name: string): string {
987
997
  return name
988
998
  .toLowerCase()
@@ -261,3 +261,50 @@ record both manifest counts as 108 plus CJK review results.
261
261
 
262
262
  No raw third-party design corpus, screenshot, or reference asset is stored by
263
263
  this workflow.
264
+
265
+ ## Sticky transcript viewport contract
266
+
267
+ Live mode remains in natural terminal flow. Entering manual history makes the
268
+ application-owned transcript lane scrollable while the status line and every
269
+ later direct composer child are a fixed suffix at the bottom. PageUp/PageDown
270
+ move by the transcript capacity; the wheel remains three rows. A focused editor
271
+ keeps focus, and ordinary editor input or paste follows live before processing
272
+ that input.
273
+
274
+ Manual output has one bounded boolean indication, exactly **`New output — type
275
+ to follow`**. It is not a count. New visible agent/tool/extension output may set
276
+ it while manual; reflow, transient chrome, reconciliation, and user input may
277
+ not. Following clears it only after a successful live repaint. Manual-era output
278
+ is authoritative in the application transcript but is never retroactively
279
+ replayed into native host scrollback; subsequent ordinary live output follows
280
+ normal host behavior.
281
+
282
+ The transcript is the only selectable coordinate space. Pinned status/composer
283
+ chrome, notices, blank rows, and overlays never enter selection or copied text;
284
+ CJK selection clamping remains directional and grapheme/cell aware. Under
285
+ constrained height the notice drops first, then decorative pet and low-priority
286
+ hooks. Focused editor/cursor, status, and normal hooks outrank those rows; zero
287
+ transcript capacity is valid and must not corrupt cursor geometry.
288
+
289
+ At narrow widths use ANSI-aware terminal-cell measurement. ASCII/no-color keeps
290
+ textual state without SGR. Korean, Japanese, Chinese, and mixed CJK/Latin prose
291
+ must wrap at semantic phrase boundaries, never through an action/status label or
292
+ short identifier; a semantic CJK break is a visual-QA failure.
293
+
294
+ ### Sticky viewport deterministic visual QA
295
+
296
+ `test/fixtures/tui/sticky-viewport-showcase.ts` is a fixed-clock, no-network,
297
+ first-party harness that starts the production `TUI` over a `VirtualTerminal`.
298
+ It constructs transcript, status, hooks, and the real composer as children,
299
+ then drives the live/manual viewport path before capturing the terminal frame.
300
+ Capture with `bun packages/coding-agent/scripts/capture-sticky-viewport-showcase.ts
301
+ --out .gjc/qa/sticky-viewport-<run>` and verify with the paired `--root` script.
302
+ The immutable matrix has exactly 20 keys: `live-overflow`, `manual-history`,
303
+ `manual-new-output`, `multiline-editor-hooks-pet`, `capacity-many`,
304
+ `capacity-one`, `capacity-zero`, and `selection-boundary` at both 80x24 and
305
+ 120x36 Unicode/color; plus `manual-new-output/80x24/ascii-no-color`,
306
+ `capacity-zero/48x10/ascii-no-color`,
307
+ `multiline-editor-hooks-pet/48x10/unicode-color`, and
308
+ `narrow-cjk/48x10/unicode-color`. Do not add or replace a manual-follow case.
309
+
310
+ Each key has only `terminal.txt`, ANSI-preserving `terminal-ansi.txt`, `terminal.html`, and `metadata.json`; the manifest records SHA-256 and byte length. Per-key metadata binds immutable font/render assumptions and the ANSI-aware wrapping/truncation policy. `VirtualTerminal` reconstructs ANSI from visible xterm cells, including cell padding, palette/RGB colors, attributes, and inverse video; plain text is always the stripped reconstruction. The verifier owns an independent literal 20-key oracle and fails closed unless stripped ANSI equals text, `terminal.html` equals the exported canonical `ansiToHtml(terminal-ansi.txt)` byte-for-byte (including its complete document envelope and global CSS), HTML independently preserves the ANSI style-run text, every retained row has the exact `Bun.stringWidth` cell width (including trailing spaces), and `ansi_mode` agrees with required Unicode color SGR or ASCII/no-color output. Every metadata entry has exact CJK phrase-boundary metadata: the narrow-CJK key has only the three canonical boundaries in order and every other key has `[]`. Manual captures prove successful production wheel and PageUp paths and retain observable historical transcript-row evidence. It validates exact payload paths (no duplicates or traversal), immutable source/output revisions, state/status/suffix order, notice cardinality, capacity, actual mouse-copied transcript-only selection, composer, CJK, and provenance invariants. `review-input.json` binds the exact manifest digest, capture author/executor identity, acceptance/design versions, required artifacts, narrow-CJK boundaries, and deterministic host matrix. `--require-independent-review` requires an attestation with an exact root key set; exact per-key result and artifact-check key sets; exact defect `{ description, accepted }` keys with a trimmed, nonblank description; canonical trimmed reviewer identity distinct from both bound identities; the independent-terminal-reviewer role; fixture revision; expected and observed counts of 20; exact checked keys; accepted per-key artifact-check/notes results; accepted artifact/CJK/host decisions; bound digest; and final `accept`. Any malformed, incomplete, or extra attestation content fails closed.
@@ -25,6 +25,7 @@ import {
25
25
  PROTOCOL_VERSION,
26
26
  type PromptRequest,
27
27
  type PromptResponse,
28
+ RequestError,
28
29
  type ResumeSessionRequest,
29
30
  type ResumeSessionResponse,
30
31
  type SessionInfo,
@@ -532,6 +533,34 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
532
533
  return { text: text.join("\n"), images };
533
534
  }
534
535
 
536
+ /**
537
+ * `AcpSdkAdapterError.code` is an internal string, but the SDK only derives a
538
+ * JSON-RPC code from a `RequestError`. Everything else collapses to an opaque
539
+ * `-32603 Internal error`, which hides the reason and defeats client-side
540
+ * recovery (an ACP client cannot see that it must authenticate). Map the codes
541
+ * that have a defined ACP/JSON-RPC counterpart onto a real `RequestError`.
542
+ */
543
+ export function acpRequestFailure(error: unknown): unknown {
544
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
545
+ if (typeof code !== "string") return error;
546
+ const message = error instanceof Error ? error.message : code;
547
+ switch (code) {
548
+ case "authentication_failed":
549
+ return RequestError.authRequired({ code, details: message }, message);
550
+ case "not_found":
551
+ return RequestError.resourceNotFound(message);
552
+ case "invalid_input":
553
+ case "unsupported":
554
+ case "unsupported_content":
555
+ return RequestError.invalidParams({ code, details: message }, message);
556
+ default:
557
+ // The remaining internal codes (conflict, unavailable, busy, …) have no ACP
558
+ // counterpart and stay -32603. Keep the discriminator in `data` so a client can
559
+ // branch on retry/reconnect instead of parsing an English message.
560
+ return RequestError.internalError({ code, details: message }, message);
561
+ }
562
+ }
563
+
535
564
  /** Registers a permission provider only when the ACP client requires prompts. */
536
565
  export function acpProviderRegistrations(
537
566
  capabilities: ClientCapabilities | undefined,
@@ -988,6 +1017,16 @@ export class AcpAgent implements Agent {
988
1017
  }
989
1018
 
990
1019
  async extMethod(method: string, params: JsonObject): Promise<JsonObject> {
1020
+ // An unrecognized extension method is a protocol failure, not an application
1021
+ // result: it must reach the client as JSON-RPC -32601 rather than a resolved
1022
+ // payload. Recognized `_gjc/*` methods keep their `{ok:false}` result contract.
1023
+ if (
1024
+ method !== "session/set_model" &&
1025
+ method !== "_gjc/sdk/global" &&
1026
+ method !== "_gjc/sdk/control" &&
1027
+ method !== "_gjc/sdk/query"
1028
+ )
1029
+ throw RequestError.methodNotFound(method);
991
1030
  try {
992
1031
  if (method === "session/set_model") {
993
1032
  const sessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
@@ -1001,13 +1040,10 @@ export class AcpAgent implements Agent {
1001
1040
  const result = await (await this.#brokerAdapter()).handle(method, params);
1002
1041
  return object(result) ?? {};
1003
1042
  }
1004
- if (method === "_gjc/sdk/control" || method === "_gjc/sdk/query") {
1005
- const id = typeof params.sessionId === "string" ? params.sessionId : undefined;
1006
- if (!id) throw new AcpSdkAdapterError("invalid_input", "sessionId is required.");
1007
- const result = await this.#adapter(id).handle(method, params);
1008
- return object(result) ?? {};
1009
- }
1010
- throw new AcpSdkAdapterError("method_not_found", `Unknown ACP ext method: ${method}`);
1043
+ const id = typeof params.sessionId === "string" ? params.sessionId : undefined;
1044
+ if (!id) throw new AcpSdkAdapterError("invalid_input", "sessionId is required.");
1045
+ const result = await this.#adapter(id).handle(method, params);
1046
+ return object(result) ?? {};
1011
1047
  } catch (error) {
1012
1048
  const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "internal";
1013
1049
  const message = error instanceof Error ? error.message : String(error);
@@ -198,7 +198,10 @@ export function mapAgentSessionEventToAcpSessionUpdates(
198
198
  }
199
199
  case "tool_execution_end": {
200
200
  const args = getToolExecutionEndArgs(event, options);
201
- const resultContent = [...extractDiffToolCallContent(event.result), ...extractToolCallContent(event.result)];
201
+ const resultContent = [
202
+ ...extractDiffToolCallContent(event.result, options.cwd),
203
+ ...extractToolCallContent(event.result),
204
+ ];
202
205
  const content = mergeToolUpdateContent(buildToolStartContent(event.toolName, args), resultContent);
203
206
  const update: SessionUpdate = {
204
207
  sessionUpdate: "tool_call_update",
@@ -207,8 +210,9 @@ export function mapAgentSessionEventToAcpSessionUpdates(
207
210
  rawOutput: event.result,
208
211
  };
209
212
  if (event.isError) {
213
+ // Failure is carried by `status`; `kind` stays the tool's real category so
214
+ // clients keep the icon/treatment established by the initial `tool_call`.
210
215
  update.title = `Failed: ${buildToolTitle(event.toolName, args, undefined)}`;
211
- update.kind = "other";
212
216
  }
213
217
  if (content.length > 0) {
214
218
  update.content = content;
@@ -713,7 +717,7 @@ function extractToolLocationsFromResult(result: unknown, cwd?: string): ToolCall
713
717
  }
714
718
 
715
719
  /** Emit a `diff` ToolCallContent for each per-file edit result that carries oldText/newText. */
716
- function extractDiffToolCallContent(result: unknown): ToolCallContent[] {
720
+ function extractDiffToolCallContent(result: unknown, cwd?: string): ToolCallContent[] {
717
721
  if (typeof result !== "object" || result === null) return [];
718
722
  const details = (result as { details?: unknown }).details;
719
723
  if (typeof details !== "object" || details === null) return [];
@@ -721,13 +725,13 @@ function extractDiffToolCallContent(result: unknown): ToolCallContent[] {
721
725
  const perFile = (details as { perFileResults?: unknown }).perFileResults;
722
726
  const entries: unknown[] = Array.isArray(perFile) ? perFile : [details];
723
727
  for (const entry of entries) {
724
- const block = buildDiffContent(entry);
728
+ const block = buildDiffContent(entry, cwd);
725
729
  if (block) blocks.push(block);
726
730
  }
727
731
  return blocks;
728
732
  }
729
733
 
730
- function buildDiffContent(entry: unknown): ToolCallContent | undefined {
734
+ function buildDiffContent(entry: unknown, cwd?: string): ToolCallContent | undefined {
731
735
  if (typeof entry !== "object" || entry === null) return undefined;
732
736
  const candidate = entry as { path?: unknown; oldText?: unknown; newText?: unknown; isError?: unknown };
733
737
  if (candidate.isError === true) return undefined;
@@ -738,7 +742,8 @@ function buildDiffContent(entry: unknown): ToolCallContent | undefined {
738
742
  if (oldText === undefined && newText === undefined) return undefined;
739
743
  return {
740
744
  type: "diff",
741
- path,
745
+ // `Diff.path` is specified as an absolute path, same as ToolCallLocation.
746
+ path: toAcpLocationPath(path, cwd),
742
747
  oldText: oldText ?? null,
743
748
  newText: newText ?? "",
744
749
  };
@@ -859,7 +864,9 @@ function toToolCallContent(value: unknown): ToolCallContent | undefined {
859
864
  resourceLinkContent.mimeType = mimeType;
860
865
  }
861
866
  const size = extractNumberProperty<ResourceLinkLikeContent>(value, "size");
862
- if (size !== undefined) {
867
+ // `ResourceLink.size` is a byte count typed as int64; drop fractional, negative,
868
+ // or non-representable values rather than emitting a block clients must reject.
869
+ if (size !== undefined && Number.isSafeInteger(size) && size >= 0) {
863
870
  resourceLinkContent.size = size;
864
871
  }
865
872
  return {
@@ -1,6 +1,6 @@
1
1
  import * as stream from "node:stream";
2
- import { AgentSideConnection, ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
3
- import { AcpAgent } from "./acp-agent";
2
+ import { type Agent, AgentSideConnection, ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
3
+ import { AcpAgent, acpRequestFailure } from "./acp-agent";
4
4
  import type { AcpStartupOptions } from "./startup-options";
5
5
 
6
6
  export interface AcpModeOptions {
@@ -8,8 +8,44 @@ export interface AcpModeOptions {
8
8
  startupOptions?: AcpStartupOptions;
9
9
  }
10
10
 
11
+ /**
12
+ * Every agent method reports failures to the client, and the SDK only derives a
13
+ * JSON-RPC code from a `RequestError` — anything else collapses to an opaque
14
+ * `-32603 Internal error` with the reason buried in `data`. Translating once at the
15
+ * connection boundary keeps that contract in one place instead of asking each method
16
+ * to remember it.
17
+ */
18
+ function withAcpRequestFailures(agent: AcpAgent): Agent {
19
+ const wrapped = new Map<string | symbol, (...args: unknown[]) => unknown>();
20
+ return new Proxy(agent, {
21
+ get(target, property) {
22
+ // Read against the target, not the proxy: `signal`/`closed` are getters that
23
+ // touch private fields, and the proxy carries no private-field brand.
24
+ const value = Reflect.get(target, property, target);
25
+ if (typeof value !== "function") return value;
26
+ const cached = wrapped.get(property);
27
+ if (cached) return cached;
28
+ // Cache per property so repeated reads keep a stable method identity.
29
+ const wrapper = (...args: unknown[]) => {
30
+ try {
31
+ const result = (value as (...a: unknown[]) => unknown).apply(target, args);
32
+ return result instanceof Promise
33
+ ? result.catch((error: unknown) => {
34
+ throw acpRequestFailure(error);
35
+ })
36
+ : result;
37
+ } catch (error) {
38
+ throw acpRequestFailure(error);
39
+ }
40
+ };
41
+ wrapped.set(property, wrapper);
42
+ return wrapper;
43
+ },
44
+ }) as unknown as Agent;
45
+ }
46
+
11
47
  export function createAcpConnection(transport: Stream, options: AcpModeOptions = {}): AgentSideConnection {
12
- return new AgentSideConnection(conn => new AcpAgent(conn, options), transport);
48
+ return new AgentSideConnection(conn => withAcpRequestFailures(new AcpAgent(conn, options)), transport);
13
49
  }
14
50
 
15
51
  export async function runAcpMode(options: AcpModeOptions = {}): Promise<never> {
@@ -84,8 +84,10 @@ export class AssistantMessageComponent extends Container {
84
84
  #lastMessage?: AssistantMessage;
85
85
  #toolImagesByCallId = new Map<string, ImageContent[]>();
86
86
  #usageInfo?: Usage;
87
- #convertedKittyImages = new Map<string, ImageContent>();
88
- #kittyConversionsInFlight = new Set<string>();
87
+ #convertedKittyImages = new Map<string, ImageContent & { source: string }>();
88
+ #kittyConversionsInFlight = new Map<string, string>();
89
+ #toolImageGenerations = new Map<string, number>();
90
+
89
91
  #responseHeader = new Text(theme.bold(theme.fg("statusLineModel", "gajae")), 1, 0);
90
92
  #contentBlocksCache = new WeakMap<object, { source: string; component: Component }>();
91
93
  #lastStreaming = false;
@@ -94,11 +96,17 @@ export class AssistantMessageComponent extends Container {
94
96
  #nextContentBlockKey = 0;
95
97
  #reusableChildren = new WeakSet<Component>();
96
98
  #viewportAnchorId?: string;
99
+ #visibleSignature = "";
100
+ #visibleSignatureInitialized = false;
101
+ #toolImagesRevision = 0;
102
+ #convertedToolImagesRevision = 0;
103
+ #disposed = false;
97
104
  constructor(
98
105
  message?: AssistantMessage,
99
106
  private hideThinkingBlock = false,
100
107
  private readonly onImageUpdate?: () => void,
101
108
  viewportAnchorId?: string,
109
+ private readonly onVisibleMutation?: () => void,
102
110
  ) {
103
111
  super();
104
112
  this.#viewportAnchorId = viewportAnchorId;
@@ -109,6 +117,56 @@ export class AssistantMessageComponent extends Container {
109
117
  if (message) {
110
118
  this.updateContent(message);
111
119
  }
120
+ this.#visibleSignature = this.#visibleContentSignature();
121
+ this.#visibleSignatureInitialized = true;
122
+ }
123
+
124
+ #visibleContentSignature(): string {
125
+ const message = this.#lastMessage;
126
+ const content: string[] = [];
127
+ let hasMarkdownContent = false;
128
+ for (const block of message?.content ?? []) {
129
+ if (block.type === "text" && block.text.trim()) {
130
+ content.push(`text:${block.text.trim()}`);
131
+ hasMarkdownContent = true;
132
+ } else if (block.type === "thinking" && block.thinking.trim()) {
133
+ content.push(
134
+ this.hideThinkingBlock
135
+ ? "thinking"
136
+ : `thinking:${elideRunawayThinkingRepetition(block.thinking.trim())}`,
137
+ );
138
+ if (!this.hideThinkingBlock) hasMarkdownContent = true;
139
+ }
140
+ }
141
+ if (hasMarkdownContent) {
142
+ content.unshift(this.#lastStreaming ? "streaming" : "final");
143
+ }
144
+ const hasToolCalls = message?.content.some(block => block.type === "toolCall") ?? false;
145
+ if (!hasToolCalls && message?.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
146
+ content.push(`aborted:${message.errorMessage ?? ""}`);
147
+ } else if (!hasToolCalls && message?.stopReason === "error") {
148
+ content.push(`error:${message.errorMessage ?? ""}`);
149
+ } else if (
150
+ message?.errorMessage &&
151
+ !isSilentAbort(message.errorMessage) &&
152
+ message.stopReason !== "aborted" &&
153
+ message.stopReason !== "error"
154
+ ) {
155
+ content.push(`message-error:${message.errorMessage}`);
156
+ }
157
+ if (settings.get("display.showTokenUsage") && this.#usageInfo) {
158
+ const usage = this.#usageInfo;
159
+ content.push(`usage:${usage.input}:${usage.cacheWrite}:${usage.output}:${usage.cacheRead}`);
160
+ }
161
+ content.push(`images:${this.#toolImagesRevision}:${this.#convertedToolImagesRevision}`);
162
+ return content.join("\u0000");
163
+ }
164
+
165
+ #notifyVisibleMutation(): void {
166
+ const after = this.#visibleContentSignature();
167
+ if (after === this.#visibleSignature) return;
168
+ this.#visibleSignature = after;
169
+ if (this.#visibleSignatureInitialized) this.onVisibleMutation?.();
112
170
  }
113
171
 
114
172
  #contentAnchorSource(
@@ -126,22 +184,36 @@ export class AssistantMessageComponent extends Container {
126
184
  }
127
185
 
128
186
  setHideThinkingBlock(hide: boolean): void {
187
+ if (this.hideThinkingBlock === hide) return;
129
188
  this.hideThinkingBlock = hide;
189
+ this.#notifyVisibleMutation();
130
190
  }
131
191
 
132
192
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
133
193
  if (!toolCallId) return;
134
194
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
195
+ const previousImages = this.#toolImagesByCallId.get(toolCallId);
196
+ if (
197
+ (previousImages?.length ?? 0) === validImages.length &&
198
+ (previousImages?.every(
199
+ (image, index) =>
200
+ image.type === validImages[index]?.type &&
201
+ image.data === validImages[index]?.data &&
202
+ image.mimeType === validImages[index]?.mimeType,
203
+ ) ??
204
+ true)
205
+ ) {
206
+ return;
207
+ }
135
208
  for (const key of Array.from(this.#convertedKittyImages.keys())) {
136
- if (key.startsWith(`${toolCallId}:`)) {
137
- this.#convertedKittyImages.delete(key);
138
- }
209
+ if (key.startsWith(`${toolCallId}:`)) this.#convertedKittyImages.delete(key);
139
210
  }
140
- for (const key of Array.from(this.#kittyConversionsInFlight)) {
141
- if (key.startsWith(`${toolCallId}:`)) {
142
- this.#kittyConversionsInFlight.delete(key);
143
- }
211
+ for (const key of Array.from(this.#kittyConversionsInFlight.keys())) {
212
+ if (key.startsWith(`${toolCallId}:`)) this.#kittyConversionsInFlight.delete(key);
144
213
  }
214
+ this.#toolImageGenerations.set(toolCallId, (this.#toolImageGenerations.get(toolCallId) ?? 0) + 1);
215
+
216
+ this.#toolImagesRevision += 1;
145
217
  if (validImages.length === 0) {
146
218
  this.#toolImagesByCallId.delete(toolCallId);
147
219
  } else {
@@ -153,30 +225,49 @@ export class AssistantMessageComponent extends Container {
153
225
  }
154
226
  }
155
227
 
228
+ #toolImageSource(image: ImageContent): string {
229
+ return `${image.mimeType}\u0000${image.data}`;
230
+ }
231
+
156
232
  #convertToolImagesForKitty(toolCallId: string, images: ImageContent[]): void {
157
233
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
158
- for (let index = 0; index < images.length; index++) {
159
- const image = images[index];
160
- if (!image || image.mimeType === "image/png") continue;
234
+ const generation = this.#toolImageGenerations.get(toolCallId) ?? 0;
235
+ for (const [index, image] of images.entries()) {
236
+ if (image.mimeType === "image/png") continue;
161
237
  const key = `${toolCallId}:${index}`;
162
- if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
163
- this.#kittyConversionsInFlight.add(key);
238
+ const source = this.#toolImageSource(image);
239
+ if (
240
+ this.#convertedKittyImages.get(key)?.source === source ||
241
+ this.#kittyConversionsInFlight.get(key) === source
242
+ )
243
+ continue;
244
+ this.#kittyConversionsInFlight.set(key, source);
164
245
  new Bun.Image(Buffer.from(image.data, "base64"))
165
246
  .png()
166
247
  .toBase64()
167
248
  .then(data => {
249
+ const currentImage = this.#toolImagesByCallId.get(toolCallId)?.[index];
250
+ if (
251
+ this.#disposed ||
252
+ this.#toolImageGenerations.get(toolCallId) !== generation ||
253
+ this.#kittyConversionsInFlight.get(key) !== source ||
254
+ !currentImage ||
255
+ this.#toolImageSource(currentImage) !== source
256
+ )
257
+ return;
168
258
  this.#kittyConversionsInFlight.delete(key);
169
- this.#convertedKittyImages.set(key, {
170
- type: "image",
171
- data,
172
- mimeType: "image/png",
173
- });
174
- if (this.#lastMessage) {
175
- this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
176
- }
259
+ this.#convertedKittyImages.set(key, { type: "image", data, mimeType: "image/png", source });
260
+ this.#convertedToolImagesRevision += 1;
261
+ if (this.#lastMessage) this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
177
262
  this.onImageUpdate?.();
178
263
  })
179
264
  .catch(() => {
265
+ if (
266
+ this.#disposed ||
267
+ this.#toolImageGenerations.get(toolCallId) !== generation ||
268
+ this.#kittyConversionsInFlight.get(key) !== source
269
+ )
270
+ return;
180
271
  this.#kittyConversionsInFlight.delete(key);
181
272
  });
182
273
  }
@@ -487,5 +578,15 @@ export class AssistantMessageComponent extends Container {
487
578
  }
488
579
 
489
580
  this.#reconcileChildren(descriptors);
581
+ this.#notifyVisibleMutation();
582
+ }
583
+
584
+ override dispose(): void {
585
+ if (this.#disposed) return;
586
+ this.#disposed = true;
587
+ this.#toolImageGenerations.clear();
588
+ this.#kittyConversionsInFlight.clear();
589
+ this.#convertedKittyImages.clear();
590
+ super.dispose();
490
591
  }
491
592
  }