@namzu/cli 15.1.2 → 16.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 (80) hide show
  1. package/README.md +37 -7
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +43 -8
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/state.d.ts +9 -0
  6. package/dist/commands/state.d.ts.map +1 -0
  7. package/dist/commands/state.js +110 -0
  8. package/dist/commands/state.js.map +1 -0
  9. package/dist/config/load.d.ts.map +1 -1
  10. package/dist/config/load.js +8 -0
  11. package/dist/config/load.js.map +1 -1
  12. package/dist/config/schema.d.ts +8 -0
  13. package/dist/config/schema.d.ts.map +1 -1
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/context/capabilities.d.ts +7 -4
  16. package/dist/context/capabilities.d.ts.map +1 -1
  17. package/dist/context/capabilities.js +7 -4
  18. package/dist/context/capabilities.js.map +1 -1
  19. package/dist/context/sandbox.d.ts +5 -0
  20. package/dist/context/sandbox.d.ts.map +1 -1
  21. package/dist/context/sandbox.js +11 -4
  22. package/dist/context/sandbox.js.map +1 -1
  23. package/dist/integrations/sessions/store.d.ts.map +1 -1
  24. package/dist/integrations/sessions/store.js +25 -2
  25. package/dist/integrations/sessions/store.js.map +1 -1
  26. package/dist/integrations/state/private-directory.d.ts +15 -0
  27. package/dist/integrations/state/private-directory.d.ts.map +1 -0
  28. package/dist/integrations/state/private-directory.js +41 -0
  29. package/dist/integrations/state/private-directory.js.map +1 -0
  30. package/dist/integrations/state/report.d.ts +125 -0
  31. package/dist/integrations/state/report.d.ts.map +1 -0
  32. package/dist/integrations/state/report.js +784 -0
  33. package/dist/integrations/state/report.js.map +1 -0
  34. package/dist/integrations/subagents/activity.d.ts +88 -0
  35. package/dist/integrations/subagents/activity.d.ts.map +1 -0
  36. package/dist/integrations/subagents/activity.js +389 -0
  37. package/dist/integrations/subagents/activity.js.map +1 -0
  38. package/dist/integrations/subagents/runtime.d.ts +11 -1
  39. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  40. package/dist/integrations/subagents/runtime.js +66 -3
  41. package/dist/integrations/subagents/runtime.js.map +1 -1
  42. package/dist/tui/AgentExplorer.d.ts +57 -0
  43. package/dist/tui/AgentExplorer.d.ts.map +1 -0
  44. package/dist/tui/AgentExplorer.js +285 -0
  45. package/dist/tui/AgentExplorer.js.map +1 -0
  46. package/dist/tui/App.d.ts.map +1 -1
  47. package/dist/tui/App.js +479 -150
  48. package/dist/tui/App.js.map +1 -1
  49. package/dist/tui/Composer.d.ts +7 -1
  50. package/dist/tui/Composer.d.ts.map +1 -1
  51. package/dist/tui/Composer.js +26 -5
  52. package/dist/tui/Composer.js.map +1 -1
  53. package/dist/tui/LiveActivity.d.ts +9 -3
  54. package/dist/tui/LiveActivity.d.ts.map +1 -1
  55. package/dist/tui/LiveActivity.js +27 -10
  56. package/dist/tui/LiveActivity.js.map +1 -1
  57. package/dist/tui/PermissionOverlay.d.ts +8 -9
  58. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  59. package/dist/tui/PermissionOverlay.js +6 -10
  60. package/dist/tui/PermissionOverlay.js.map +1 -1
  61. package/dist/tui/StatusBar.d.ts +1 -47
  62. package/dist/tui/StatusBar.d.ts.map +1 -1
  63. package/dist/tui/StatusBar.js +9 -79
  64. package/dist/tui/StatusBar.js.map +1 -1
  65. package/dist/tui/agent.d.ts +3 -0
  66. package/dist/tui/agent.d.ts.map +1 -1
  67. package/dist/tui/agent.js +217 -178
  68. package/dist/tui/agent.js.map +1 -1
  69. package/dist/tui/permission-review.d.ts +18 -2
  70. package/dist/tui/permission-review.d.ts.map +1 -1
  71. package/dist/tui/permission-review.js +88 -2
  72. package/dist/tui/permission-review.js.map +1 -1
  73. package/dist/tui/slashCommands.d.ts +4 -0
  74. package/dist/tui/slashCommands.d.ts.map +1 -1
  75. package/dist/tui/slashCommands.js +14 -0
  76. package/dist/tui/slashCommands.js.map +1 -1
  77. package/dist/user-commands/store.d.ts.map +1 -1
  78. package/dist/user-commands/store.js +26 -5
  79. package/dist/user-commands/store.js.map +1 -1
  80. package/package.json +2 -2
package/dist/tui/App.js CHANGED
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * automatic reuse is not mistaken for a durable operator preference.
13
13
  */
14
14
  import { join, relative } from 'node:path';
15
- import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
15
+ import { DefaultPathBuilder, DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, RunCancelled, SessionGoalActivation, StaleGoalError, asSessionId, createAssistantMessage, createUserMessage, generateRunId, generateSessionId, isCompactionMessage, kernelHostCommands, } from '@namzu/sdk';
16
16
  import { Box, Text, useApp, useInput, useStdout, useWindowSize } from 'ink';
17
17
  import { useCallback, useEffect, useRef, useState } from 'react';
18
18
  import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
@@ -28,19 +28,18 @@ 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
32
  import { ChoicePicker } from './ChoicePicker.js';
32
33
  import { Composer, suggestionWindowSize, } from './Composer.js';
33
34
  import { CopyPicker } from './CopyPicker.js';
34
35
  import { EditPromptPicker } from './EditPromptPicker.js';
35
36
  import { LiveActivity, formatElapsed } from './LiveActivity.js';
36
37
  import { PermissionOverlay } from './PermissionOverlay.js';
37
- import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
38
38
  import { Picker } from './Picker.js';
39
39
  import { ResumePicker } from './ResumePicker.js';
40
40
  import { StatusBar } from './StatusBar.js';
41
41
  import { TextPrompt } from './TextPrompt.js';
42
42
  import { Transcript, willCollapse } from './Transcript.js';
43
- import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
44
43
  import { TrustPrompt } from './TrustPrompt.js';
45
44
  import { createAgentSession, probeAgentSession, } from './agent.js';
46
45
  import { keepRecentRows } from './compact-transcript.js';
@@ -48,18 +47,20 @@ import { approvalIsDeliberate } from './consent-timing.js';
48
47
  import { planTurnPublication } from './conversation-history.js';
49
48
  import { copyTargetsForResponse } from './copy-targets.js';
50
49
  import { editablePrompts } from './edit-prompts.js';
50
+ import { editDraftInExternalEditor } from './external-editor.js';
51
51
  import { liveWindow } from './live-window.js';
52
- import { describeRunInterruption } from './run-interruption.js';
53
52
  import { describeCodexDeviceLoginStart, describeLoginOutcome, describeLoginStart, describeLogout, describeProviderLogout, } from './login-prompt.js';
54
53
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
55
54
  import { expandFileMentions, listMentionableFiles } from './mentions.js';
56
55
  import { openInBrowser } from './open-browser.js';
56
+ import { PERMISSION_REVIEW_PAGE_ROWS, buildPermissionReview, buildPermissionSummary, permissionReviewRefusal, permissionReviewRows, } from './permission-review.js';
57
+ import { describeRunInterruption } from './run-interruption.js';
58
+ import { moveSelection } from './selection-window.js';
57
59
  import { baseBranchReviewPrompt, commitReviewPrompt, hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
58
60
  import { splitCompleteBlocks } from './stream-blocks.js';
59
- import { moveSelection } from './selection-window.js';
61
+ import { terminalSupportsHyperlinks } from './terminal-hyperlinks.js';
60
62
  import { theme } from './theme.js';
61
63
  import { useSelectionIndex } from './use-selection-index.js';
62
- import { editDraftInExternalEditor } from './external-editor.js';
63
64
  import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
64
65
  import { listReviewBranches, listReviewCommits, reviewMergeBase, } from './workspace-review.js';
65
66
  /** Last non-empty assistant text in a durable conversation, newest first. */
@@ -293,6 +294,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
293
294
  const [permission, setPermission] = useState(null);
294
295
  const [permissionReviewOffset, setPermissionReviewOffsetState] = useState(0);
295
296
  const permissionReviewOffsetRef = useRef(0);
297
+ const [permissionDetailsOpen, setPermissionDetailsOpenState] = useState(false);
298
+ const permissionDetailsOpenRef = useRef(false);
299
+ const setPermissionDetailsOpen = useCallback((next) => {
300
+ permissionDetailsOpenRef.current = next;
301
+ setPermissionDetailsOpenState(next);
302
+ }, []);
296
303
  const setPermissionReviewOffset = useCallback((next) => {
297
304
  permissionReviewOffsetRef.current = next;
298
305
  setPermissionReviewOffsetState(next);
@@ -319,10 +326,6 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
319
326
  const [mentionCandidates, setMentionCandidates] = useState([]);
320
327
  const mentionLoadOwnerRef = useRef(null);
321
328
  const [usage, setUsage] = useState(null);
322
- // Context fill, straight from the kernel and held apart from `usage` —
323
- // they are different quantities and conflating them is what made the
324
- // gauge climb with turn count instead of with context.
325
- const [context, setContext] = useState(null);
326
329
  // Tools currently executing — rendered live (spinner + elapsed) below the
327
330
  // transcript, then committed as static lines on completion.
328
331
  const [activeTools, setActiveTools] = useState([]);
@@ -392,11 +395,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
392
395
  /** `/copy` owns this exact response snapshot until selection or cancellation. */
393
396
  const [copyPicker, setCopyPickerState] = useState(null);
394
397
  const copyPickerRef = useRef(null);
398
+ const copyPickerCommittedRef = useRef(null);
395
399
  const { selection: selectedCopy, selectionRef: selectedCopyRef, setSelection: setSelectedCopy, } = useSelectionIndex(0);
396
400
  const setCopyPicker = useCallback((next) => {
397
401
  copyPickerRef.current = next;
402
+ if (next === null)
403
+ copyPickerCommittedRef.current = null;
398
404
  setCopyPickerState(next);
399
405
  }, []);
406
+ useEffect(() => {
407
+ copyPickerCommittedRef.current = copyPicker;
408
+ }, [copyPicker]);
400
409
  /** Finite slash-command choice owned synchronously until apply or cancel. */
401
410
  const [choicePicker, setChoicePickerState] = useState(null);
402
411
  const choicePickerRef = useRef(null);
@@ -426,7 +435,49 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
426
435
  setTextPromptState(next);
427
436
  }, []);
428
437
  const [composerDraft, setComposerDraft] = useState(null);
438
+ const [composerHasDraft, setComposerHasDraft] = useState(false);
429
439
  const composerDraftTokenRef = useRef(0);
440
+ /** Bounded child-run projection published by the current AgentSession. */
441
+ const [subagents, setSubagentsState] = useState([]);
442
+ const subagentsRef = useRef([]);
443
+ const [agentSurface, setAgentSurfaceState] = useState(null);
444
+ const agentSurfaceRef = useRef(null);
445
+ const agentSurfaceCommittedRef = useRef(null);
446
+ const setAgentSurface = useCallback((next) => {
447
+ const wasVisible = agentSurfaceCommittedRef.current !== null;
448
+ agentSurfaceRef.current = next;
449
+ if (next === null)
450
+ agentSurfaceCommittedRef.current = null;
451
+ // Only opening the surface needs a paint fence. Once the operator has
452
+ // seen it, navigation may update the state object several times in one
453
+ // terminal input burst; those updates remain actionable immediately.
454
+ else if (wasVisible)
455
+ agentSurfaceCommittedRef.current = next;
456
+ setAgentSurfaceState(next);
457
+ }, []);
458
+ useEffect(() => {
459
+ agentSurfaceCommittedRef.current = agentSurface;
460
+ }, [agentSurface]);
461
+ const replaceSubagents = useCallback((next) => {
462
+ subagentsRef.current = next;
463
+ setSubagentsState(next);
464
+ const surface = agentSurfaceRef.current;
465
+ if (!surface || next.some((agent) => agent.viewId === surface.selectedId))
466
+ return;
467
+ const phases = agentPhases(next);
468
+ const fallbackPhase = phases.find((phase) => phase.id === surface.selectedPhaseId) ?? phases[0];
469
+ const fallbackAgent = fallbackPhase?.agents[0];
470
+ if (!fallbackPhase || !fallbackAgent) {
471
+ setAgentSurface(null);
472
+ return;
473
+ }
474
+ setAgentSurface({
475
+ kind: 'cockpit',
476
+ selectedPhaseId: fallbackPhase.id,
477
+ selectedId: fallbackAgent.viewId,
478
+ focus: surface.kind === 'cockpit' ? surface.focus : surface.returnFocus,
479
+ });
480
+ }, [setAgentSurface]);
430
481
  /** One git-backed review choice may be resolving while its visible picker remains authoritative. */
431
482
  const reviewChoiceInFlightRef = useRef(null);
432
483
  const exitArmedRef = useRef(false);
@@ -466,6 +517,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
466
517
  * server's child process outlives the object that opened it.
467
518
  */
468
519
  const previousSessionRef = useRef(null);
520
+ useEffect(() => {
521
+ const source = session?.subagents;
522
+ setAgentSurface(null);
523
+ if (!source) {
524
+ replaceSubagents([]);
525
+ return;
526
+ }
527
+ const refresh = () => replaceSubagents(source.getSnapshot());
528
+ refresh();
529
+ return source.subscribe(refresh);
530
+ }, [replaceSubagents, session, setAgentSurface]);
531
+ const resetSubagentActivity = useCallback(() => {
532
+ session?.subagents?.reset();
533
+ setAgentSurface(null);
534
+ replaceSubagents([]);
535
+ }, [replaceSubagents, session, setAgentSurface]);
469
536
  // Source of truth for in-flight tools (the event loop runs across renders, so
470
537
  // a ref avoids stale state); `activeTools` mirrors it for rendering.
471
538
  const activeToolsRef = useRef([]);
@@ -496,9 +563,19 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
496
563
  // turns attribute to the resumed conversation.
497
564
  const sessionsRef = useRef(null);
498
565
  const scopeRef = useRef(null);
566
+ /**
567
+ * A provider session can be ready before a person has started a conversation.
568
+ * The scope still needs a mutable session cursor, but its generated id is
569
+ * provisional until the first admitted prompt or an explicit conversation
570
+ * operation publishes the durable Session.
571
+ */
572
+ const conversationMaterializedRef = useRef(false);
573
+ const materializationRef = useRef(null);
499
574
  const exitWithSummary = useCallback(() => {
500
575
  onExitSummary?.({
501
- ...(scopeRef.current?.sessionId ? { conversationId: scopeRef.current.sessionId } : {}),
576
+ ...(conversationMaterializedRef.current && scopeRef.current?.sessionId
577
+ ? { conversationId: scopeRef.current.sessionId }
578
+ : {}),
502
579
  });
503
580
  exit();
504
581
  }, [exit, onExitSummary]);
@@ -775,12 +852,21 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
775
852
  }
776
853
  applyReasoningEffort(next);
777
854
  }, [applyReasoningEffort, pushMessage, session]);
778
- const recordFeedback = useCallback((runIdValue, messageIdValue, rating, note) => {
779
- // Written under the same `<cwd>/.namzu` root the runs live in, so a
780
- // rating and the transcript it judges travel together.
855
+ const recordFeedback = useCallback((sessionId, runIdValue, messageIdValue, rating, note) => {
856
+ const sessions = sessionsRef.current;
857
+ if (!sessions) {
858
+ pushMessage('system', 'Could not record feedback: conversation persistence is unavailable.');
859
+ return;
860
+ }
861
+ // Run evidence is session-scoped in the canonical store. The old flat
862
+ // `<cwd>/.namzu/runs` lookup never existed after the hierarchy migration,
863
+ // so the feedback store correctly refused every TUI rating as unverifiable.
864
+ // Bind both trees to the exact conversation captured with the streamed
865
+ // message; `/resume` may change the active scope before a delayed click.
866
+ const sessionDir = new DefaultPathBuilder(sessions.root).sessionDir(sessions.projectId, sessionId);
781
867
  const store = new DiskMessageFeedbackStore({
782
- rootDir: join(ctx.cwd, '.namzu', 'feedback'),
783
- runsDir: join(ctx.cwd, '.namzu', 'runs'),
868
+ rootDir: join(sessionDir, 'feedback'),
869
+ runsDir: join(sessionDir, 'runs'),
784
870
  });
785
871
  const runId = runIdValue;
786
872
  const messageId = messageIdValue;
@@ -802,7 +888,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
802
888
  pushMessage('system', `Could not record feedback: ${error instanceof Error ? error.message : String(error)}`);
803
889
  }
804
890
  })();
805
- }, [ctx.cwd, pushMessage]);
891
+ }, [pushMessage]);
806
892
  const activateSkill = useCallback((name) => {
807
893
  const info = discoverSkills({ cwd: ctx.cwd }).find((skill) => skill.name === name);
808
894
  if (!info) {
@@ -849,8 +935,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
849
935
  return;
850
936
  const sessions = sessionsRef.current;
851
937
  const sessionId = scopeRef.current?.sessionId;
852
- if (!sessions || !sessionId) {
853
- pushMessage('system', 'Cannot archive this conversation because durable session persistence is unavailable.');
938
+ if (!sessions || !sessionId || !conversationMaterializedRef.current) {
939
+ pushMessage('system', !conversationMaterializedRef.current
940
+ ? 'There is no conversation to archive yet — send a message first.'
941
+ : 'Cannot archive this conversation because durable session persistence is unavailable.');
854
942
  return;
855
943
  }
856
944
  if (state !== 'idle' ||
@@ -901,8 +989,10 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
901
989
  const stableExportSource = useCallback(() => {
902
990
  const sessions = sessionsRef.current;
903
991
  const sessionId = scopeRef.current?.sessionId;
904
- if (!sessions || !sessionId) {
905
- pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
992
+ if (!sessions || !sessionId || !conversationMaterializedRef.current) {
993
+ pushMessage('system', !conversationMaterializedRef.current
994
+ ? 'There is no conversation to export yet — send a message first.'
995
+ : 'Cannot export this conversation because durable session persistence is unavailable.');
906
996
  return null;
907
997
  }
908
998
  if (abortRef.current ||
@@ -1149,7 +1239,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1149
1239
  return;
1150
1240
  }
1151
1241
  if (picker.kind === 'feedback-rating') {
1152
- recordFeedback(picker.runId, picker.messageId, value);
1242
+ recordFeedback(picker.sessionId, picker.runId, picker.messageId, value);
1153
1243
  return;
1154
1244
  }
1155
1245
  if (picker.kind === 'skill') {
@@ -1268,9 +1358,12 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1268
1358
  ? { text: persistedOutput, provenance: 'persisted' }
1269
1359
  : null;
1270
1360
  initialConversationIdRef.current = undefined;
1361
+ conversationMaterializedRef.current = true;
1271
1362
  }
1272
1363
  else {
1273
- sessionId = await startConversation(sessions);
1364
+ // Provider/tool startup needs stable project/tenant/topic ids, not a
1365
+ // durable conversation. The cursor is replaced on first admitted use.
1366
+ sessionId = generateSessionId();
1274
1367
  }
1275
1368
  sessionsRef.current = sessions;
1276
1369
  scopeRef.current = {
@@ -1287,6 +1380,45 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1287
1380
  return undefined;
1288
1381
  }
1289
1382
  }, [nextId]);
1383
+ /** Publish the provisional conversation exactly once, at first durable use. */
1384
+ const materializeConversation = useCallback(async () => {
1385
+ const existing = await ensureSessions();
1386
+ if (!existing)
1387
+ return undefined;
1388
+ if (conversationMaterializedRef.current)
1389
+ return existing;
1390
+ if (materializationRef.current)
1391
+ return materializationRef.current;
1392
+ const pending = (async () => {
1393
+ const sessions = sessionsRef.current;
1394
+ const scope = scopeRef.current;
1395
+ if (!sessions || !scope)
1396
+ return undefined;
1397
+ conversationMutationRef.current = 'materialize';
1398
+ try {
1399
+ const sessionId = await startConversation(sessions);
1400
+ if (scopeRef.current !== scope || conversationMaterializedRef.current) {
1401
+ throw new Error('the active conversation changed while its first session was published');
1402
+ }
1403
+ scope.sessionId = sessionId;
1404
+ conversationMaterializedRef.current = true;
1405
+ return scope;
1406
+ }
1407
+ finally {
1408
+ if (conversationMutationRef.current === 'materialize') {
1409
+ conversationMutationRef.current = null;
1410
+ }
1411
+ }
1412
+ })();
1413
+ materializationRef.current = pending;
1414
+ try {
1415
+ return await pending;
1416
+ }
1417
+ finally {
1418
+ if (materializationRef.current === pending)
1419
+ materializationRef.current = null;
1420
+ }
1421
+ }, [ensureSessions]);
1290
1422
  const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
1291
1423
  if (signal?.aborted)
1292
1424
  return;
@@ -1341,8 +1473,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1341
1473
  const mentionLoadOwner = {};
1342
1474
  mentionLoadOwnerRef.current = mentionLoadOwner;
1343
1475
  void listMentionableFiles(activeCtx.cwd, appLifetime.signal).then((files) => {
1344
- if (!appLifetime.signal.aborted &&
1345
- mentionLoadOwnerRef.current === mentionLoadOwner)
1476
+ if (!appLifetime.signal.aborted && mentionLoadOwnerRef.current === mentionLoadOwner)
1346
1477
  setMentionCandidates(files);
1347
1478
  });
1348
1479
  setCurrentProvider(primaryProvider(prefs).id);
@@ -1715,7 +1846,13 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1715
1846
  settled: settledRef.current,
1716
1847
  raw: rawOutput,
1717
1848
  });
1718
- settledRef.current = window.settled;
1849
+ // A child view is a redrawable overlay above the parent's native scrollback.
1850
+ // Freeze the parent's Static floor while it is open; otherwise a parent turn
1851
+ // settling in the background prints through the child screen. Returning
1852
+ // advances the floor once and emits those finalized rows exactly once.
1853
+ if (agentSurface === null)
1854
+ settledRef.current = window.settled;
1855
+ const renderedSettled = agentSurface === null ? window.settled : settledRef.current;
1719
1856
  // One merged vocabulary for the session: this host's own commands plus
1720
1857
  // whatever the kernel's registry reports. Built here so `/help`, the
1721
1858
  // autocomplete and the dispatcher all answer from the same list — three
@@ -1801,10 +1938,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1801
1938
  permissionResolveRef.current = null;
1802
1939
  permissionOpenedAtRef.current = null;
1803
1940
  setPermissionReviewOffset(0);
1941
+ setPermissionDetailsOpen(false);
1804
1942
  setPermission(null);
1805
1943
  if (resolve)
1806
1944
  resolve(decision);
1807
- }, [setPermissionReviewOffset]);
1945
+ }, [setPermissionDetailsOpen, setPermissionReviewOffset]);
1808
1946
  /**
1809
1947
  * Stop the running turn, and hand the screen back in a usable state.
1810
1948
  *
@@ -1918,16 +2056,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1918
2056
  goalActivation.clear();
1919
2057
  wakeGoalDriver();
1920
2058
  conversationGenRef.current += 1;
2059
+ resetSubagentActivity();
1921
2060
  activeTurnTokenRef.current = null;
1922
2061
  resetTranscript();
1923
2062
  setMessages(restored);
1924
2063
  modelHistoryRef.current = msgs;
1925
2064
  setHistory((previous) => [...previous, ...promptHistoryFromConversation(msgs)]);
2065
+ // Persisted conversation rows do not carry the live run/message identity
2066
+ // required by feedback validation. Do not leave the answer from the
2067
+ // conversation we just exited as a hidden `/feedback` target.
2068
+ lastAssistantMessage.current = null;
1926
2069
  const persistedOutput = latestAssistantOutput(msgs);
1927
2070
  lastCompletedOutputRef.current = persistedOutput
1928
2071
  ? { text: persistedOutput, provenance: 'persisted' }
1929
2072
  : null;
1930
2073
  scope.sessionId = conv.id; // new turns now attribute to the resumed session
2074
+ conversationMaterializedRef.current = true;
1931
2075
  pushMessage('system', `Resumed: ${conv.title}`);
1932
2076
  if (discardedQueued > 0) {
1933
2077
  pushMessage('system', `Discarded ${discardedQueued} queued prompt${discardedQueued === 1 ? '' : 's'} from the conversation you left.`);
@@ -1946,6 +2090,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1946
2090
  interruptTurn,
1947
2091
  nextId,
1948
2092
  pushMessage,
2093
+ resetSubagentActivity,
1949
2094
  resetTranscript,
1950
2095
  wakeGoalDriver,
1951
2096
  ]);
@@ -1985,13 +2130,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
1985
2130
  goalActivation.clear();
1986
2131
  wakeGoalDriver();
1987
2132
  conversationGenRef.current += 1;
2133
+ resetSubagentActivity();
1988
2134
  activeTurnTokenRef.current = null;
1989
2135
  modelHistoryRef.current = [];
1990
2136
  lastAssistantMessage.current = null;
1991
2137
  lastCompletedOutputRef.current = null;
1992
- setContext(null);
1993
- if (sourceScope && targetSessionId)
2138
+ if (sourceScope && targetSessionId) {
1994
2139
  sourceScope.sessionId = targetSessionId;
2140
+ conversationMaterializedRef.current = true;
2141
+ }
1995
2142
  if (clearScreen) {
1996
2143
  setMessages([]);
1997
2144
  resetTranscript();
@@ -2017,7 +2164,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2017
2164
  conversationMutationRef.current = null;
2018
2165
  setConversationMutation(null);
2019
2166
  }
2020
- }, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
2167
+ }, [
2168
+ discardQueued,
2169
+ goalActivation,
2170
+ interruptTurn,
2171
+ pushMessage,
2172
+ resetSubagentActivity,
2173
+ resetTranscript,
2174
+ wakeGoalDriver,
2175
+ ]);
2021
2176
  /**
2022
2177
  * `/rename` (and the older `/title` alias): edit or set the name this
2023
2178
  * conversation appears under.
@@ -2027,8 +2182,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2027
2182
  * history; it is a session-store operation with the session id captured here.
2028
2183
  */
2029
2184
  const doTitle = useCallback(async (title, clear) => {
2030
- const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
2031
- const scope = scopeRef.current;
2185
+ const scope = await materializeConversation();
2186
+ const sessions = sessionsRef.current;
2032
2187
  if (!sessions || !scope) {
2033
2188
  pushMessage('system', 'Conversation history is unavailable in this folder.');
2034
2189
  return;
@@ -2056,7 +2211,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2056
2211
  catch (err) {
2057
2212
  pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
2058
2213
  }
2059
- }, [ensureSessions, pushMessage, setTextPrompt]);
2214
+ }, [materializeConversation, pushMessage, setTextPrompt]);
2060
2215
  const submitTextPrompt = useCallback((value) => {
2061
2216
  const prompt = textPromptRef.current;
2062
2217
  if (!prompt)
@@ -2124,8 +2279,8 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2124
2279
  conversationMutationRef.current = 'fork';
2125
2280
  setConversationMutation('fork');
2126
2281
  try {
2127
- const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
2128
- const scope = scopeRef.current;
2282
+ const scope = await materializeConversation();
2283
+ const sessions = sessionsRef.current;
2129
2284
  if (!sessions || !scope) {
2130
2285
  pushMessage('system', 'Conversation history is unavailable in this folder.');
2131
2286
  return;
@@ -2139,6 +2294,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2139
2294
  // The transcript on screen is already the fork's history, so nothing
2140
2295
  // is reloaded or reset. Only where the NEXT turn is written changes.
2141
2296
  scope.sessionId = forked.id;
2297
+ resetSubagentActivity();
2142
2298
  goalActivation.clear();
2143
2299
  wakeGoalDriver();
2144
2300
  pushMessage('system', `Forked into "${forked.title}" — ${forked.copied} message(s) copied. This screen continues in the copy; ${original} is unchanged and still in /resume.`);
@@ -2150,7 +2306,14 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2150
2306
  conversationMutationRef.current = null;
2151
2307
  setConversationMutation(null);
2152
2308
  }
2153
- }, [ensureSessions, goalActivation, hasUnsettledTurn, pushMessage, wakeGoalDriver]);
2309
+ }, [
2310
+ goalActivation,
2311
+ hasUnsettledTurn,
2312
+ materializeConversation,
2313
+ pushMessage,
2314
+ resetSubagentActivity,
2315
+ wakeGoalDriver,
2316
+ ]);
2154
2317
  /** Open the durable prompt picker after the composer's second empty Esc. */
2155
2318
  const openPromptEditor = useCallback(() => {
2156
2319
  if (conversationMutationRef.current || compactingRef.current)
@@ -2206,6 +2369,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2206
2369
  .slice(0, target.userOrdinal)
2207
2370
  .map((prompt) => prompt.displayText);
2208
2371
  conversationGenRef.current += 1;
2372
+ resetSubagentActivity();
2209
2373
  activeTurnTokenRef.current = null;
2210
2374
  goalActivation.clear();
2211
2375
  wakeGoalDriver();
@@ -2246,6 +2410,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2246
2410
  hasUnsettledTurn,
2247
2411
  nextId,
2248
2412
  pushMessage,
2413
+ resetSubagentActivity,
2249
2414
  resetTranscript,
2250
2415
  wakeGoalDriver,
2251
2416
  ]);
@@ -2259,15 +2424,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2259
2424
  feedback: permissionReviewRefusal(review.reason),
2260
2425
  });
2261
2426
  }
2427
+ const summary = buildPermissionSummary(review.text);
2262
2428
  return new Promise((resolve) => {
2263
2429
  permissionResolveRef.current = resolve;
2264
2430
  permissionOpenedAtRef.current = Date.now();
2265
2431
  setPermissionReviewOffset(0);
2266
- setPermission({ ...req, review: review.text });
2432
+ setPermissionDetailsOpen(!summary.complete);
2433
+ setPermission({ ...req, review: review.text, summary });
2267
2434
  setState('awaiting-permission');
2268
2435
  sendTerminalNotification({ kind: 'approval-required' });
2269
2436
  });
2270
- }, [sendTerminalNotification, setPermissionReviewOffset]);
2437
+ }, [sendTerminalNotification, setPermissionDetailsOpen, setPermissionReviewOffset]);
2271
2438
  // Render one agent event onto the transcript. Shared by the local turn loop
2272
2439
  // and the daemon-attach poller, so both paths produce identical output.
2273
2440
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -2291,10 +2458,11 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2291
2458
  switch (event.kind) {
2292
2459
  case 'delta': {
2293
2460
  setState('thinking');
2294
- if (event.messageId && event.runId) {
2461
+ if (event.messageId && event.runId && st.sessionId) {
2295
2462
  lastAssistantMessage.current = {
2296
2463
  runId: event.runId,
2297
2464
  messageId: event.messageId,
2465
+ sessionId: st.sessionId,
2298
2466
  };
2299
2467
  }
2300
2468
  st.text += event.text;
@@ -2368,20 +2536,6 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2368
2536
  }
2369
2537
  case 'usage':
2370
2538
  setUsage({ totalTokens: event.totalTokens, cost: event.cost });
2371
- // Mirrored verbatim, absences included. A run that reports no
2372
- // window clears the gauge rather than leaving the last one up:
2373
- // a stale bar is read as current, and this bar's whole defect
2374
- // was being read as something it was not.
2375
- setContext({
2376
- ...(event.contextTokens !== undefined ? { tokens: event.contextTokens } : {}),
2377
- ...(event.contextWindowTokens !== undefined
2378
- ? { windowTokens: event.contextWindowTokens }
2379
- : {}),
2380
- ...(event.contextMeasuredBy !== undefined
2381
- ? { measuredBy: event.contextMeasuredBy }
2382
- : {}),
2383
- ...(event.windowSource !== undefined ? { windowSource: event.windowSource } : {}),
2384
- });
2385
2539
  break;
2386
2540
  case 'task':
2387
2541
  pushMessage('tool', event.subject, false, event.status === 'completed' ? '☑' : '☐');
@@ -2464,13 +2618,22 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2464
2618
  // state idle while the admission read was pending let a second submit
2465
2619
  // start beside it and broke the queue's FIFO ownership.
2466
2620
  setState('thinking');
2621
+ let durableScope;
2622
+ try {
2623
+ durableScope = await materializeConversation();
2624
+ }
2625
+ catch (error) {
2626
+ pushMessage('system', `Conversation persistence could not start: ${error instanceof Error ? error.message : String(error)}. This turn will continue in memory and cannot be resumed.`);
2627
+ }
2467
2628
  // A conversation can be closed outside this App after it was opened or
2468
2629
  // resumed. Re-check at the actual turn boundary, before expanding file
2469
2630
  // mentions, recording run evidence, or creating a provider iterator.
2470
2631
  // The persistence helpers repeat the same check at their own boundary;
2471
2632
  // neither read is claimed to serialize a concurrent archive (that needs
2472
2633
  // a store-level lease shared with ProjectManager).
2473
- const destination = scopeRef.current?.sessionId ?? null;
2634
+ const destination = conversationMaterializedRef.current
2635
+ ? (durableScope?.sessionId ?? scopeRef.current?.sessionId ?? null)
2636
+ : null;
2474
2637
  const turnSessions = sessionsRef.current;
2475
2638
  if (turnSessions && destination) {
2476
2639
  try {
@@ -2522,6 +2685,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2522
2685
  pending: '',
2523
2686
  completed: false,
2524
2687
  outcome: null,
2688
+ sessionId: destination,
2525
2689
  notification: null,
2526
2690
  };
2527
2691
  const ac = new AbortController();
@@ -2848,6 +3012,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2848
3012
  finalizeMessage,
2849
3013
  flushStream,
2850
3014
  goalActivation,
3015
+ materializeConversation,
2851
3016
  onPermission,
2852
3017
  pushMessage,
2853
3018
  replaceQueued,
@@ -2872,7 +3037,9 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2872
3037
  ? 'branched for prompt editing'
2873
3038
  : conversationMutationRef.current === 'archive'
2874
3039
  ? 'archived'
2875
- : 'moved to a fresh conversation';
3040
+ : conversationMutationRef.current === 'materialize'
3041
+ ? 'initialized'
3042
+ : 'moved to a fresh conversation';
2876
3043
  pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
2877
3044
  return;
2878
3045
  }
@@ -2909,6 +3076,24 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
2909
3076
  });
2910
3077
  return;
2911
3078
  }
3079
+ case 'agent-cockpit': {
3080
+ const agents = subagentsRef.current;
3081
+ if (agents.length === 0) {
3082
+ 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.');
3083
+ return;
3084
+ }
3085
+ const firstPhase = agentPhases(agents)[0];
3086
+ const firstAgent = firstPhase?.agents[0];
3087
+ if (!firstPhase || !firstAgent)
3088
+ return;
3089
+ setAgentSurface({
3090
+ kind: 'cockpit',
3091
+ selectedPhaseId: firstPhase.id,
3092
+ selectedId: firstAgent.viewId,
3093
+ focus: 'agents',
3094
+ });
3095
+ return;
3096
+ }
2912
3097
  case 'clear-screen':
2913
3098
  setMessages([]);
2914
3099
  resetTranscript();
@@ -3243,29 +3428,46 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3243
3428
  // THIS session can answer from. The descriptors used for
3244
3429
  // the merge above carry no store — they are names — so the
3245
3430
  // registry is rebuilt here with the live one.
3246
- const durableSessions = sessionsRef.current;
3247
- const runScope = scopeRef.current;
3248
3431
  const generation = conversationGenRef.current;
3249
3432
  const goalCommand = slash.name === 'goal';
3250
3433
  if (goalCommand)
3251
3434
  goalCommandInFlightRef.current = true;
3252
- const registry = new HostCommandRegistry();
3253
- registry.register(kernelHostCommands({
3254
- allowedAgentIds: session?.agentIds ?? [],
3255
- ...(durableSessions && runScope
3256
- ? {
3257
- goal: {
3258
- store: durableSessions.goals,
3259
- sessionId: runScope.sessionId,
3260
- tenantId: durableSessions.tenantId,
3261
- activation: goalActivation,
3262
- },
3263
- }
3264
- : {}),
3265
- }));
3266
3435
  void (async () => {
3436
+ let goalCommandReleased = false;
3437
+ const releaseGoalCommand = () => {
3438
+ if (!goalCommand || goalCommandReleased)
3439
+ return;
3440
+ goalCommandReleased = true;
3441
+ goalCommandInFlightRef.current = false;
3442
+ wakeGoalDriver();
3443
+ };
3267
3444
  try {
3445
+ if (goalCommand)
3446
+ await materializeConversation();
3447
+ const durableSessions = sessionsRef.current;
3448
+ const runScope = scopeRef.current;
3449
+ const registry = new HostCommandRegistry();
3450
+ registry.register(kernelHostCommands({
3451
+ allowedAgentIds: session?.agentIds ?? [],
3452
+ ...(durableSessions && runScope && conversationMaterializedRef.current
3453
+ ? {
3454
+ goal: {
3455
+ store: durableSessions.goals,
3456
+ sessionId: runScope.sessionId,
3457
+ tenantId: durableSessions.tenantId,
3458
+ activation: goalActivation,
3459
+ },
3460
+ }
3461
+ : {}),
3462
+ }));
3268
3463
  const outcome = await registry.dispatch(`/${slash.name} ${slash.args.join(' ')}`.trim());
3464
+ // The dispatch result is the durable boundary. Release the input
3465
+ // gate before publishing that result: once the operator can see a
3466
+ // completed `/goal` row, the next goal command must be admissible.
3467
+ // Keeping the gate until `finally` left one render-sized window in
3468
+ // which a visible success was followed by "still reaching durable
3469
+ // state" when the operator immediately typed `/goal resume`.
3470
+ releaseGoalCommand();
3269
3471
  // A late readout from the conversation just left must not be
3270
3472
  // painted as state of the one now on screen.
3271
3473
  if (conversationGenRef.current !== generation)
@@ -3280,10 +3482,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3280
3482
  pushMessage('system', `Could not run /${slash.name}: ${error instanceof Error ? error.message : String(error)}`);
3281
3483
  }
3282
3484
  finally {
3283
- if (goalCommand) {
3284
- goalCommandInFlightRef.current = false;
3285
- wakeGoalDriver();
3286
- }
3485
+ releaseGoalCommand();
3287
3486
  }
3288
3487
  })();
3289
3488
  return;
@@ -3301,6 +3500,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3301
3500
  title: 'Rate the latest answer',
3302
3501
  runId: target.runId,
3303
3502
  messageId: target.messageId,
3503
+ sessionId: target.sessionId,
3304
3504
  values,
3305
3505
  options: [
3306
3506
  {
@@ -3318,7 +3518,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3318
3518
  pushMessage('system', 'Nothing to rate yet.');
3319
3519
  return;
3320
3520
  }
3321
- recordFeedback(target.runId, slash.messageId, slash.rating, slash.note);
3521
+ recordFeedback(target.sessionId, target.runId, slash.messageId, slash.rating, slash.note);
3322
3522
  return;
3323
3523
  }
3324
3524
  case 'review': {
@@ -3417,11 +3617,6 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3417
3617
  await replaceConversation(sessions, destination, result.messages);
3418
3618
  }
3419
3619
  modelHistoryRef.current = result.messages;
3420
- // The old gauge measured the history that was just replaced.
3421
- // Clear it only after the durable projection and live model
3422
- // history agree; until then the old measurement is still the
3423
- // truthful one. The next run publishes a fresh measurement.
3424
- setContext(null);
3425
3620
  // How many user/assistant turns survived the pass.
3426
3621
  // `keepRecentRows` explains why the transcript is trimmed
3427
3622
  // to match rather than rebuilt from `result.messages`.
@@ -3906,13 +4101,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3906
4101
  return;
3907
4102
  }
3908
4103
  if (key.home || key.end || key.pageUp || key.pageDown) {
3909
- setSelectedEdit((index) => moveSelection(index, editList.length, key.home
3910
- ? 'first'
3911
- : key.end
3912
- ? 'last'
3913
- : key.pageUp
3914
- ? 'previous-page'
3915
- : 'next-page'));
4104
+ setSelectedEdit((index) => moveSelection(index, editList.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
3916
4105
  return;
3917
4106
  }
3918
4107
  if (key.escape || key.leftArrow || key.upArrow) {
@@ -3939,13 +4128,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3939
4128
  if (resumeCommittedRef.current)
3940
4129
  return;
3941
4130
  if (key.home || key.end || key.pageUp || key.pageDown) {
3942
- setSelectedResume((index) => moveSelection(index, resumeList.length, key.home
3943
- ? 'first'
3944
- : key.end
3945
- ? 'last'
3946
- : key.pageUp
3947
- ? 'previous-page'
3948
- : 'next-page'));
4131
+ setSelectedResume((index) => moveSelection(index, resumeList.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
3949
4132
  }
3950
4133
  else if (key.upArrow)
3951
4134
  setSelectedResume((index) => moveSelection(index, resumeList.length, 'previous'));
@@ -3989,9 +4172,18 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
3989
4172
  }
3990
4173
  // A pending permission prompt owns the keyboard: y/n/a decide it.
3991
4174
  if (permissionResolveRef.current) {
4175
+ const ch = input.toLowerCase();
4176
+ if (permission && ch === 'd') {
4177
+ setPermissionReviewOffset(0);
4178
+ setPermissionDetailsOpen(!permissionDetailsOpenRef.current);
4179
+ return;
4180
+ }
3992
4181
  if (permission &&
3993
4182
  (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end)) {
3994
- const count = permissionReviewRows(permission.review, terminal.columns).length;
4183
+ const source = permissionDetailsOpenRef.current
4184
+ ? permission.review
4185
+ : permission.summary.text;
4186
+ const count = permissionReviewRows(source, terminal.columns).length;
3995
4187
  const maxOffset = Math.max(0, count - PERMISSION_REVIEW_PAGE_ROWS);
3996
4188
  const current = Math.min(permissionReviewOffsetRef.current, maxOffset);
3997
4189
  const next = key.home
@@ -4008,7 +4200,6 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4008
4200
  setPermissionReviewOffset(next);
4009
4201
  return;
4010
4202
  }
4011
- const ch = input.toLowerCase();
4012
4203
  // Refusing is never deferred or gated — it is the direction a
4013
4204
  // mistake is recoverable in, so it answers on the first press.
4014
4205
  if (key.ctrl && input === 'c') {
@@ -4034,6 +4225,125 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4034
4225
  resolvePermission({ kind: 'approve-all' });
4035
4226
  return;
4036
4227
  }
4228
+ // Child observation owns navigation, but never outranks consent: the
4229
+ // permission branch above preempts it if a child or parent call asks.
4230
+ const agentView = agentSurfaceRef.current;
4231
+ if (agentView) {
4232
+ // The Return that opened `/agent` cannot also drill into the
4233
+ // highlighted child before the list has appeared on screen.
4234
+ if (agentSurfaceCommittedRef.current !== agentView)
4235
+ return;
4236
+ const agents = subagentsRef.current;
4237
+ if (agentView.kind === 'cockpit') {
4238
+ const phases = agentPhases(agents);
4239
+ const phaseIndex = Math.max(0, phases.findIndex((phase) => phase.id === agentView.selectedPhaseId));
4240
+ const phase = phases[phaseIndex];
4241
+ const phaseAgents = phase?.agents ?? [];
4242
+ const agentIndex = Math.max(0, phaseAgents.findIndex((agent) => agent.viewId === agentView.selectedId));
4243
+ const selected = phaseAgents[agentIndex];
4244
+ if (!phase || !selected) {
4245
+ setAgentSurface(null);
4246
+ return;
4247
+ }
4248
+ if (key.escape || (key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
4249
+ setAgentSurface(null);
4250
+ return;
4251
+ }
4252
+ if (key.leftArrow || key.rightArrow || key.tab) {
4253
+ const focus = key.leftArrow
4254
+ ? 'phases'
4255
+ : key.rightArrow
4256
+ ? 'agents'
4257
+ : agentView.focus === 'phases'
4258
+ ? 'agents'
4259
+ : 'phases';
4260
+ setAgentSurface({ ...agentView, focus });
4261
+ return;
4262
+ }
4263
+ if (key.return) {
4264
+ if (agentView.focus === 'phases') {
4265
+ setAgentSurface({ ...agentView, focus: 'agents' });
4266
+ return;
4267
+ }
4268
+ setAgentSurface({
4269
+ kind: 'transcript',
4270
+ selectedPhaseId: phase.id,
4271
+ selectedId: selected.viewId,
4272
+ returnFocus: agentView.focus,
4273
+ tailOffset: 0,
4274
+ });
4275
+ return;
4276
+ }
4277
+ if (key.home || key.end || key.pageUp || key.pageDown || key.upArrow || key.downArrow) {
4278
+ const movement = key.home
4279
+ ? 'first'
4280
+ : key.end
4281
+ ? 'last'
4282
+ : key.pageUp
4283
+ ? 'previous-page'
4284
+ : key.pageDown
4285
+ ? 'next-page'
4286
+ : key.upArrow
4287
+ ? 'previous'
4288
+ : 'next';
4289
+ if (agentView.focus === 'phases') {
4290
+ const next = moveSelection(phaseIndex, phases.length, movement, agentPhasePageSize(terminal.rows, agentCockpitIsWide(terminal.columns)));
4291
+ const targetPhase = phases[next];
4292
+ const targetAgent = targetPhase?.agents[0];
4293
+ if (targetPhase && targetAgent) {
4294
+ setAgentSurface({
4295
+ ...agentView,
4296
+ selectedPhaseId: targetPhase.id,
4297
+ selectedId: targetAgent.viewId,
4298
+ });
4299
+ }
4300
+ }
4301
+ else {
4302
+ const next = moveSelection(agentIndex, phaseAgents.length, movement, agentPickerPageSize(terminal.rows, agentCockpitIsWide(terminal.columns)));
4303
+ const target = phaseAgents[next];
4304
+ if (target)
4305
+ setAgentSurface({ ...agentView, selectedId: target.viewId });
4306
+ }
4307
+ }
4308
+ return;
4309
+ }
4310
+ const selected = agents.find((agent) => agent.viewId === agentView.selectedId);
4311
+ if (!selected) {
4312
+ setAgentSurface(null);
4313
+ return;
4314
+ }
4315
+ if (input.toLowerCase() === 'q' || (key.ctrl && input === 'c')) {
4316
+ setAgentSurface(null);
4317
+ return;
4318
+ }
4319
+ if (key.escape) {
4320
+ setAgentSurface({
4321
+ kind: 'cockpit',
4322
+ selectedPhaseId: agentView.selectedPhaseId,
4323
+ selectedId: selected.viewId,
4324
+ focus: agentView.returnFocus,
4325
+ });
4326
+ return;
4327
+ }
4328
+ if (key.home || key.end || key.pageUp || key.pageDown || key.upArrow || key.downArrow) {
4329
+ const page = agentTranscriptPageSize(terminal.rows);
4330
+ const max = maxAgentTranscriptTailOffset(selected, terminal.rows, terminal.columns);
4331
+ const offset = Math.min(agentView.tailOffset, max);
4332
+ const next = key.home
4333
+ ? max
4334
+ : key.end
4335
+ ? 0
4336
+ : key.pageUp
4337
+ ? Math.min(max, offset + page)
4338
+ : key.pageDown
4339
+ ? Math.max(0, offset - page)
4340
+ : key.upArrow
4341
+ ? Math.min(max, offset + 1)
4342
+ : Math.max(0, offset - 1);
4343
+ setAgentSurface({ ...agentView, tailOffset: next });
4344
+ }
4345
+ return;
4346
+ }
4037
4347
  // A host-owned text prompt has its own input hook. This synchronous ref
4038
4348
  // claims the interval before React paints it too, so App cannot read a
4039
4349
  // key repeat as an interrupt, exit ladder, or finite-menu choice.
@@ -4058,13 +4368,7 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4058
4368
  }
4059
4369
  if (key.home || key.end || key.pageUp || key.pageDown) {
4060
4370
  const pageSize = picker.kind === 'command' ? picker.windowSize : undefined;
4061
- setSelectedChoice((index) => moveSelection(index, options.length, key.home
4062
- ? 'first'
4063
- : key.end
4064
- ? 'last'
4065
- : key.pageUp
4066
- ? 'previous-page'
4067
- : 'next-page', pageSize));
4371
+ setSelectedChoice((index) => moveSelection(index, options.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page', pageSize));
4068
4372
  return;
4069
4373
  }
4070
4374
  if (key.upArrow) {
@@ -4090,19 +4394,17 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4090
4394
  // stays above it in this handler so an approval request that arrives
4091
4395
  // while the picker is open cannot have its keys stolen by the chooser.
4092
4396
  if (copyPickerRef.current) {
4397
+ // The Return that submitted `/copy` is an ownership fence, not a
4398
+ // selection made against a picker the operator has not seen yet.
4399
+ if (copyPickerCommittedRef.current !== copyPickerRef.current)
4400
+ return;
4093
4401
  const targets = copyPickerRef.current.targets;
4094
4402
  if (key.escape || (key.ctrl && input === 'c')) {
4095
4403
  setCopyPicker(null);
4096
4404
  return;
4097
4405
  }
4098
4406
  if (key.home || key.end || key.pageUp || key.pageDown) {
4099
- setSelectedCopy((index) => moveSelection(index, targets.length, key.home
4100
- ? 'first'
4101
- : key.end
4102
- ? 'last'
4103
- : key.pageUp
4104
- ? 'previous-page'
4105
- : 'next-page'));
4407
+ setSelectedCopy((index) => moveSelection(index, targets.length, key.home ? 'first' : key.end ? 'last' : key.pageUp ? 'previous-page' : 'next-page'));
4106
4408
  return;
4107
4409
  }
4108
4410
  if (key.upArrow) {
@@ -4213,21 +4515,73 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4213
4515
  // Background is left natural — we inherit the terminal's own background
4214
4516
  // and only theme the foreground. Forcing
4215
4517
  // a filled bg left mismatched patches around bordered areas, so we don't.
4216
- const lifecycleOwnsViewport = phase === 'trust' || phase === 'resume' || phase === 'edit' || phase === 'picker';
4217
- 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: window.settled, 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: [_jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? (_jsx(PermissionOverlay, { toolCalls: permission.toolCalls, review: permission.review, reviewOffset: permissionReviewOffset, columns: terminal.columns })) : null, textPrompt ? (_jsx(TextPrompt, { title: textPrompt.title, placeholder: textPrompt.placeholder, initialValue: textPrompt.initialValue, emptyNotice: textPrompt.emptyNotice, hidden: permission !== null, onSubmit: submitTextPrompt, onCancel: cancelTextPrompt }, textPrompt.token)) : permission === null && choicePicker ? (_jsx(ChoicePicker, { title: choicePicker.title, notice: choicePicker.notice, options: choicePicker.options, selected: selectedChoice, windowSize: choicePicker.kind === 'command' ? choicePicker.windowSize : undefined })) : permission === null && copyPicker ? (_jsx(CopyPicker, { targets: copyPicker.targets, selected: selectedCopy })) : null, _jsxs(ComposerFrame, { focus: state === 'idle' &&
4518
+ const liveSubagentCount = subagents.filter((agent) => agent.status === 'starting' || agent.status === 'working').length;
4519
+ const selectedSubagent = agentSurface
4520
+ ? subagents.find((agent) => agent.viewId === agentSurface.selectedId)
4521
+ : undefined;
4522
+ const lifecycleOwnsViewport = phase === 'trust' ||
4523
+ phase === 'resume' ||
4524
+ phase === 'edit' ||
4525
+ phase === 'picker' ||
4526
+ agentSurface !== null;
4527
+ const statusGoal = phase === 'ready' &&
4528
+ state === 'idle' &&
4529
+ conversationMutation === null &&
4530
+ !compacting &&
4531
+ permission === null &&
4532
+ textPrompt === null &&
4533
+ choicePicker === null &&
4534
+ copyPicker === null &&
4535
+ agentSurface === null
4536
+ ? goalStatusLabel(goalStatus)
4537
+ : null;
4538
+ const statusHint = conversationMutation === 'fork'
4539
+ ? 'forking conversation — input is paused'
4540
+ : conversationMutation === 'edit'
4541
+ ? 'branching before prompt — input is paused'
4542
+ : conversationMutation === 'archive'
4543
+ ? 'archiving conversation — input is paused'
4544
+ : conversationMutation === 'new'
4545
+ ? 'starting a fresh conversation — input is paused'
4546
+ : compacting
4547
+ ? 'compacting conversation — input is paused'
4548
+ : permission
4549
+ ? hintForPhase(phase, state, session?.hasProvider === true)
4550
+ : agentSurface?.kind === 'cockpit'
4551
+ ? agentSurface.focus === 'phases'
4552
+ ? 'agent phases — enter agents · esc return'
4553
+ : 'agents — enter inspect · left phases · esc return'
4554
+ : agentSurface?.kind === 'transcript'
4555
+ ? 'observing agent — esc agents · q parent'
4556
+ : textPrompt
4557
+ ? 'name editor open — enter save · esc cancel'
4558
+ : choicePicker
4559
+ ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
4560
+ : copyPicker
4561
+ ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
4562
+ : goalStatus && phase === 'ready' && state === 'idle'
4563
+ ? undefined
4564
+ : hintForPhase(phase, state, session?.hasProvider === true, composerHasDraft);
4565
+ 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' &&
4218
4566
  phase === 'ready' &&
4219
4567
  conversationMutation === null &&
4220
4568
  textPrompt === null &&
4221
4569
  choicePicker === null &&
4222
- copyPicker === null, hidden: permission !== null || choicePicker !== null || copyPicker !== null, children: [pendingSteers.length > 0 &&
4570
+ copyPicker === null &&
4571
+ agentSurface === null, hidden: permission !== null ||
4572
+ choicePicker !== null ||
4573
+ copyPicker !== null ||
4574
+ agentSurface !== null, children: [pendingSteers.length > 0 &&
4223
4575
  permission === null &&
4224
4576
  textPrompt === null &&
4225
4577
  choicePicker === null &&
4226
- copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
4578
+ copyPicker === null &&
4579
+ agentSurface === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.accent.user, children: ["\u21B3 ", pendingSteers.length, " message", pendingSteers.length > 1 ? 's' : '', " steering the active turn \u2014 waiting for its next response boundary"] }) })) : null, queued.length > 0 &&
4227
4580
  permission === null &&
4228
4581
  textPrompt === null &&
4229
4582
  choicePicker === null &&
4230
- copyPicker === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
4583
+ copyPicker === null &&
4584
+ agentSurface === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
4231
4585
  ? queuePause.outcome === 'paused'
4232
4586
  ? 'held after a resumable run paused; wait for recovery, change model, or send a message to release it'
4233
4587
  : `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
@@ -4235,42 +4589,15 @@ export function App({ ctx: initialCtx, onExitSummary, externalEditor = defaultEx
4235
4589
  state === 'awaiting-permission' ||
4236
4590
  compacting ||
4237
4591
  conversationMutation !== null ||
4238
- externalEditorRequest !== null, hidden: permission !== null ||
4592
+ externalEditorRequest !== null ||
4593
+ agentSurface !== null, hidden: permission !== null ||
4239
4594
  textPrompt !== null ||
4240
4595
  choicePicker !== null ||
4241
- copyPicker !== null,
4596
+ copyPicker !== null ||
4597
+ agentSurface !== null,
4242
4598
  // A turn is running, so Esc is the interrupt and not
4243
4599
  // the composer's clear.
4244
- 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)), 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: phase === 'ready' &&
4245
- state === 'idle' &&
4246
- conversationMutation === null &&
4247
- !compacting &&
4248
- permission === null &&
4249
- textPrompt === null &&
4250
- choicePicker === null &&
4251
- copyPicker === null
4252
- ? goalStatusLabel(goalStatus)
4253
- : null, state: state, hint: conversationMutation === 'fork'
4254
- ? 'forking conversation — input is paused'
4255
- : conversationMutation === 'edit'
4256
- ? 'branching before prompt — input is paused'
4257
- : conversationMutation === 'archive'
4258
- ? 'archiving conversation — input is paused'
4259
- : conversationMutation === 'new'
4260
- ? 'starting a fresh conversation — input is paused'
4261
- : compacting
4262
- ? 'compacting conversation — input is paused'
4263
- : permission
4264
- ? hintForPhase(phase, state, session?.hasProvider === true)
4265
- : textPrompt
4266
- ? 'name editor open — enter save · esc cancel'
4267
- : choicePicker
4268
- ? 'choice open — ↑↓ / 1–9 select · enter apply · esc cancel'
4269
- : copyPicker
4270
- ? 'copy target open — ↑↓ / 1–9 select · esc cancel'
4271
- : goalStatus && phase === 'ready' && state === 'idle'
4272
- ? undefined
4273
- : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] }) }));
4600
+ 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 }) })] }) }));
4274
4601
  }
4275
4602
  function Banner({ version, session, permissionMode, cwd, }) {
4276
4603
  const { columns } = useWindowSize();
@@ -4302,7 +4629,9 @@ function formatToolCall(toolName, summary, standalone = false) {
4302
4629
  }
4303
4630
  function hintForPhase(phase, state,
4304
4631
  /** Whether there is a working session behind the picker to go back to. */
4305
- canReturn = false) {
4632
+ canReturn = false,
4633
+ /** Whether the active-turn composer contains text, a paste, or an attachment. */
4634
+ hasDraft = false) {
4306
4635
  // Enter is absent because Enter grants nothing here, deliberately — see the
4307
4636
  // trust branch in the key handler above.
4308
4637
  if (phase === 'trust')
@@ -4328,7 +4657,7 @@ canReturn = false) {
4328
4657
  if (state === 'awaiting-permission')
4329
4658
  return 'y approve · n / esc reject · a approve all';
4330
4659
  if (state !== 'idle')
4331
- return 'enter steer · tab queue · esc interrupt';
4660
+ return hasDraft ? 'enter steer · tab queue · esc interrupt' : '';
4332
4661
  // The composer already points to /help. Keeping the complete key legend in
4333
4662
  // every idle frame made ordinary conversation read like a permanent setup
4334
4663
  // screen; only state-specific actions belong in the footer.