hammoc 1.7.2 → 1.8.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 (130) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/packages/client/dist/assets/{agentExampleHighlight-De1SlBXH.js → agentExampleHighlight-B2IJxYzN.js} +1 -1
  4. package/packages/client/dist/assets/{commandTokenHighlight-COQi5YnH.js → commandTokenHighlight-BdGXkDR5.js} +1 -1
  5. package/packages/client/dist/assets/{index-D8XvfvVS.js → index-BFWGN51V.js} +1 -1
  6. package/packages/client/dist/assets/index-BX7y_Qt3.css +32 -0
  7. package/packages/client/dist/assets/{index-CQgqPGOG.js → index-lmx-m-iP.js} +552 -478
  8. package/packages/client/dist/assets/{snippetTokenHighlight-jMAHlrFd.js → snippetTokenHighlight-B9bH9Npx.js} +1 -1
  9. package/packages/client/dist/index.html +2 -2
  10. package/packages/client/dist/sw.js +1 -1
  11. package/packages/server/dist/app.d.ts.map +1 -1
  12. package/packages/server/dist/app.js +25 -1
  13. package/packages/server/dist/app.js.map +1 -1
  14. package/packages/server/dist/config/index.d.ts +10 -0
  15. package/packages/server/dist/config/index.d.ts.map +1 -1
  16. package/packages/server/dist/config/index.js +10 -0
  17. package/packages/server/dist/config/index.js.map +1 -1
  18. package/packages/server/dist/controllers/accountController.d.ts +19 -0
  19. package/packages/server/dist/controllers/accountController.d.ts.map +1 -0
  20. package/packages/server/dist/controllers/accountController.js +92 -0
  21. package/packages/server/dist/controllers/accountController.js.map +1 -0
  22. package/packages/server/dist/controllers/projectController.d.ts +1 -1
  23. package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
  24. package/packages/server/dist/controllers/projectController.js +19 -5
  25. package/packages/server/dist/controllers/projectController.js.map +1 -1
  26. package/packages/server/dist/controllers/serverController.d.ts.map +1 -1
  27. package/packages/server/dist/controllers/serverController.js +8 -1
  28. package/packages/server/dist/controllers/serverController.js.map +1 -1
  29. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  30. package/packages/server/dist/handlers/websocket.js +83 -6
  31. package/packages/server/dist/handlers/websocket.js.map +1 -1
  32. package/packages/server/dist/index.js +8 -0
  33. package/packages/server/dist/index.js.map +1 -1
  34. package/packages/server/dist/middleware/session.d.ts.map +1 -1
  35. package/packages/server/dist/middleware/session.js +7 -5
  36. package/packages/server/dist/middleware/session.js.map +1 -1
  37. package/packages/server/dist/routes/accounts.d.ts +13 -0
  38. package/packages/server/dist/routes/accounts.d.ts.map +1 -0
  39. package/packages/server/dist/routes/accounts.js +18 -0
  40. package/packages/server/dist/routes/accounts.js.map +1 -0
  41. package/packages/server/dist/routes/config.d.ts +3 -0
  42. package/packages/server/dist/routes/config.d.ts.map +1 -0
  43. package/packages/server/dist/routes/config.js +59 -0
  44. package/packages/server/dist/routes/config.js.map +1 -0
  45. package/packages/server/dist/routes/preferences.d.ts.map +1 -1
  46. package/packages/server/dist/routes/preferences.js +22 -4
  47. package/packages/server/dist/routes/preferences.js.map +1 -1
  48. package/packages/server/dist/services/accountInfoService.js +6 -6
  49. package/packages/server/dist/services/accountInfoService.js.map +1 -1
  50. package/packages/server/dist/services/accountStorageService.d.ts +119 -0
  51. package/packages/server/dist/services/accountStorageService.d.ts.map +1 -0
  52. package/packages/server/dist/services/accountStorageService.js +285 -0
  53. package/packages/server/dist/services/accountStorageService.js.map +1 -0
  54. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  55. package/packages/server/dist/services/chatService.js +4 -8
  56. package/packages/server/dist/services/chatService.js.map +1 -1
  57. package/packages/server/dist/services/claudeLoginService.d.ts +124 -0
  58. package/packages/server/dist/services/claudeLoginService.d.ts.map +1 -0
  59. package/packages/server/dist/services/claudeLoginService.js +417 -0
  60. package/packages/server/dist/services/claudeLoginService.js.map +1 -0
  61. package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -1
  62. package/packages/server/dist/services/cliChatEngine.js +85 -33
  63. package/packages/server/dist/services/cliChatEngine.js.map +1 -1
  64. package/packages/server/dist/services/cliGridCards.d.ts +30 -10
  65. package/packages/server/dist/services/cliGridCards.d.ts.map +1 -1
  66. package/packages/server/dist/services/cliGridCards.js +80 -34
  67. package/packages/server/dist/services/cliGridCards.js.map +1 -1
  68. package/packages/server/dist/services/cliModalDetect.d.ts +15 -7
  69. package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -1
  70. package/packages/server/dist/services/cliModalDetect.js +16 -12
  71. package/packages/server/dist/services/cliModalDetect.js.map +1 -1
  72. package/packages/server/dist/services/cliScreenModel.d.ts +3 -2
  73. package/packages/server/dist/services/cliScreenModel.d.ts.map +1 -1
  74. package/packages/server/dist/services/cliScreenModel.js +2 -0
  75. package/packages/server/dist/services/cliScreenModel.js.map +1 -1
  76. package/packages/server/dist/services/cliService.d.ts.map +1 -1
  77. package/packages/server/dist/services/cliService.js +2 -7
  78. package/packages/server/dist/services/cliService.js.map +1 -1
  79. package/packages/server/dist/services/cliSessionPool.d.ts +13 -1
  80. package/packages/server/dist/services/cliSessionPool.d.ts.map +1 -1
  81. package/packages/server/dist/services/cliSessionPool.js +14 -4
  82. package/packages/server/dist/services/cliSessionPool.js.map +1 -1
  83. package/packages/server/dist/services/preferencesService.d.ts +1 -0
  84. package/packages/server/dist/services/preferencesService.d.ts.map +1 -1
  85. package/packages/server/dist/services/preferencesService.js +25 -2
  86. package/packages/server/dist/services/preferencesService.js.map +1 -1
  87. package/packages/server/dist/services/queueService.d.ts +1 -0
  88. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  89. package/packages/server/dist/services/queueService.js +3 -0
  90. package/packages/server/dist/services/queueService.js.map +1 -1
  91. package/packages/server/dist/services/rateLimitProbeService.d.ts +7 -0
  92. package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
  93. package/packages/server/dist/services/rateLimitProbeService.js +10 -0
  94. package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
  95. package/packages/server/dist/services/workspaceContext.d.ts +29 -3
  96. package/packages/server/dist/services/workspaceContext.d.ts.map +1 -1
  97. package/packages/server/dist/services/workspaceContext.js +100 -8
  98. package/packages/server/dist/services/workspaceContext.js.map +1 -1
  99. package/packages/server/dist/snippets/promote-to-story +86 -9
  100. package/packages/server/dist/utils/cliDebugLog.d.ts +6 -5
  101. package/packages/server/dist/utils/cliDebugLog.d.ts.map +1 -1
  102. package/packages/server/dist/utils/cliDebugLog.js +11 -8
  103. package/packages/server/dist/utils/cliDebugLog.js.map +1 -1
  104. package/packages/server/dist/utils/logger.d.ts +7 -0
  105. package/packages/server/dist/utils/logger.d.ts.map +1 -1
  106. package/packages/server/dist/utils/logger.js +13 -1
  107. package/packages/server/dist/utils/logger.js.map +1 -1
  108. package/packages/server/resources/manual/01-getting-started.md +11 -5
  109. package/packages/server/resources/manual/03-sessions.md +1 -0
  110. package/packages/server/resources/manual/04-slash-commands-favorites.md +1 -0
  111. package/packages/server/resources/manual/13-settings.md +34 -10
  112. package/packages/server/resources/manual/16-troubleshooting.md +4 -7
  113. package/packages/shared/dist/index.d.ts +2 -1
  114. package/packages/shared/dist/index.d.ts.map +1 -1
  115. package/packages/shared/dist/index.js +2 -0
  116. package/packages/shared/dist/index.js.map +1 -1
  117. package/packages/shared/dist/types/accounts.d.ts +104 -0
  118. package/packages/shared/dist/types/accounts.d.ts.map +1 -0
  119. package/packages/shared/dist/types/accounts.js +14 -0
  120. package/packages/shared/dist/types/accounts.js.map +1 -0
  121. package/packages/shared/dist/types/preferences.d.ts +39 -0
  122. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  123. package/packages/shared/dist/types/preferences.js.map +1 -1
  124. package/packages/shared/dist/types/sdk.d.ts +3 -1
  125. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  126. package/packages/shared/dist/types/sdk.js.map +1 -1
  127. package/packages/shared/dist/types/websocket.d.ts +23 -1
  128. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  129. package/packages/client/dist/assets/index-BsHMOF5r.css +0 -32
  130. package/packages/server/dist/snippets/promote-issue +0 -9
@@ -59,17 +59,18 @@ import fs from 'fs/promises';
59
59
  import { watch, mkdirSync, writeFileSync, createWriteStream } from 'fs';
60
60
  import { sessionService } from './sessionService.js';
61
61
  import { cliSessionPool } from './cliSessionPool.js';
62
+ import { preferencesService } from './preferencesService.js';
62
63
  import { createCliScreenModel, CLI_SCREEN_COLS, CLI_SCREEN_ROWS } from './cliScreenModel.js';
63
64
  import { setCliScreen } from './cliScreenCache.js';
64
65
  import { createTrailingThrottle } from '../utils/trailingThrottle.js';
65
66
  import { readSpinnerProgress } from './cliSpinnerProgress.js';
66
67
  import { detectPermissionDialog, detectUsageLimit, detectRateLimit, extractToolName, extractPromptSentence, detectQuestionModal, parseQuestionModal, parsePrecedingText, parsePrecedingPermissionText, countQuestionTabs, parseQuestionTabHeaders, parseQuestionTabBody, parseConfirmChoiceMenu, readPermissionMode, permissionModeCycleIndex, isIdleInputGrid, isGeneratingGrid, classifyPreInjectScreen, CLI_PERMISSION_MODE_CYCLE, } from './cliModalDetect.js';
67
68
  import { liveFooterText, scrollbackBodyRows } from './cliGridRegion.js';
68
- import { parseGridCards, collectToolHeaderKeys, restoreFlickeredToolBullets } from './cliGridCards.js';
69
+ import { parseGridCards, collectToolLineKeys, restoreFlickeredToolBullets } from './cliGridCards.js';
69
70
  import { rateLimitProbeService } from './rateLimitProbeService.js';
70
71
  import { parseJSONLFile, parseTaskNotification } from './historyParser.js';
71
72
  import { rewindSessionFiles } from './fileRewind.js';
72
- import { DEFAULT_WORKSPACE_TEMPLATE, resolveTemplateVariables } from './workspaceContext.js';
73
+ import { buildSystemPrompt, resolveTemplateVariables } from './workspaceContext.js';
73
74
  import { createLogger } from '../utils/logger.js';
74
75
  import { SDKError, SDKErrorCode } from '../utils/errors.js';
75
76
  import { CliDebugLog } from '../utils/cliDebugLog.js';
@@ -154,6 +155,13 @@ const CLI_MAX_BOOT_WAIT_MS = 4000; // boot readiness re-poll interval (inject as
154
155
  // "still painting"; injection is grid-gated so we keep polling. A genuine boot freeze is covered by the
155
156
  // soft screen-stall affordance (boot is now mirrored), pty.onExit (crash), and the user's Stop.
156
157
  const CLI_SUBMIT_GAP_MS = 1000; // Enter sent this long after the prompt text
158
+ // Prompt injection is PACED in small chunks, not one bulk write. A single large pty.write of a long
159
+ // prompt overruns the Windows ConPTY input path: only a fraction (~the last few hundred bytes) reaches
160
+ // claude's input box and the head/middle is silently dropped (실측 2026-06-22: a 2439-byte prompt arrived
161
+ // as 391 bytes; bracketed-paste framing did NOT help; paced chunks delivered 100%). Pacing lets the TUI
162
+ // drain between writes so the buffer never overruns. Tuned from the same measurement (50 cps / 8ms = 100%).
163
+ const CLI_INJECT_CHUNK_CHARS = 50; // code points per write (code-point sliced — never splits a char)
164
+ const CLI_INJECT_CHUNK_GAP_MS = 8; // pause between chunk writes so the TUI drains its input buffer
157
165
  /**
158
166
  * Tool-approval interception (Story 32.6 — *constrained*; keys verified against
159
167
  * claude v2.1.162 by a real-PTY observation, Task 1). The interactive permission
@@ -672,6 +680,16 @@ export class CliChatEngine {
672
680
  const projectSlug = sessionService.encodeProjectPath(cwd);
673
681
  const sessionsDir = sessionService.getSessionsDir(cwd);
674
682
  const resumeId = typeof options.resume === 'string' && options.resume.length > 0 ? options.resume : undefined;
683
+ // Story BS-6: resolve the CLI debug instrumentation flags once per spawn. Preference-first
684
+ // (set via the HAMMOC_DEBUG settings panel), falling back to the original env vars so
685
+ // existing HAMMOC_CLI_* usage keeps working when no preference is set. Tool trace rides
686
+ // along with PTY dump (the original env coupling preserved). These are referenced below at
687
+ // the four gated sites (debug-file, PTY dump, tool trace, and the pre-inject snapshot deep
688
+ // inside attemptInjectFromGrid).
689
+ const debugPrefs = await preferencesService.readPreferences();
690
+ const cliDebug = debugPrefs.debugCliTrace ?? !!process.env.HAMMOC_CLI_DEBUG;
691
+ const ptyDump = debugPrefs.debugPtyDump ?? !!process.env.HAMMOC_CLI_PTY_DUMP;
692
+ const toolTrace = debugPrefs.debugToolTrace ?? (!!process.env.HAMMOC_CLI_TOOL_TRACE || ptyDump);
675
693
  // Build interactive claude args. NEVER --print / --output-format stream-json:
676
694
  // headless output bills like the SDK, which is the whole reason CLI mode exists.
677
695
  const args = [];
@@ -717,8 +735,8 @@ export class CliChatEngine {
717
735
  // workspaceContext). Verified: node-pty delivers the multi-line arg intact to the PTY child, and
718
736
  // billing is unaffected (the pool is set by interactive-vs-print, not prompt content — this only
719
737
  // adds input tokens to the same subscription pool).
720
- const appendTemplate = options.customSystemPrompt || DEFAULT_WORKSPACE_TEMPLATE;
721
- args.push('--append-system-prompt', resolveTemplateVariables(appendTemplate, cwd, options.displayName));
738
+ const assembled = buildSystemPrompt('cli', options.isBmadProject ?? false, options.customSystemPrompt);
739
+ args.push('--append-system-prompt', resolveTemplateVariables(assembled, cwd, options.displayName));
722
740
  // Session-scoped `--settings` JSON (the global ~/.claude/settings.json is never
723
741
  // modified). Thinking summaries (default ON): Opus 4.7+ omit summaries unless asked.
724
742
  const settingsObj = {};
@@ -750,7 +768,7 @@ export class CliChatEngine {
750
768
  // captured per spawn, to diagnose why claude self-compacts on some long-idle resumes.
751
769
  // No-op unless the env flag is set; *.log is gitignored. Best-effort — instrumentation
752
770
  // must never break a turn.
753
- if (process.env.HAMMOC_CLI_DEBUG) {
771
+ if (cliDebug) {
754
772
  try {
755
773
  const dbgDir = path.join(process.cwd(), 'logs', 'claude-debug');
756
774
  mkdirSync(dbgDir, { recursive: true });
@@ -820,7 +838,7 @@ export class CliChatEngine {
820
838
  // raw bytes (ANSI intact) are appended to a gitignored logs/cli-pty-dump/*.log.
821
839
  // Best-effort — instrumentation must never break a turn.
822
840
  let ptyDumpStream = null;
823
- if (process.env.HAMMOC_CLI_PTY_DUMP) {
841
+ if (ptyDump) {
824
842
  try {
825
843
  const dumpDir = path.join(process.cwd(), 'logs', 'cli-pty-dump');
826
844
  mkdirSync(dumpDir, { recursive: true });
@@ -840,7 +858,7 @@ export class CliChatEngine {
840
858
  // Activate when EITHER its own flag OR the PTY-dump flag is set — so when only HAMMOC_CLI_PTY_DUMP can
841
859
  // be toggled (already on), the tool-completion trace rides along with no extra env. (Temporary debug
842
860
  // convenience; both are gitignored *.log and OFF in normal runs.)
843
- if (process.env.HAMMOC_CLI_TOOL_TRACE || process.env.HAMMOC_CLI_PTY_DUMP) {
861
+ if (toolTrace) {
844
862
  try {
845
863
  const traceDir = path.join(process.cwd(), 'logs', 'cli-tool-trace');
846
864
  mkdirSync(traceDir, { recursive: true });
@@ -861,7 +879,7 @@ export class CliChatEngine {
861
879
  }
862
880
  };
863
881
  // Unified CLI decision log — per-turn session file, opt-in via HAMMOC_CLI_DEBUG (no-op when unset).
864
- const dlog = new CliDebugLog(resumeId ?? options.sessionId ?? 'new');
882
+ const dlog = new CliDebugLog(resumeId ?? options.sessionId ?? 'new', cliDebug);
865
883
  this.currentDebugLog?.close();
866
884
  this.currentDebugLog = dlog;
867
885
  // Story 36.2: report the pre-generation phase so the UI shows progress through the
@@ -909,6 +927,7 @@ export class CliChatEngine {
909
927
  let bootRecoverTimer = null; // Story 37.6: post-Esc re-classify timer
910
928
  let choiceMenuHandled = false; // Story 37.6 follow-up: one-shot handoff of a drivable confirm menu (resume prompt)
911
929
  let submitTimer = null;
930
+ let injectChunkTimer = null; // paces the chunked prompt write
912
931
  // Permission-dialog state (Story 32.6 — post-injection only). Story 37.4: detection reads
913
932
  // the settled screen grid (no rolling buffer); `permissionPending` is the per-modal re-fire
914
933
  // guard that the old buffer-clear consume used to provide.
@@ -1074,6 +1093,10 @@ export class CliChatEngine {
1074
1093
  clearTimeout(submitTimer);
1075
1094
  submitTimer = null;
1076
1095
  }
1096
+ if (injectChunkTimer) {
1097
+ clearTimeout(injectChunkTimer);
1098
+ injectChunkTimer = null;
1099
+ }
1077
1100
  if (questionSettleTimer) {
1078
1101
  clearTimeout(questionSettleTimer);
1079
1102
  questionSettleTimer = null;
@@ -1224,19 +1247,43 @@ export class CliChatEngine {
1224
1247
  bootRecoverTimer = null;
1225
1248
  }
1226
1249
  try {
1227
- pty.write(promptToInject);
1228
- submitTimer = setTimeout(() => {
1229
- submitTimer = null;
1250
+ // Inject the prompt in small paced chunks (see CLI_INJECT_CHUNK_* notes) instead of one bulk
1251
+ // write, which the Windows ConPTY input path truncates to its tail. The submit CR is sent as a
1252
+ // SEPARATE write only AFTER the final chunk + the usual gap, preserving bracketed-paste safety
1253
+ // (a CR close to typed text coalesces into a newline; the gap keeps it a real submit).
1254
+ const codePoints = Array.from(promptToInject);
1255
+ let chunkStart = 0;
1256
+ const writeNextChunk = () => {
1257
+ injectChunkTimer = null;
1230
1258
  if (settled)
1231
1259
  return;
1260
+ if (chunkStart >= codePoints.length) {
1261
+ // All chunks delivered — now schedule the submit Enter after the bracketed-paste-safe gap.
1262
+ submitTimer = setTimeout(() => {
1263
+ submitTimer = null;
1264
+ if (settled)
1265
+ return;
1266
+ try {
1267
+ pty.write('\r');
1268
+ onPhase?.('waiting'); // prompt submitted — awaiting the first response block
1269
+ }
1270
+ catch (err) {
1271
+ fail(err instanceof Error ? err : new Error(String(err)));
1272
+ }
1273
+ }, CLI_SUBMIT_GAP_MS);
1274
+ return;
1275
+ }
1232
1276
  try {
1233
- pty.write('\r');
1234
- onPhase?.('waiting'); // prompt submitted — awaiting the first response block
1277
+ pty.write(codePoints.slice(chunkStart, chunkStart + CLI_INJECT_CHUNK_CHARS).join(''));
1235
1278
  }
1236
1279
  catch (err) {
1237
1280
  fail(err instanceof Error ? err : new Error(String(err)));
1281
+ return;
1238
1282
  }
1239
- }, CLI_SUBMIT_GAP_MS);
1283
+ chunkStart += CLI_INJECT_CHUNK_CHARS;
1284
+ injectChunkTimer = setTimeout(writeNextChunk, CLI_INJECT_CHUNK_GAP_MS);
1285
+ };
1286
+ writeNextChunk();
1240
1287
  }
1241
1288
  catch (err) {
1242
1289
  fail(err instanceof Error ? err : new Error(String(err)));
@@ -1331,7 +1378,7 @@ export class CliChatEngine {
1331
1378
  return;
1332
1379
  const grid = screen.readGrid();
1333
1380
  const classification = classifyPreInjectScreen(grid);
1334
- if (process.env.HAMMOC_CLI_DEBUG)
1381
+ if (cliDebug)
1335
1382
  snapshotPreInject(grid, classification);
1336
1383
  if (classification === 'input-box') {
1337
1384
  void alignModeThenInject(); // AC1 — verified input box (align mode → inject)
@@ -1562,7 +1609,7 @@ export class CliChatEngine {
1562
1609
  // mid-repaint) BEFORE parsing, so it opens as its own tool card instead of fusing into the prose
1563
1610
  // above. Content-gated on the cross-frame memory, so prose is never promoted. Row count is
1564
1611
  // unchanged (glyph prepended), so `bodyColors` stays index-aligned.
1565
- cards = parseGridCards(restoreFlickeredToolBullets(bodyRows, recentToolHeaderKeys), bodyColors);
1612
+ cards = parseGridCards(restoreFlickeredToolBullets(bodyRows, recentToolHeaderKeys, bodyColors), bodyColors);
1566
1613
  // Stop once a known block is in view: it is the bounded boundary — everything above it is known.
1567
1614
  if (knownSigs.size === 0 || cards.some((c) => knownSigs.has(cardSig(c))))
1568
1615
  break;
@@ -1579,17 +1626,18 @@ export class CliChatEngine {
1579
1626
  cards: cards.map((c) => ({ kind: c.kind, tool: c.toolName, color: c.bulletColor, textLen: c.text.length, preview: c.text.slice(0, 50) })),
1580
1627
  });
1581
1628
  }
1582
- // Story 37.12: refresh the cross-frame tool-header memory from the FINAL (deepest) frame read.
1629
+ // Story 37.12: refresh the cross-frame tool-line memory from the FINAL (deepest) frame read.
1583
1630
  // First drop keys whose line scrolled off — retention is scoped to on-screen text INCLUDING
1584
1631
  // bullet-less rows, so a tool whose glyph is currently flickering stays remembered while a tool that
1585
1632
  // truly scrolled away is forgotten (can't re-promote unrelated prose later). Then add this frame's
1586
- // glyph-carrying tool headers — the confident observations worth remembering.
1587
- const onScreenToolKeys = collectToolHeaderKeys(bodyRows, true);
1633
+ // TOOL-COLORED bullet lines (green/red/gray) — the confident observations worth remembering; a white
1634
+ // text-body bullet is excluded by the color gate, so prose is never remembered as a flicker tool.
1635
+ const onScreenToolKeys = collectToolLineKeys(bodyRows, { includeBulletless: true });
1588
1636
  for (const key of [...recentToolHeaderKeys]) {
1589
1637
  if (!onScreenToolKeys.has(key))
1590
1638
  recentToolHeaderKeys.delete(key);
1591
1639
  }
1592
- for (const key of collectToolHeaderKeys(bodyRows, false))
1640
+ for (const key of collectToolLineKeys(bodyRows, { bulletColors: bodyColors }))
1593
1641
  recentToolHeaderKeys.add(key);
1594
1642
  // CONTENT-SET dedup (replaces the position/suffix align that re-emitted the whole sequence the
1595
1643
  // moment a card was inserted mid-list or mutated). Walk the visible cards; for each STABLE card:
@@ -1609,30 +1657,34 @@ export class CliChatEngine {
1609
1657
  // output; if it scrolled off we still mark complete (the turn-end reload fills the canonical output).
1610
1658
  // Fixes "tool spins forever though the mirror shows a green/done bullet" — the completion row scrolls
1611
1659
  // out of the viewport but the color is enough.
1612
- const flipDoneIfGreen = (slot, bulletColor, cardIdx) => {
1613
- if (bulletColor !== 'green' || slot < 0 || gridResultFlippedSlots.has(slot))
1660
+ const flipToolSettled = (slot, bulletColor, cardIdx) => {
1661
+ // A tool's bullet SETTLES to green (success) or red (failure) when it finishes — both are terminal
1662
+ // and flip the provisional card running→complete in place. Gray/other = still running → leave pending
1663
+ // (the turn-end reload supplies the canonical result). node-pty실측: red = 255,107,128, steady.
1664
+ if ((bulletColor !== 'green' && bulletColor !== 'red') || slot < 0 || gridResultFlippedSlots.has(slot))
1614
1665
  return;
1615
1666
  const sid = provToolSlotIds[slot];
1616
1667
  if (!sid)
1617
1668
  return;
1618
1669
  const next = cards[cardIdx + 1];
1619
1670
  const output = next && next.kind === 'result' && next.text.trim() ? next.text : '';
1671
+ const success = bulletColor === 'green';
1620
1672
  gridResultFlippedSlots.add(slot);
1621
- trace(`flip-green slot=${slot} sid=${sid} (screen green → done)`);
1622
- dlog.server('grid-tool-green-flip', { slot, sid, hasOutput: !!output });
1623
- callbacks.onToolResult?.(sid, { success: true, output }, true);
1673
+ trace(`flip-settled slot=${slot} sid=${sid} color=${bulletColor} (screen ${bulletColor}${success ? 'done' : 'failed'})`);
1674
+ dlog.server('grid-tool-settle-flip', { slot, sid, color: bulletColor, success, hasOutput: !!output });
1675
+ callbacks.onToolResult?.(sid, { success, output }, true);
1624
1676
  };
1625
1677
  for (let cardIdx = 0; cardIdx < cards.length; cardIdx++) {
1626
1678
  const card = cards[cardIdx];
1627
1679
  if (card.kind === 'result') {
1628
- // Flip the preceding tool running→complete ONLY when its `●` bullet is GREEN (done). claude
1629
- // paints `⎿ Waiting…`/`⎿ Running…` under a still-running gray-bullet tool; those are NOT the
1630
- // result (flipping shows a premature "complete: Waiting…"). Gray/unreadable stays pending →
1631
- // the turn-end reload supplies the canonical result. Only a grid-provisioned slot.
1680
+ // Flip the preceding tool running→complete when its `●` bullet has SETTLED green (success) or red
1681
+ // (failure). claude paints `⎿ Waiting…`/`⎿ Running…` under a still-running gray-bullet tool; those
1682
+ // are NOT the result (flipping shows a premature "complete: Waiting…"). Gray/unreadable stays
1683
+ // pending → the turn-end reload supplies the canonical result. Only a grid-provisioned slot.
1632
1684
  const sid = lastToolSlot >= 0 ? provToolSlotIds[lastToolSlot] : undefined;
1633
- if (sid && !gridResultFlippedSlots.has(lastToolSlot) && lastToolColor === 'green' && card.text.trim()) {
1685
+ if (sid && !gridResultFlippedSlots.has(lastToolSlot) && (lastToolColor === 'green' || lastToolColor === 'red') && card.text.trim()) {
1634
1686
  gridResultFlippedSlots.add(lastToolSlot);
1635
- callbacks.onToolResult?.(sid, { success: true, output: card.text }, true);
1687
+ callbacks.onToolResult?.(sid, { success: lastToolColor === 'green', output: card.text }, true);
1636
1688
  }
1637
1689
  continue;
1638
1690
  }
@@ -1715,7 +1767,7 @@ export class CliChatEngine {
1715
1767
  lastToolColor = card.bulletColor;
1716
1768
  if (card.bulletColor === 'green')
1717
1769
  h.seenGreen = true;
1718
- flipDoneIfGreen(h.slot, card.bulletColor, cardIdx);
1770
+ flipToolSettled(h.slot, card.bulletColor, cardIdx);
1719
1771
  }
1720
1772
  continue;
1721
1773
  }
@@ -1763,7 +1815,7 @@ export class CliChatEngine {
1763
1815
  });
1764
1816
  lastToolSlot = slot;
1765
1817
  lastToolColor = card.bulletColor;
1766
- flipDoneIfGreen(slot, card.bulletColor, cardIdx); // a tool already green on first sight = done
1818
+ flipToolSettled(slot, card.bulletColor, cardIdx); // a tool already green/red on first sight = settled
1767
1819
  }
1768
1820
  else if (card.kind === 'text') {
1769
1821
  const slot = liveTextSlots++;