@namzu/cli 17.0.1 → 18.1.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 (79) hide show
  1. package/README.md +22 -12
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +1 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts.map +1 -1
  6. package/dist/commands/acp.js +1 -0
  7. package/dist/commands/acp.js.map +1 -1
  8. package/dist/commands/run-stream.d.ts.map +1 -1
  9. package/dist/commands/run-stream.js +1 -0
  10. package/dist/commands/run-stream.js.map +1 -1
  11. package/dist/commands/run.d.ts.map +1 -1
  12. package/dist/commands/run.js +1 -0
  13. package/dist/commands/run.js.map +1 -1
  14. package/dist/config/load.d.ts.map +1 -1
  15. package/dist/config/load.js +15 -0
  16. package/dist/config/load.js.map +1 -1
  17. package/dist/config/schema.d.ts +17 -0
  18. package/dist/config/schema.d.ts.map +1 -1
  19. package/dist/config/schema.js.map +1 -1
  20. package/dist/context/doctrine.d.ts +44 -0
  21. package/dist/context/doctrine.d.ts.map +1 -0
  22. package/dist/context/doctrine.js +81 -0
  23. package/dist/context/doctrine.js.map +1 -0
  24. package/dist/context/turn-snapshot.d.ts +59 -0
  25. package/dist/context/turn-snapshot.d.ts.map +1 -0
  26. package/dist/context/turn-snapshot.js +122 -0
  27. package/dist/context/turn-snapshot.js.map +1 -0
  28. package/dist/integrations/subagents/activity.d.ts +8 -0
  29. package/dist/integrations/subagents/activity.d.ts.map +1 -1
  30. package/dist/integrations/subagents/activity.js +38 -2
  31. package/dist/integrations/subagents/activity.js.map +1 -1
  32. package/dist/integrations/subagents/policy.d.ts +6 -0
  33. package/dist/integrations/subagents/policy.d.ts.map +1 -0
  34. package/dist/integrations/subagents/policy.js +6 -0
  35. package/dist/integrations/subagents/policy.js.map +1 -0
  36. package/dist/integrations/subagents/runtime.d.ts +13 -0
  37. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  38. package/dist/integrations/subagents/runtime.js +77 -13
  39. package/dist/integrations/subagents/runtime.js.map +1 -1
  40. package/dist/permissions/mode.d.ts +25 -0
  41. package/dist/permissions/mode.d.ts.map +1 -1
  42. package/dist/permissions/mode.js +11 -1
  43. package/dist/permissions/mode.js.map +1 -1
  44. package/dist/tui/AgentExplorer.d.ts +21 -0
  45. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  46. package/dist/tui/AgentExplorer.js +52 -1
  47. package/dist/tui/AgentExplorer.js.map +1 -1
  48. package/dist/tui/App.d.ts.map +1 -1
  49. package/dist/tui/App.js +298 -26
  50. package/dist/tui/App.js.map +1 -1
  51. package/dist/tui/Composer.d.ts +8 -1
  52. package/dist/tui/Composer.d.ts.map +1 -1
  53. package/dist/tui/Composer.js +23 -3
  54. package/dist/tui/Composer.js.map +1 -1
  55. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  56. package/dist/tui/PermissionOverlay.js +16 -1
  57. package/dist/tui/PermissionOverlay.js.map +1 -1
  58. package/dist/tui/TaskList.d.ts +30 -0
  59. package/dist/tui/TaskList.d.ts.map +1 -0
  60. package/dist/tui/TaskList.js +58 -0
  61. package/dist/tui/TaskList.js.map +1 -0
  62. package/dist/tui/agent.d.ts +49 -4
  63. package/dist/tui/agent.d.ts.map +1 -1
  64. package/dist/tui/agent.js +171 -20
  65. package/dist/tui/agent.js.map +1 -1
  66. package/dist/tui/permission-review.d.ts.map +1 -1
  67. package/dist/tui/permission-review.js +151 -1
  68. package/dist/tui/permission-review.js.map +1 -1
  69. package/dist/tui/slashCommands.d.ts +2 -0
  70. package/dist/tui/slashCommands.d.ts.map +1 -1
  71. package/dist/tui/slashCommands.js +6 -3
  72. package/dist/tui/slashCommands.js.map +1 -1
  73. package/dist/tui/stream-blocks.d.ts +24 -0
  74. package/dist/tui/stream-blocks.d.ts.map +1 -1
  75. package/dist/tui/stream-blocks.js +74 -0
  76. package/dist/tui/stream-blocks.js.map +1 -1
  77. package/dist/tui/types.d.ts +3 -1
  78. package/dist/tui/types.d.ts.map +1 -1
  79. package/package.json +5 -5
package/dist/tui/App.js CHANGED
@@ -28,12 +28,13 @@ import { checkUpdates } from '../integrations/updates.js';
28
28
  import { appendMemory, composeMemoryPrompt, readMemory } from '../memory/store.js';
29
29
  import { composeSkillsPrompt, discoverSkills, loadSkillBody } from '../skills/store.js';
30
30
  import { discoverUserCommands } from '../user-commands/store.js';
31
- import { AgentCockpit, AgentTranscript, agentCockpitIsWide, agentPickerPageSize, agentPhasePageSize, agentPhases, agentTranscriptPageSize, maxAgentTranscriptTailOffset, } from './AgentExplorer.js';
31
+ import { AgentCockpit, AgentTaskPanel, AgentTranscript, activeSubagentCohorts, agentCockpitIsWide, agentPhasePageSize, agentPhases, agentPickerPageSize, agentTranscriptPageSize, maxAgentTranscriptTailOffset, } from './AgentExplorer.js';
32
32
  import { ChoicePicker } from './ChoicePicker.js';
33
33
  import { Composer, suggestionWindowSize, } from './Composer.js';
34
34
  import { CopyPicker } from './CopyPicker.js';
35
35
  import { EditPromptPicker } from './EditPromptPicker.js';
36
36
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
37
+ import { TaskList } from './TaskList.js';
37
38
  import { PermissionOverlay } from './PermissionOverlay.js';
38
39
  import { Picker } from './Picker.js';
39
40
  import { ResumePicker } from './ResumePicker.js';
@@ -57,12 +58,36 @@ import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, buildPermissionSumm
57
58
  import { describeRunInterruption } from './run-interruption.js';
58
59
  import { moveSelection } from './selection-window.js';
59
60
  import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
60
- import { splitCompleteBlocks } from './stream-blocks.js';
61
+ import { splitCompleteBlocks, splitSafeCut } from './stream-blocks.js';
61
62
  import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
62
63
  import { theme } from './theme.js';
63
64
  import { useSelectionIndex } from './use-selection-index.js';
64
65
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
65
66
  import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
67
+ /** The value a question row carries: an option id, or the free-text escape. */
68
+ const FREE_TEXT_ANSWER = '__free_text__';
69
+ /**
70
+ * The streaming assistant bubble, carried across events within one turn.
71
+ *
72
+ * `text` is everything the model has said and is what gets persisted;
73
+ * `pending` is the part not yet on screen, held until it forms a whole block.
74
+ * The two are separate because they answer different questions — what was
75
+ * said, and what has been shown — and conflating them is how a reply gets
76
+ * saved with a paragraph the operator never saw, or shown twice.
77
+ */
78
+ /**
79
+ * How long held reply text may wait before a sentence of it is shown.
80
+ *
81
+ * Blocks are released as they complete (`splitCompleteBlocks`), which keeps a
82
+ * fast reply landing a paragraph at a time. A slow one — a long paragraph
83
+ * arriving over twenty seconds — completes no block until its last character
84
+ * and was invisible for its whole length. Past this interval the pending
85
+ * text is released to its last safe cut instead (`splitSafeCut`): a sentence
86
+ * or a line, never a half-word, never inside a fence. Long enough that a
87
+ * fast stream never reaches it; short enough that a slow one reads as
88
+ * writing rather than as silence.
89
+ */
90
+ const STREAM_RELEASE_MS = 250;
66
91
  /** Last non-empty assistant text in a durable conversation, newest first. */
67
92
  function latestAssistantOutput(messages) {
68
93
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -292,6 +317,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
292
317
  */
293
318
  const [pickerNotice, setPickerNotice] = useState(null);
294
319
  const [permission, setPermission] = useState(null);
320
+ /**
321
+ * The question currently on screen and the promise the tool is waiting
322
+ * on. Resolved exactly once: by a chosen row, by free text, by Esc (skip)
323
+ * or by Ctrl+C (abort). A second resolution is a no-op, not a second
324
+ * answer.
325
+ */
326
+ const questionRef = useRef(null);
327
+ const resolveQuestion = useCallback((answer) => {
328
+ const pending = questionRef.current;
329
+ if (!pending?.resolve)
330
+ return;
331
+ const resolve = pending.resolve;
332
+ pending.resolve = null;
333
+ questionRef.current = null;
334
+ resolve(answer);
335
+ }, []);
295
336
  const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
296
337
  const permissionReviewOffsetRef = useRef(0);
297
338
  const [permissionDetailsOpen, setPermissionDetailsOpenState] = useState(false);
@@ -329,6 +370,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
329
370
  // Tools currently executing — rendered live (spinner + elapsed) below the
330
371
  // transcript, then committed as static lines on completion.
331
372
  const [activeTools, setActiveTools] = useState([]);
373
+ /**
374
+ * The model's plan for the current request, in creation order, updated in
375
+ * place on every status change. Cleared when the next request begins —
376
+ * a plan is scoped to the request it was made for, and one that outlived
377
+ * it would read as work still pending on a turn that already ended.
378
+ */
379
+ const [tasks, setTasks] = useState([]);
380
+ /** Ids already seen this request, so an opening row is written once. */
381
+ const knownTaskIdsRef = useRef(new Set());
332
382
  // Bumped to reset the <Static> transcript log (on /clear, /clear-screen and /resume).
333
383
  const [resetKey, setResetKey] = useState(0);
334
384
  /**
@@ -462,9 +512,16 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
462
512
  subagentsRef.current = next;
463
513
  setSubagentsState(next);
464
514
  const surface = agentSurfaceRef.current;
465
- if (!surface || next.some((agent) => agent.viewId === surface.selectedId))
515
+ if (!surface)
516
+ return;
517
+ const activeCohorts = activeSubagentCohorts(next);
518
+ if (activeCohorts.length === 0) {
519
+ setAgentSurface(null);
520
+ return;
521
+ }
522
+ if (activeCohorts.some((agent) => agent.viewId === surface.selectedId))
466
523
  return;
467
- const phases = agentPhases(next);
524
+ const phases = agentPhases(activeCohorts);
468
525
  const fallbackPhase = phases.find((phase) => phase.id === surface.selectedPhaseId) ?? phases[0];
469
526
  const fallbackAgent = fallbackPhase?.agents[0];
470
527
  if (!fallbackPhase || !fallbackAgent) {
@@ -479,7 +536,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
479
536
  });
480
537
  }, [setAgentSurface]);
481
538
  const openAgentCockpit = useCallback(() => {
482
- const agents = subagentsRef.current;
539
+ const agents = activeSubagentCohorts(subagentsRef.current);
483
540
  const firstPhase = agentPhases(agents)[0];
484
541
  const firstAgent = firstPhase?.agents[0];
485
542
  if (!firstPhase || !firstAgent)
@@ -1020,6 +1077,20 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1020
1077
  }
1021
1078
  return { sessions, sessionId };
1022
1079
  }, [hasUnsettledTurn, pushMessage, state]);
1080
+ /**
1081
+ * Shift+Tab: `prompt` → `accept-edits` → `plan` → `prompt`. Three stops
1082
+ * only — `auto` and `strict` are deliberate choices made by name in
1083
+ * `/permissions`, not stops on a key an operator presses on reflex. From
1084
+ * either of those the key returns to `prompt`, which is the direction a
1085
+ * reflex should fall. The change goes through the same gate `/permissions`
1086
+ * uses, so it is refused while a turn is active, and the refusal is
1087
+ * explained on screen.
1088
+ */
1089
+ const cyclePermissionMode = useCallback(() => {
1090
+ const current = permissionModeRef.current;
1091
+ const next = current === 'prompt' ? 'accept-edits' : current === 'accept-edits' ? 'plan' : 'prompt';
1092
+ applyPermissionMode(next);
1093
+ }, [applyPermissionMode]);
1023
1094
  const runConversationExport = useCallback((destination) => {
1024
1095
  const source = stableExportSource();
1025
1096
  if (!source)
@@ -1202,6 +1273,39 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1202
1273
  })();
1203
1274
  return;
1204
1275
  }
1276
+ if (picker.kind === 'user-question') {
1277
+ const pending = questionRef.current;
1278
+ setChoicePicker(null);
1279
+ if (!pending)
1280
+ return;
1281
+ if (value === FREE_TEXT_ANSWER) {
1282
+ // The scope on a text prompt is what its other kinds check
1283
+ // against a conversation switch; a question belongs to the turn
1284
+ // that asked it, and the turn holds the promise.
1285
+ const scope = scopeRef.current;
1286
+ if (!scope) {
1287
+ resolveQuestion({ kind: 'skip' });
1288
+ return;
1289
+ }
1290
+ textPromptTokenRef.current += 1;
1291
+ setTextPrompt({
1292
+ token: textPromptTokenRef.current,
1293
+ kind: 'user-question',
1294
+ title: pending.question.question,
1295
+ placeholder: 'Type your answer and press Enter · Esc skips',
1296
+ emptyNotice: 'An empty answer is a skip. Press Esc to skip.',
1297
+ initialValue: '',
1298
+ sessionId: scope.sessionId,
1299
+ });
1300
+ return;
1301
+ }
1302
+ // The tool's own result row carries the answer the model saw; this
1303
+ // row is the operator's, so a reader of the transcript sees the
1304
+ // question and the choice together where it happened.
1305
+ pushMessage('system', `Answered "${pending.question.question}": ${labelOfOption(pending.question, value)}`, false, '?');
1306
+ resolveQuestion({ kind: 'answer', selectedOptionIds: [value] });
1307
+ return;
1308
+ }
1205
1309
  if (picker.kind === 'review-commit') {
1206
1310
  advanceQueueContinuation();
1207
1311
  enqueueQueued({
@@ -1341,12 +1445,35 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1341
1445
  * if every exit releases it; this is the function every exit calls.
1342
1446
  */
1343
1447
  const flushStream = useCallback((st) => {
1448
+ if (st.releaseTimer) {
1449
+ clearTimeout(st.releaseTimer);
1450
+ st.releaseTimer = undefined;
1451
+ }
1344
1452
  if (!st.pending || st.pending.length === 0)
1345
1453
  return;
1346
1454
  const id = st.assistantId ?? pushMessage('assistant', '', true);
1347
1455
  st.assistantId = id;
1348
1456
  appendToMessage(id, st.pending);
1349
1457
  st.pending = '';
1458
+ st.releasedAt = Date.now();
1459
+ }, [appendToMessage, pushMessage]);
1460
+ /**
1461
+ * Release held text to its last safe cut — a sentence or a line — when it
1462
+ * has waited longer than `STREAM_RELEASE_MS`. Called from the delta path
1463
+ * with the clock, and from the timer the delta path arms so a stream that
1464
+ * goes quiet mid-paragraph still shows what it has.
1465
+ */
1466
+ const releaseStalled = useCallback((st) => {
1467
+ if (!st.pending || st.pending.length === 0)
1468
+ return;
1469
+ const { ready, rest } = splitSafeCut(st.pending);
1470
+ if (ready.length === 0)
1471
+ return;
1472
+ const id = st.assistantId ?? pushMessage('assistant', '', true);
1473
+ st.assistantId = id;
1474
+ st.pending = rest;
1475
+ appendToMessage(id, ready);
1476
+ st.releasedAt = Date.now();
1350
1477
  }, [appendToMessage, pushMessage]);
1351
1478
  const finalizeMessage = useCallback((id, finalContent) => {
1352
1479
  setMessages((prev) => prev.map((m) => m.id === id ? { ...m, content: finalContent ?? m.content, pending: false } : m));
@@ -1445,7 +1572,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1445
1572
  ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
1446
1573
  ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
1447
1574
  ...(activeCtx.plugins ? { plugins: activeCtx.plugins } : {}),
1575
+ ...(activeCtx.web ? { web: activeCtx.web } : {}),
1448
1576
  ...(activeCtx.sandbox ? { sandbox: activeCtx.sandbox } : {}),
1577
+ // Somebody is at this terminal, so the model may ask them one
1578
+ // question when a decision is genuinely theirs.
1579
+ askUser: true,
1449
1580
  });
1450
1581
  if (signal?.aborted) {
1451
1582
  void s.close();
@@ -2236,6 +2367,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2236
2367
  if (!prompt)
2237
2368
  return;
2238
2369
  setTextPrompt(null);
2370
+ if (prompt.kind === 'user-question') {
2371
+ const text = value.trim();
2372
+ const pending = questionRef.current;
2373
+ if (text.length === 0) {
2374
+ resolveQuestion({ kind: 'skip' });
2375
+ return;
2376
+ }
2377
+ if (pending) {
2378
+ pushMessage('system', `Answered "${pending.question.question}": ${text}`, false, '?');
2379
+ }
2380
+ resolveQuestion({ kind: 'answer', selectedOptionIds: [], freeText: text });
2381
+ return;
2382
+ }
2239
2383
  const scope = scopeRef.current;
2240
2384
  if (!scope || scope.sessionId !== prompt.sessionId) {
2241
2385
  pushMessage('system', prompt.kind === 'conversation-title'
@@ -2268,8 +2412,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2268
2412
  queueMicrotask(() => {
2269
2413
  if (textPromptRef.current === prompt)
2270
2414
  setTextPrompt(null);
2415
+ // A cancelled free-text answer is a skipped question, not a hung tool.
2416
+ if (prompt?.kind === 'user-question')
2417
+ resolveQuestion({ kind: 'skip' });
2271
2418
  });
2272
- }, [setTextPrompt]);
2419
+ }, [resolveQuestion, setTextPrompt]);
2273
2420
  /**
2274
2421
  * `/fork`: continue in a copy, leaving this conversation where it is.
2275
2422
  *
@@ -2454,6 +2601,39 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2454
2601
  sendTerminalNotification({ kind: 'approval-required' });
2455
2602
  });
2456
2603
  }, [sendTerminalNotification, setPermissionDetailsOpen, setPermissionReviewOffset]);
2604
+ /**
2605
+ * `ask_user_question`, on screen. The question becomes a finite chooser —
2606
+ * the model's options, then "Something else…" when it allowed free text —
2607
+ * and the tool's promise waits on the row the operator picks. One question
2608
+ * at a time: the tool is not concurrency-safe, so a second cannot arrive
2609
+ * while the first is up.
2610
+ */
2611
+ const askQuestion = useCallback((question) => new Promise((resolve) => {
2612
+ questionRef.current = { question, resolve };
2613
+ const values = [
2614
+ ...question.options.map((option) => option.id),
2615
+ ...(question.allowFreeText ? [FREE_TEXT_ANSWER] : []),
2616
+ ];
2617
+ setSelectedChoice(0);
2618
+ setChoicePicker({
2619
+ kind: 'user-question',
2620
+ title: question.question,
2621
+ notice: question.header
2622
+ ? `${question.header} · Enter answers · Esc skips`
2623
+ : 'Enter answers · Esc skips',
2624
+ values,
2625
+ options: [
2626
+ ...question.options.map((option) => ({
2627
+ label: option.label,
2628
+ description: option.description ?? '',
2629
+ })),
2630
+ ...(question.allowFreeText
2631
+ ? [{ label: 'Something else…', description: 'Answer in your own words' }]
2632
+ : []),
2633
+ ],
2634
+ });
2635
+ sendTerminalNotification({ kind: 'approval-required' });
2636
+ }), [sendTerminalNotification, setChoicePicker, setSelectedChoice]);
2457
2637
  // Render one agent event onto the transcript. Shared by the local turn loop
2458
2638
  // and the daemon-attach poller, so both paths produce identical output.
2459
2639
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -2484,6 +2664,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2484
2664
  sessionId: st.sessionId,
2485
2665
  };
2486
2666
  }
2667
+ // The release clock starts with the first character, not with the
2668
+ // turn: the wait before the first token is the model's, and
2669
+ // counting it would release the opening sentence on arrival.
2670
+ st.releasedAt ??= Date.now();
2487
2671
  st.text += event.text;
2488
2672
  // Held, not appended. Appending each delta is what produced text
2489
2673
  // that types itself out — nothing animates it, but a few
@@ -2494,7 +2678,21 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2494
2678
  if (ready.length > 0) {
2495
2679
  st.pending = rest;
2496
2680
  appendToMessage(ensureAssistant(), ready);
2681
+ st.releasedAt = Date.now();
2682
+ }
2683
+ // A block is the preferred unit; a sentence is the fallback for
2684
+ // a paragraph that is taking longer than an operator will wait
2685
+ // for it. The timer covers the stream going quiet between two
2686
+ // deltas, which the clock check alone cannot see.
2687
+ if (st.releaseTimer)
2688
+ clearTimeout(st.releaseTimer);
2689
+ if (Date.now() - (st.releasedAt ?? 0) >= STREAM_RELEASE_MS) {
2690
+ releaseStalled(st);
2497
2691
  }
2692
+ st.releaseTimer = setTimeout(() => {
2693
+ st.releaseTimer = undefined;
2694
+ releaseStalled(st);
2695
+ }, STREAM_RELEASE_MS);
2498
2696
  break;
2499
2697
  }
2500
2698
  case 'tool-start': {
@@ -2502,6 +2700,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2502
2700
  setState('tool');
2503
2701
  const tool = {
2504
2702
  id: event.toolUseId,
2703
+ ...(event.runId ? { runId: event.runId } : {}),
2505
2704
  toolName: event.toolName,
2506
2705
  label: formatToolCall(event.toolName, event.summary, event.standalone),
2507
2706
  startedAt: Date.now(),
@@ -2513,7 +2712,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2513
2712
  }
2514
2713
  case 'tool-progress': {
2515
2714
  const running = activeToolsRef.current;
2516
- const index = running.findIndex((tool) => tool.id === event.toolUseId);
2715
+ const index = running.findIndex((tool) => tool.id === event.toolUseId &&
2716
+ (event.runId === undefined || tool.runId === event.runId));
2517
2717
  // A terminal event may already have removed this call. Never recreate
2518
2718
  // a live row from late diagnostic state.
2519
2719
  if (index < 0)
@@ -2539,12 +2739,25 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2539
2739
  // active tool" — under parallel calls that mis-attributes a
2540
2740
  // result to the wrong call. If no id matches, render the
2541
2741
  // completion on its own (label from the end event itself).
2542
- const i = running.findIndex((t) => t.id === event.toolUseId);
2742
+ const i = running.findIndex((tool) => tool.id === event.toolUseId &&
2743
+ (event.runId === undefined || tool.runId === event.runId));
2543
2744
  const done = i >= 0 ? running[i] : undefined;
2544
2745
  if (i >= 0) {
2545
2746
  activeToolsRef.current = [...running.slice(0, i), ...running.slice(i + 1)];
2546
2747
  setActiveTools(activeToolsRef.current);
2547
2748
  }
2749
+ const representedSuccessfulAgent = !event.isError &&
2750
+ event.toolName.toLowerCase() === 'agent' &&
2751
+ subagentsRef.current.some((agent) => agent.toolUseId === event.toolUseId &&
2752
+ (event.runId === undefined || agent.workflowId === event.runId));
2753
+ if (representedSuccessfulAgent) {
2754
+ // The automatic panel already owned this successful child from
2755
+ // start through settlement. Re-emitting its internal Agent call as
2756
+ // a transcript tool row after the panel closes is duplicate protocol,
2757
+ // not useful history. Failures remain durable transcript rows.
2758
+ setState(activeToolsRef.current.length > 0 ? 'tool' : 'thinking');
2759
+ break;
2760
+ }
2548
2761
  pushMessage('tool', done?.label ?? formatToolCall(event.toolName, event.summary), false, event.isError ? '✗' : '✓', done?.detail, event.isError ? theme.status.error : theme.status.ok, event.durationMs !== undefined
2549
2762
  ? formatElapsed(event.durationMs)
2550
2763
  : done
@@ -2560,9 +2773,34 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2560
2773
  case 'usage':
2561
2774
  setUsage({ totalTokens: event.totalTokens, cost: event.cost });
2562
2775
  break;
2563
- case 'task':
2564
- pushMessage('tool', event.subject, false, event.status === 'completed' ? '☑' : '☐');
2776
+ case 'task': {
2777
+ // The live list gets every change; the transcript records the
2778
+ // opening and the close, as it did before the list existed.
2779
+ // Decided from a ref, not inside the state updater: React runs
2780
+ // updaters lazily, so a flag set there is still unset when the
2781
+ // transcript row below is chosen.
2782
+ const isNew = !knownTaskIdsRef.current.has(event.taskId);
2783
+ knownTaskIdsRef.current.add(event.taskId);
2784
+ const item = {
2785
+ id: event.taskId,
2786
+ subject: event.subject,
2787
+ status: event.status,
2788
+ };
2789
+ setTasks((prev) => {
2790
+ const index = prev.findIndex((task) => task.id === item.id);
2791
+ return index < 0 ? [...prev, item] : prev.map((task, i) => (i === index ? item : task));
2792
+ });
2793
+ if (event.status === 'completed') {
2794
+ pushMessage('tool', event.subject, false, '☑');
2795
+ }
2796
+ else if (event.status === 'failed') {
2797
+ pushMessage('tool', event.subject, false, '☒');
2798
+ }
2799
+ else if (isNew) {
2800
+ pushMessage('tool', event.subject, false, '☐');
2801
+ }
2565
2802
  break;
2803
+ }
2566
2804
  case 'context':
2567
2805
  // Into the TRANSCRIPT, not a status line. A status indicator is
2568
2806
  // present while nothing is happening and gone afterwards, so
@@ -2699,6 +2937,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2699
2937
  else {
2700
2938
  pushMessage('user', text, false, undefined, undefined, undefined, humanPromptMeta(attached.length, attachments));
2701
2939
  }
2940
+ // A plan is scoped to the request it was made for. Cleared here, at
2941
+ // the start of the next one, rather than when the previous turn
2942
+ // ended: a finished list stays on screen until the operator moves on,
2943
+ // which is the moment it has told them everything it can.
2944
+ setTasks([]);
2945
+ knownTaskIdsRef.current = new Set();
2702
2946
  // The model interleaves text → tool → text across iterations; `applyEvent`
2703
2947
  // renders each one in order.
2704
2948
  const st = {
@@ -2848,6 +3092,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2848
3092
  ...(goalRound ? { goalRound } : {}),
2849
3093
  // The mode above decides whether this callback is consulted.
2850
3094
  onPermission: askPermission,
3095
+ onQuestion: askQuestion,
2851
3096
  inboundMessages: () => inbox.drain(),
2852
3097
  extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
2853
3098
  onConversationMessages: (messages) => {
@@ -3101,7 +3346,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3101
3346
  }
3102
3347
  case 'agent-cockpit': {
3103
3348
  if (!openAgentCockpit()) {
3104
- pushMessage('system', 'No delegated agents are available in this conversation yet. When an Agent tool starts one, /agent opens its live activity and retained transcript.');
3349
+ pushMessage('system', 'No delegated agents are active. When an Agent tool starts one, its live activity appears below the composer and this view can inspect it.');
3105
3350
  }
3106
3351
  return;
3107
3352
  }
@@ -3196,7 +3441,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3196
3441
  pushMessage('system', 'Permission choices are unavailable until the active turn, prompt, compaction, queued work, or embedded-session approval latch can be safely settled.');
3197
3442
  return;
3198
3443
  }
3199
- const values = ['prompt', 'auto', 'strict'];
3444
+ const values = ['prompt', 'accept-edits', 'auto', 'strict', 'plan'];
3200
3445
  setSelectedChoice(Math.max(0, values.indexOf(permissionModeRef.current)));
3201
3446
  setChoicePicker({
3202
3447
  kind: 'permission-mode',
@@ -4241,8 +4486,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4241
4486
  // Open the same retained cockpit as `/agent`; Enter then drills into a
4242
4487
  // child's transcript. Keep the shortcut below consent so it can never
4243
4488
  // hide an approval that owns the keyboard.
4244
- if (key.ctrl && input === 't' && openAgentCockpit())
4245
- return;
4489
+ if (key.ctrl && input === 't') {
4490
+ if (agentSurfaceRef.current) {
4491
+ setAgentSurface(null);
4492
+ return;
4493
+ }
4494
+ if (openAgentCockpit())
4495
+ return;
4496
+ }
4246
4497
  // Child observation owns navigation, but never outranks consent: the
4247
4498
  // permission branch above preempts it if a child or parent call asks.
4248
4499
  const agentView = agentSurfaceRef.current;
@@ -4251,7 +4502,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4251
4502
  // highlighted child before the list has appeared on screen.
4252
4503
  if (agentSurfaceCommittedRef.current !== agentView)
4253
4504
  return;
4254
- const agents = subagentsRef.current;
4505
+ const agents = activeSubagentCohorts(subagentsRef.current);
4255
4506
  if (agentView.kind === 'cockpit') {
4256
4507
  const phases = agentPhases(agents);
4257
4508
  const phaseIndex = Math.max(0, phases.findIndex((phase) => phase.id === agentView.selectedPhaseId));
@@ -4382,6 +4633,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4382
4633
  if (key.escape || (key.ctrl && input === 'c')) {
4383
4634
  reviewChoiceInFlightRef.current = null;
4384
4635
  setChoicePicker(null);
4636
+ // Esc leaves the question unanswered and the turn running; the
4637
+ // tool tells the model so. Ctrl+C is the operator saying stop.
4638
+ if (picker.kind === 'user-question') {
4639
+ resolveQuestion({ kind: key.escape ? 'skip' : 'abort' });
4640
+ }
4385
4641
  return;
4386
4642
  }
4387
4643
  if (key.home || key.end || key.pageUp || key.pageDown) {
@@ -4533,9 +4789,21 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4533
4789
  // Background is left natural — we inherit the terminal's own background
4534
4790
  // and only theme the foreground. Forcing
4535
4791
  // a filled bg left mismatched patches around bordered areas, so we don't.
4536
- const liveSubagentCount = subagents.filter((agent) => agent.status === 'starting' || agent.status === 'working').length;
4792
+ const liveSubagents = activeSubagentCohorts(subagents);
4793
+ const representedSubagentTools = new Set(subagents
4794
+ .filter((agent) => agent.toolUseId !== undefined)
4795
+ .map((agent) => JSON.stringify([agent.workflowId, agent.toolUseId])));
4796
+ const representedUnscopedSubagentToolUseIds = new Set(subagents.map((agent) => agent.toolUseId).filter((id) => id !== undefined));
4797
+ // Keep the exact parent Agent row suppressed through the child-settled →
4798
+ // tool-end hand-off. The monitor publishes terminal state synchronously,
4799
+ // while the provider event that removes the generic tool row arrives on the
4800
+ // next tick; filtering only live children made that raw row flash back.
4801
+ const visibleActiveTools = activeTools.filter((tool) => tool.toolName.toLowerCase() !== 'agent' ||
4802
+ !(tool.runId
4803
+ ? representedSubagentTools.has(JSON.stringify([tool.runId, tool.id]))
4804
+ : representedUnscopedSubagentToolUseIds.has(tool.id)));
4537
4805
  const selectedSubagent = agentSurface
4538
- ? subagents.find((agent) => agent.viewId === agentSurface.selectedId)
4806
+ ? liveSubagents.find((agent) => agent.viewId === agentSurface.selectedId)
4539
4807
  : undefined;
4540
4808
  const lifecycleOwnsViewport = phase === 'trust' ||
4541
4809
  phase === 'resume' ||
@@ -4580,16 +4848,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4580
4848
  : goalStatus && phase === 'ready' && state === 'idle'
4581
4849
  ? undefined
4582
4850
  : hintForPhase(phase, state, session?.hasProvider === true, composerHasDraft);
4583
- return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [transcriptOwned || !lifecycleOwnsViewport ? (_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: renderedSettled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, showLive: !lifecycleOwnsViewport, header: transcriptOwned ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) })) : null, phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [agentSurface === null ? (_jsx(LiveActivity, { activeTools: activeTools, working: state === 'thinking' || state === 'tool', agentCount: liveSubagentCount, interruptible: abortRef.current !== null, animate: stdout.isTTY === true })) : null, permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, summary: permission.summary, detailsOpen: permissionDetailsOpen, reviewOffset: permissionReviewOffset, columns: terminal.columns })) : null, permission === null && agentSurface?.kind === 'cockpit' ? (_jsx(AgentCockpit, { agents: subagents, selectedPhaseId: agentSurface.selectedPhaseId, selectedId: agentSurface.selectedId, focus: agentSurface.focus, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'transcript' && selectedSubagent ? (_jsx(AgentTranscript, { agent: selectedSubagent, tailOffset: agentSurface.tailOffset, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null || agentSurface !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && agentSurface === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && agentSurface === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
4851
+ return (_jsx(Box, { flexDirection: "column", display: externalEditorRequest ? 'none' : 'flex', children: _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [transcriptOwned || !lifecycleOwnsViewport ? (_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: renderedSettled, resetKey: resetKey, raw: rawOutput, hyperlinks: hyperlinks, showLive: !lifecycleOwnsViewport, header: transcriptOwned ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) })) : null, phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'edit' ? (_jsx(EditPromptPicker, { prompts: editList, selected: selectedEdit })) : phase === 'picker' ? (_jsx(Picker, { detected: pickerDetected ?? detected, currentProvider: currentProvider, selectionKind: pickerSelectionKind, currentModel: session?.modelSummary ?? null, initialView: pickerInitialView, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: startLoginFromPicker, onLoginComplete: finishLoginFromPicker, keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [agentSurface === null ? (_jsx(LiveActivity, { activeTools: visibleActiveTools, working: state === 'thinking' || state === 'tool', interruptible: abortRef.current !== null, animate: stdout.isTTY === true })) : null, agentSurface === null && permission === null ? _jsx(TaskList, { tasks: tasks }) : null, permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, summary: permission.summary, detailsOpen: permissionDetailsOpen, reviewOffset: permissionReviewOffset, columns: terminal.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null || agentSurface !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && agentSurface === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && agentSurface === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
4584
4852
  phase === 'ready' &&
4585
4853
  conversationMutation === null &&
4586
4854
  textPrompt === null &&
4587
4855
  choicePicker === null &&
4588
4856
  copyPicker === null &&
4589
- agentSurface === null, hidden: permission !== null ||
4590
- choicePicker !== null ||
4591
- copyPicker !== null ||
4592
- agentSurface !== null, children: [pendingSteers.length > 0 &&
4857
+ agentSurface === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
4593
4858
  permission === null &&
4594
4859
  textPrompt === null &&
4595
4860
  choicePicker === null &&
@@ -4603,7 +4868,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4603
4868
  ? queuePause.outcome === 'paused'
4604
4869
  ? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
4605
4870
  : `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
4606
- : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
4871
+ : 'sending when ready'] }) })) : null, _jsx(Composer, { permissionMode: permissionMode, onCycleMode: cyclePermissionMode, disabled: phase !== 'ready' ||
4607
4872
  state === 'awaiting-permission' ||
4608
4873
  compacting ||
4609
4874
  conversationMutation !== null ||
@@ -4611,11 +4876,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4611
4876
  agentSurface !== null, hidden: permission !== null ||
4612
4877
  textPrompt !== null ||
4613
4878
  choicePicker !== null ||
4614
- copyPicker !== null ||
4615
- agentSurface !== null,
4879
+ copyPicker !== null,
4616
4880
  // A turn is running, so Esc is the interrupt and not
4617
4881
  // the composer's clear.
4618
- escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), onDraftPresenceChange: setComposerHasDraft, userCommands: userCommands, mentionCandidates: mentionCandidates, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: statusGoal, state: state, hint: statusHint }) })] }) }));
4882
+ escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onStepReasoningEffort: stepReasoningEffort, onExternalEdit: requestExternalEditor, onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), onDraftPresenceChange: setComposerHasDraft, onOpenAgentPanel: openAgentCockpit, userCommands: userCommands, mentionCandidates: mentionCandidates, history: history })] }), permission === null && agentSurface === null && liveSubagents.length > 0 ? (_jsx(AgentTaskPanel, { agents: liveSubagents, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'cockpit' ? (_jsx(AgentCockpit, { agents: liveSubagents, selectedPhaseId: agentSurface.selectedPhaseId, selectedId: agentSurface.selectedId, focus: agentSurface.focus, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : permission === null && agentSurface?.kind === 'transcript' && selectedSubagent ? (_jsx(AgentTranscript, { agent: selectedSubagent, tailOffset: agentSurface.tailOffset, terminalRows: terminal.rows, terminalColumns: terminal.columns })) : null] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, effort: reasoningEffort ?? 'default', goal: statusGoal, state: state, hint: statusHint }) })] }) }));
4619
4883
  }
4620
4884
  function Banner({ version, session, permissionMode, cwd, }) {
4621
4885
  const { columns } = useWindowSize();
@@ -4695,10 +4959,18 @@ function goalStatusLabel(goal) {
4695
4959
  return 'Goal achieved';
4696
4960
  }
4697
4961
  }
4962
+ /** The label the operator saw for the option id they chose. */
4963
+ function labelOfOption(question, optionId) {
4964
+ return question.options.find((option) => option.id === optionId)?.label ?? optionId;
4965
+ }
4698
4966
  function permissionModeDescription(mode) {
4699
4967
  switch (mode) {
4700
4968
  case 'prompt':
4701
4969
  return 'Ask before an undecided tool call runs';
4970
+ case 'accept-edits':
4971
+ return 'Approve file edits and writes without asking; shell and everything else still ask';
4972
+ case 'plan':
4973
+ return 'Read-only: the agent explores and presents a plan; leave plan mode to carry it out';
4702
4974
  case 'auto':
4703
4975
  return 'Approve undecided calls unless a rule or safety gate refuses';
4704
4976
  case 'strict':