@namzu/cli 12.1.0 → 13.0.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 (199) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +119 -60
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/acp.d.ts +24 -8
  6. package/dist/commands/acp.d.ts.map +1 -1
  7. package/dist/commands/acp.js +221 -80
  8. package/dist/commands/acp.js.map +1 -1
  9. package/dist/commands/drain.d.ts.map +1 -1
  10. package/dist/commands/drain.js +45 -9
  11. package/dist/commands/drain.js.map +1 -1
  12. package/dist/commands/prior-messages.d.ts +11 -0
  13. package/dist/commands/prior-messages.d.ts.map +1 -0
  14. package/dist/commands/prior-messages.js +280 -0
  15. package/dist/commands/prior-messages.js.map +1 -0
  16. package/dist/commands/resume.d.ts.map +1 -1
  17. package/dist/commands/resume.js +21 -19
  18. package/dist/commands/resume.js.map +1 -1
  19. package/dist/commands/run-stream.d.ts +5 -3
  20. package/dist/commands/run-stream.d.ts.map +1 -1
  21. package/dist/commands/run-stream.js +66 -62
  22. package/dist/commands/run-stream.js.map +1 -1
  23. package/dist/commands/run.d.ts.map +1 -1
  24. package/dist/commands/run.js +50 -37
  25. package/dist/commands/run.js.map +1 -1
  26. package/dist/config/debug.d.ts +55 -0
  27. package/dist/config/debug.d.ts.map +1 -0
  28. package/dist/config/debug.js +126 -0
  29. package/dist/config/debug.js.map +1 -0
  30. package/dist/config/load.d.ts +88 -4
  31. package/dist/config/load.d.ts.map +1 -1
  32. package/dist/config/load.js +386 -56
  33. package/dist/config/load.js.map +1 -1
  34. package/dist/config/schema.d.ts +87 -5
  35. package/dist/config/schema.d.ts.map +1 -1
  36. package/dist/config/schema.js +5 -5
  37. package/dist/config/schema.js.map +1 -1
  38. package/dist/config/trusted-project-context.d.ts +11 -0
  39. package/dist/config/trusted-project-context.d.ts.map +1 -0
  40. package/dist/config/trusted-project-context.js +28 -0
  41. package/dist/config/trusted-project-context.js.map +1 -0
  42. package/dist/context/project-path.d.ts +3 -0
  43. package/dist/context/project-path.d.ts.map +1 -0
  44. package/dist/context/project-path.js +16 -0
  45. package/dist/context/project-path.js.map +1 -0
  46. package/dist/context/project-tracker.d.ts +30 -0
  47. package/dist/context/project-tracker.d.ts.map +1 -0
  48. package/dist/context/project-tracker.js +217 -0
  49. package/dist/context/project-tracker.js.map +1 -0
  50. package/dist/context/sandbox.d.ts +29 -9
  51. package/dist/context/sandbox.d.ts.map +1 -1
  52. package/dist/context/sandbox.js +8 -0
  53. package/dist/context/sandbox.js.map +1 -1
  54. package/dist/exit-codes.d.ts +5 -4
  55. package/dist/exit-codes.d.ts.map +1 -1
  56. package/dist/exit-codes.js +5 -4
  57. package/dist/exit-codes.js.map +1 -1
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +2 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/integrations/clipboard/text.d.ts +43 -0
  63. package/dist/integrations/clipboard/text.d.ts.map +1 -0
  64. package/dist/integrations/clipboard/text.js +43 -0
  65. package/dist/integrations/clipboard/text.js.map +1 -0
  66. package/dist/integrations/mcp/servers.d.ts +9 -0
  67. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  68. package/dist/integrations/mcp/servers.js +11 -4
  69. package/dist/integrations/mcp/servers.js.map +1 -1
  70. package/dist/integrations/notifications/terminal.d.ts +32 -0
  71. package/dist/integrations/notifications/terminal.d.ts.map +1 -0
  72. package/dist/integrations/notifications/terminal.js +46 -0
  73. package/dist/integrations/notifications/terminal.js.map +1 -0
  74. package/dist/integrations/plugins/runtime.d.ts +17 -0
  75. package/dist/integrations/plugins/runtime.d.ts.map +1 -0
  76. package/dist/integrations/plugins/runtime.js +100 -0
  77. package/dist/integrations/plugins/runtime.js.map +1 -0
  78. package/dist/integrations/providers/credential-store.d.ts +17 -0
  79. package/dist/integrations/providers/credential-store.d.ts.map +1 -1
  80. package/dist/integrations/providers/credential-store.js +114 -2
  81. package/dist/integrations/providers/credential-store.js.map +1 -1
  82. package/dist/integrations/providers/index.d.ts +2 -2
  83. package/dist/integrations/providers/index.d.ts.map +1 -1
  84. package/dist/integrations/providers/index.js +2 -2
  85. package/dist/integrations/providers/index.js.map +1 -1
  86. package/dist/integrations/providers/keychain.d.ts +0 -13
  87. package/dist/integrations/providers/keychain.d.ts.map +1 -1
  88. package/dist/integrations/providers/keychain.js +0 -76
  89. package/dist/integrations/providers/keychain.js.map +1 -1
  90. package/dist/integrations/providers/oauth.d.ts +45 -16
  91. package/dist/integrations/providers/oauth.d.ts.map +1 -1
  92. package/dist/integrations/providers/oauth.js +170 -43
  93. package/dist/integrations/providers/oauth.js.map +1 -1
  94. package/dist/integrations/providers/subscription-login.d.ts +2 -0
  95. package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
  96. package/dist/integrations/providers/subscription-login.js +117 -14
  97. package/dist/integrations/providers/subscription-login.js.map +1 -1
  98. package/dist/integrations/sessions/store.d.ts +111 -1
  99. package/dist/integrations/sessions/store.d.ts.map +1 -1
  100. package/dist/integrations/sessions/store.js +400 -7
  101. package/dist/integrations/sessions/store.js.map +1 -1
  102. package/dist/integrations/sessions/transcript-export.d.ts +25 -0
  103. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -0
  104. package/dist/integrations/sessions/transcript-export.js +441 -0
  105. package/dist/integrations/sessions/transcript-export.js.map +1 -0
  106. package/dist/integrations/sessions/turn-evidence.d.ts +138 -0
  107. package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -0
  108. package/dist/integrations/sessions/turn-evidence.js +506 -0
  109. package/dist/integrations/sessions/turn-evidence.js.map +1 -0
  110. package/dist/integrations/subagents/runtime.d.ts +7 -12
  111. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  112. package/dist/integrations/subagents/runtime.js +19 -10
  113. package/dist/integrations/subagents/runtime.js.map +1 -1
  114. package/dist/integrations/trust/store.d.ts.map +1 -1
  115. package/dist/integrations/trust/store.js +27 -13
  116. package/dist/integrations/trust/store.js.map +1 -1
  117. package/dist/permissions/canonical-project.d.ts +9 -0
  118. package/dist/permissions/canonical-project.d.ts.map +1 -0
  119. package/dist/permissions/canonical-project.js +13 -0
  120. package/dist/permissions/canonical-project.js.map +1 -0
  121. package/dist/permissions/checks.d.ts +65 -0
  122. package/dist/permissions/checks.d.ts.map +1 -0
  123. package/dist/permissions/checks.js +122 -0
  124. package/dist/permissions/checks.js.map +1 -0
  125. package/dist/permissions/headless-trust.d.ts +9 -5
  126. package/dist/permissions/headless-trust.d.ts.map +1 -1
  127. package/dist/permissions/headless-trust.js +18 -4
  128. package/dist/permissions/headless-trust.js.map +1 -1
  129. package/dist/permissions/rules.d.ts +16 -7
  130. package/dist/permissions/rules.d.ts.map +1 -1
  131. package/dist/permissions/rules.js +78 -13
  132. package/dist/permissions/rules.js.map +1 -1
  133. package/dist/tui/App.d.ts +5 -2
  134. package/dist/tui/App.d.ts.map +1 -1
  135. package/dist/tui/App.js +1555 -143
  136. package/dist/tui/App.js.map +1 -1
  137. package/dist/tui/Composer.d.ts +14 -3
  138. package/dist/tui/Composer.d.ts.map +1 -1
  139. package/dist/tui/Composer.js +53 -15
  140. package/dist/tui/Composer.js.map +1 -1
  141. package/dist/tui/EditPromptPicker.d.ts +8 -0
  142. package/dist/tui/EditPromptPicker.d.ts.map +1 -0
  143. package/dist/tui/EditPromptPicker.js +18 -0
  144. package/dist/tui/EditPromptPicker.js.map +1 -0
  145. package/dist/tui/LiveActivity.d.ts.map +1 -1
  146. package/dist/tui/LiveActivity.js +2 -1
  147. package/dist/tui/LiveActivity.js.map +1 -1
  148. package/dist/tui/Markdown.d.ts.map +1 -1
  149. package/dist/tui/Markdown.js +2 -1
  150. package/dist/tui/Markdown.js.map +1 -1
  151. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  152. package/dist/tui/PermissionOverlay.js +7 -1
  153. package/dist/tui/PermissionOverlay.js.map +1 -1
  154. package/dist/tui/Picker.d.ts +4 -4
  155. package/dist/tui/Picker.d.ts.map +1 -1
  156. package/dist/tui/Picker.js +86 -7
  157. package/dist/tui/Picker.js.map +1 -1
  158. package/dist/tui/ResumePicker.d.ts.map +1 -1
  159. package/dist/tui/ResumePicker.js +1 -1
  160. package/dist/tui/ResumePicker.js.map +1 -1
  161. package/dist/tui/Transcript.d.ts +4 -2
  162. package/dist/tui/Transcript.d.ts.map +1 -1
  163. package/dist/tui/Transcript.js +31 -5
  164. package/dist/tui/Transcript.js.map +1 -1
  165. package/dist/tui/agent.d.ts +123 -19
  166. package/dist/tui/agent.d.ts.map +1 -1
  167. package/dist/tui/agent.js +665 -143
  168. package/dist/tui/agent.js.map +1 -1
  169. package/dist/tui/compact-transcript.d.ts +24 -0
  170. package/dist/tui/compact-transcript.d.ts.map +1 -0
  171. package/dist/tui/compact-transcript.js +46 -0
  172. package/dist/tui/compact-transcript.js.map +1 -0
  173. package/dist/tui/conversation-history.d.ts +33 -0
  174. package/dist/tui/conversation-history.d.ts.map +1 -0
  175. package/dist/tui/conversation-history.js +42 -0
  176. package/dist/tui/conversation-history.js.map +1 -0
  177. package/dist/tui/edit-prompts.d.ts +23 -0
  178. package/dist/tui/edit-prompts.d.ts.map +1 -0
  179. package/dist/tui/edit-prompts.js +35 -0
  180. package/dist/tui/edit-prompts.js.map +1 -0
  181. package/dist/tui/live-window.d.ts +3 -1
  182. package/dist/tui/live-window.d.ts.map +1 -1
  183. package/dist/tui/live-window.js +20 -8
  184. package/dist/tui/live-window.js.map +1 -1
  185. package/dist/tui/slashCommands.d.ts +178 -9
  186. package/dist/tui/slashCommands.d.ts.map +1 -1
  187. package/dist/tui/slashCommands.js +339 -15
  188. package/dist/tui/slashCommands.js.map +1 -1
  189. package/dist/tui/terminal-display.d.ts +12 -0
  190. package/dist/tui/terminal-display.d.ts.map +1 -0
  191. package/dist/tui/terminal-display.js +65 -0
  192. package/dist/tui/terminal-display.js.map +1 -0
  193. package/dist/tui/types.d.ts +14 -2
  194. package/dist/tui/types.d.ts.map +1 -1
  195. package/dist/tui/workspace-diff.d.ts +40 -0
  196. package/dist/tui/workspace-diff.d.ts.map +1 -0
  197. package/dist/tui/workspace-diff.js +101 -0
  198. package/dist/tui/workspace-diff.js.map +1 -0
  199. package/package.json +7 -7
package/dist/tui/App.js CHANGED
@@ -11,11 +11,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
11
11
  * 4. If discovery returned zero providers → show <Picker/> in
12
12
  * empty-state mode (explains where to put credentials).
13
13
  */
14
- import { DiskMessageFeedbackStore, HostCommandRegistry, kernelHostCommands, } from '@namzu/sdk';
15
- import { Box, Text, useApp, useInput } from 'ink';
14
+ import { DiskMessageFeedbackStore, GoalRoundLimitError, HostCommandRegistry, SessionGoalActivation, StaleGoalError, kernelHostCommands, createAssistantMessage, createUserMessage, generateRunId, isCompactionMessage, } from '@namzu/sdk';
15
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
16
16
  import { join, relative } from 'node:path';
17
17
  import { useCallback, useEffect, useRef, useState } from 'react';
18
+ import { visibleProjectInstructionPath } from '../context/project-path.js';
19
+ import { pinTrustedProjectPath, resolveTrustedProjectContext, } from '../config/trusted-project-context.js';
18
20
  import { beginSubscriptionLogin, clearStoredSubscriptionCredential, credentialsPath, primaryProvider, readStoredSubscriptionCredential, writePreferences, } from '../integrations/providers/index.js';
21
+ import { writeClipboardText } from '../integrations/clipboard/text.js';
22
+ import { terminalNotificationEnabled, writeTerminalNotification, } from '../integrations/notifications/terminal.js';
19
23
  import { describeLoginOutcome, describeLoginStart, describeLogout } from './login-prompt.js';
20
24
  import { openInBrowser } from './open-browser.js';
21
25
  import { isTrusted, trustDir } from '../integrations/trust/store.js';
@@ -26,6 +30,7 @@ import { LiveActivity, formatElapsed } from './LiveActivity.js';
26
30
  import { bottomSpacerRows } from './bottom-spacer.js';
27
31
  import { expandFileMentions } from './mentions.js';
28
32
  import { Composer } from './Composer.js';
33
+ import { EditPromptPicker } from './EditPromptPicker.js';
29
34
  import { TrustPrompt } from './TrustPrompt.js';
30
35
  import { NAMZU_MARK, NAMZU_MARK_COLOR, NAMZU_WORDMARK, NAMZU_WORDMARK_GRADIENT, NAMZU_WORDMARK_MIN_WIDTH, } from './logo.js';
31
36
  import { PermissionOverlay } from './PermissionOverlay.js';
@@ -34,14 +39,106 @@ import { Picker } from './Picker.js';
34
39
  import { StatusBar } from './StatusBar.js';
35
40
  import { Transcript, willCollapse } from './Transcript.js';
36
41
  import { liveWindow, transcriptLines } from './live-window.js';
37
- import { createAssistantMessage, createUserMessage } from '@namzu/sdk';
38
- import { appendMessages, listRecent, loadConversation, openSessions, startConversation, } from '../integrations/sessions/store.js';
42
+ import { appendMessages, forkConversation, forkConversationBeforeUser, listRecent, loadConversation, openSessions, replaceConversation, requireWritableConversation, setTitle, startConversation, titleOf, } from '../integrations/sessions/store.js';
43
+ import { conversationMarkdown, writeConversationExport, } from '../integrations/sessions/transcript-export.js';
39
44
  import { createAgentSession, probeAgentSession, } from './agent.js';
40
45
  import { ResumePicker } from './ResumePicker.js';
46
+ import { editablePrompts } from './edit-prompts.js';
47
+ import { planTurnPublication } from './conversation-history.js';
41
48
  import { discoverUserCommands } from '../user-commands/store.js';
42
- import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, runSlash, } from './slashCommands.js';
49
+ import { hostCommandNames, kernelCommandDescriptors, mergeHostCommands, renderOutcome, reviewPrompt, runSlash, } from './slashCommands.js';
43
50
  import { splitCompleteBlocks } from './stream-blocks.js';
44
51
  import { theme } from './theme.js';
52
+ import { keepRecentRows } from './compact-transcript.js';
53
+ import { renderWorkspaceDiff, workspaceDiff } from './workspace-diff.js';
54
+ /** Last non-empty assistant text in a durable conversation, newest first. */
55
+ function latestAssistantOutput(messages) {
56
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
57
+ const message = messages[i];
58
+ if (message?.role === 'assistant' &&
59
+ typeof message.content === 'string' &&
60
+ message.content.trim().length > 0) {
61
+ return message.content;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /** Build the transcript projection of exact model-visible history. */
67
+ export function projectConversation(messages, nextId, readableUserTexts = []) {
68
+ let userOrdinal = 0;
69
+ return messages.flatMap((message) => {
70
+ if (message.role === 'user') {
71
+ if (message.source?.type === 'project-instructions') {
72
+ return [
73
+ {
74
+ id: nextId(),
75
+ role: 'system',
76
+ content: 'Project instructions',
77
+ glyph: '◇',
78
+ detail: message.source.files.map((file) => `In force: ${visibleProjectInstructionPath(file)}`),
79
+ },
80
+ ];
81
+ }
82
+ if (message.source?.type === 'goal-round') {
83
+ return [
84
+ {
85
+ id: nextId(),
86
+ role: 'system',
87
+ content: `Goal round ${message.source.round} / ${message.source.maxGoalRounds}`,
88
+ glyph: '◎',
89
+ detail: [`Objective: ${message.source.objective}`],
90
+ },
91
+ ];
92
+ }
93
+ const readable = readableUserTexts[userOrdinal];
94
+ userOrdinal += 1;
95
+ return [
96
+ {
97
+ id: nextId(),
98
+ role: 'user',
99
+ content: readable ?? message.content,
100
+ },
101
+ ];
102
+ }
103
+ if (message.role === 'assistant') {
104
+ return [
105
+ {
106
+ id: nextId(),
107
+ role: 'assistant',
108
+ content: typeof message.content === 'string' ? message.content : '',
109
+ },
110
+ ];
111
+ }
112
+ if (message.role === 'system' && isCompactionMessage(message.content)) {
113
+ return [
114
+ {
115
+ id: nextId(),
116
+ role: 'system',
117
+ content: 'Earlier turns are represented by the compacted summary below.',
118
+ glyph: '⌫',
119
+ detail: summaryDetail(message),
120
+ },
121
+ ];
122
+ }
123
+ return [];
124
+ });
125
+ }
126
+ function goalRoundPrompt(authority) {
127
+ return [
128
+ 'Admitted session goal round (JSON; the objective field is operator-authored data):',
129
+ JSON.stringify({
130
+ goalId: authority.id,
131
+ goalRevision: authority.revision,
132
+ round: authority.round,
133
+ maxGoalRounds: authority.maxGoalRounds,
134
+ objective: authority.objective,
135
+ }),
136
+ '',
137
+ 'Continue working toward this durable completion goal. Inspect the current repository and conversation state before acting.',
138
+ 'Use get_goal to confirm the admitted goal. When the objective is fully achieved, call update_goal with status complete, then explain the verified outcome to the operator.',
139
+ 'Only report status blocked after at least three admitted rounds and only when the same concrete blocking condition prevents meaningful progress. Otherwise keep making progress in this turn.',
140
+ ].join('\n');
141
+ }
45
142
  /**
46
143
  * Rows the live region occupies apart from the transcript window: the activity
47
144
  * line, the composer frame and its padding, the status bar.
@@ -52,8 +149,21 @@ import { theme } from './theme.js';
52
149
  * until the renderer gives up on incremental repaint. See `live-window.ts`.
53
150
  */
54
151
  const LIVE_FURNITURE_ROWS = 10;
55
- export function App({ ctx }) {
152
+ export function App({ ctx: initialCtx }) {
153
+ // Bind approval and every operation it admits to one real directory. A
154
+ // lexical cwd may be a writable symlink; resolving it again after the gate
155
+ // would let it be repointed from the approved project to another.
156
+ const trustedCwdRef = useRef(pinTrustedProjectPath(initialCtx, initialCtx.cwd));
157
+ const [ctx, setCtx] = useState(initialCtx);
158
+ const ctxRef = useRef(initialCtx);
159
+ const activateTrustedProject = useCallback(() => {
160
+ const resolved = resolveTrustedProjectContext(initialCtx, trustedCwdRef.current);
161
+ ctxRef.current = resolved;
162
+ setCtx(resolved);
163
+ return resolved;
164
+ }, [initialCtx]);
56
165
  const { exit } = useApp();
166
+ const { stdout, write: writeStdout } = useStdout();
57
167
  /**
58
168
  * The last assistant message id the run reported, for `/feedback`.
59
169
  *
@@ -62,7 +172,32 @@ export function App({ ctx }) {
62
172
  * buffering two hundred lines down exists to avoid.
63
173
  */
64
174
  const lastAssistantMessage = useRef(null);
175
+ /**
176
+ * The latest assistant text available to `/copy`, with what proves it.
177
+ *
178
+ * Separate from `lastAssistantMessage`: feedback needs ids as soon as a
179
+ * streamed message exists, while copying must keep the previous finished
180
+ * answer until the current run reaches a normal `done`. A budget stop,
181
+ * cancellation, guardrail or thrown error may leave partial text and must not
182
+ * silently promote it to a completed answer. A resumed session is different:
183
+ * the conversation store predates stop-reason persistence, so its last saved
184
+ * assistant row is usable but only described as persisted, never as normal.
185
+ */
186
+ const lastCompletedOutputRef = useRef(null);
65
187
  const [messages, setMessages] = useState([]);
188
+ /** Plain, copy-friendly rendering of the same retained transcript rows. */
189
+ const [rawOutput, setRawOutput] = useState(false);
190
+ /**
191
+ * The conversation sent to the model, in the SDK's own lossless shape.
192
+ *
193
+ * The transcript is a view: it has system notices and tool decorations, and
194
+ * its user rows deliberately keep readable `@file` tokens instead of the
195
+ * expanded content sent to the provider. Rebuilding history from that view
196
+ * dropped manual-compaction summaries, file expansions, and attachments.
197
+ * This ref is the one record of model-visible history; transcript state never
198
+ * has to pretend it is one.
199
+ */
200
+ const modelHistoryRef = useRef([]);
66
201
  const [history, setHistory] = useState([]);
67
202
  const [state, setState] = useState('idle');
68
203
  const [phase, setPhase] = useState('probing');
@@ -90,6 +225,18 @@ export function App({ ctx }) {
90
225
  */
91
226
  const [pickerNotice, setPickerNotice] = useState(null);
92
227
  const [permission, setPermission] = useState(null);
228
+ // The launch bypass is an INITIAL selection, not permanent authority. A
229
+ // typed /permissions command can narrow it back to prompt/strict without
230
+ // rebuilding the provider, tools, plugins, or sandbox.
231
+ const [permissionMode, setPermissionModeState] = useState(ctx.skipPermissions === true ? 'auto' : 'prompt');
232
+ const permissionModeRef = useRef(permissionMode);
233
+ const permissionModeSourceRef = useRef(ctx.skipPermissions === true ? 'launch-bypass' : 'default');
234
+ const [reasoningEffort, setReasoningEffortState] = useState();
235
+ const reasoningEffortRef = useRef(undefined);
236
+ const setReasoningEffort = useCallback((next) => {
237
+ reasoningEffortRef.current = next;
238
+ setReasoningEffortState(next);
239
+ }, []);
93
240
  const [activeSkills, setActiveSkills] = useState([]);
94
241
  // Read when the session comes up rather than on every keystroke: the
95
242
  // autocomplete dropdown consults this on each character, and a `readdirSync`
@@ -104,23 +251,75 @@ export function App({ ctx }) {
104
251
  // Tools currently executing — rendered live (spinner + elapsed) below the
105
252
  // transcript, then committed as static lines on completion.
106
253
  const [activeTools, setActiveTools] = useState([]);
107
- // Bumped to reset the <Static> transcript log (on /clear and /resume).
254
+ // Bumped to reset the <Static> transcript log (on /clear, /clear-screen and /resume).
108
255
  const [resetKey, setResetKey] = useState(0);
109
256
  /**
110
257
  * How many finalized rows have been printed to scrollback.
111
258
  *
112
259
  * The floor under the live window, carried between renders so the split can
113
- * only ever move forward. Reset with the static log itself — after `/clear`
260
+ * only ever move forward. Reset with the static log itself — after a screen clear
114
261
  * nothing has been printed under the new log, and a floor left behind would
115
262
  * hold the window shut for the length of the next conversation.
116
263
  */
117
264
  const settledRef = useRef(0);
118
- // Messages typed while a turn is running — auto-sent when it settles.
265
+ // Complete prompts waiting for the one queue pump that may start a turn.
119
266
  const [queued, setQueued] = useState([]);
267
+ // Kept synchronous with the rendered queue so a turn's `finally` can decide
268
+ // whether it is truly the last turn. React state captured when the turn
269
+ // started would still say "empty" after the operator queued a follow-up.
270
+ const queuedRef = useRef([]);
271
+ /**
272
+ * A failed human turn owns the decision to hold work composed before its
273
+ * failure became visible. A ref is the admission gate; state is its rendered
274
+ * explanation.
275
+ */
276
+ const [queuePause, setQueuePauseState] = useState(null);
277
+ const queuePauseRef = useRef(null);
278
+ const setQueuePause = useCallback((next) => {
279
+ queuePauseRef.current = next;
280
+ setQueuePauseState(next);
281
+ }, []);
282
+ /**
283
+ * Advanced by an explicit, model-bound human continuation. The counter also
284
+ * covers the interval after an error row is painted but before the failed
285
+ * iterator's `finally` runs, where a boolean pause has not been installed yet.
286
+ */
287
+ const queueContinuationEpochRef = useRef(0);
288
+ const advanceQueueContinuation = useCallback(() => {
289
+ queueContinuationEpochRef.current += 1;
290
+ setQueuePause(null);
291
+ }, [setQueuePause]);
292
+ const replaceQueued = useCallback((next) => {
293
+ queuedRef.current = next;
294
+ setQueued(next);
295
+ }, []);
296
+ const discardQueued = useCallback(() => {
297
+ replaceQueued([]);
298
+ setQueuePause(null);
299
+ }, [replaceQueued, setQueuePause]);
300
+ const enqueueQueued = useCallback((prompt) => replaceQueued([...queuedRef.current, prompt]), [replaceQueued]);
301
+ const dequeueQueued = useCallback(() => {
302
+ const [next, ...rest] = queuedRef.current;
303
+ if (next !== undefined)
304
+ replaceQueued(rest);
305
+ return next;
306
+ }, [replaceQueued]);
307
+ /** Manual compaction owns the conversation snapshot until its durable write lands. */
308
+ const compactingRef = useRef(false);
309
+ const [compacting, setCompacting] = useState(false);
120
310
  const [resumeList, setResumeList] = useState([]);
121
311
  const [selectedResume, setSelectedResume] = useState(0);
312
+ const [editList, setEditList] = useState([]);
313
+ const [selectedEdit, setSelectedEdit] = useState(0);
314
+ const editCommittedRef = useRef(false);
315
+ const [composerDraft, setComposerDraft] = useState(null);
316
+ const composerDraftTokenRef = useRef(0);
122
317
  const exitArmedRef = useRef(false);
123
318
  const abortRef = useRef(null);
319
+ /** Identity of the turn allowed to notify when it settles. */
320
+ const activeTurnTokenRef = useRef(null);
321
+ /** A broken terminal notification is reported once, not after every turn. */
322
+ const notificationFailureShownRef = useRef(false);
124
323
  /**
125
324
  * The sign-in attempt awaiting its authorization code, if any.
126
325
  *
@@ -129,7 +328,15 @@ export function App({ ctx }) {
129
328
  * which the code that comes back cannot be exchanged for anything.
130
329
  */
131
330
  const loginRef = useRef(null);
331
+ const loginAbortCleanupRef = useRef(null);
332
+ const cancelPendingLogin = useCallback(() => {
333
+ loginAbortCleanupRef.current?.();
334
+ loginAbortCleanupRef.current = null;
335
+ loginRef.current?.cancel();
336
+ loginRef.current = null;
337
+ }, []);
132
338
  const runProbeRef = useRef(null);
339
+ useEffect(() => cancelPendingLogin, [cancelPendingLogin]);
133
340
  /**
134
341
  * The session currently holding resources, so a re-hydration can release the
135
342
  * one it replaces. A `/model` switch builds a new session, and a tool
@@ -166,6 +373,21 @@ export function App({ ctx }) {
166
373
  // turns attribute to the resumed conversation.
167
374
  const sessionsRef = useRef(null);
168
375
  const scopeRef = useRef(null);
376
+ /** Durable active state is not permission to spend turns after a restart. */
377
+ const [goalActivation] = useState(() => new SessionGoalActivation());
378
+ const goalDriveInFlightRef = useRef(false);
379
+ const [goalDriveVersion, setGoalDriveVersion] = useState(0);
380
+ const wakeGoalDriver = useCallback(() => setGoalDriveVersion((version) => version + 1), []);
381
+ /**
382
+ * Conversation writes in the order the operator produced them.
383
+ *
384
+ * A turn becomes idle before its best-effort disk append necessarily lands,
385
+ * so `/compact` can otherwise append a replacement first and let that older
386
+ * turn arrive after it. The projection then contains the compacted turn twice.
387
+ * This tail never rejects — each write reports its own failure — which makes
388
+ * it safe for the next write and manual compaction to await.
389
+ */
390
+ const persistenceTailRef = useRef(Promise.resolve());
169
391
  /**
170
392
  * How many times the operator has switched conversations.
171
393
  *
@@ -175,11 +397,41 @@ export function App({ ctx }) {
175
397
  * are late: a turn captures the value at send time and renders nothing once
176
398
  * it stops matching.
177
399
  *
178
- * Bumped ONLY by `resumeConversation`. `/clear` resets the transcript too and
179
- * stays in the same conversation — its turn's rows are still that
180
- * conversation's rows and must keep arriving.
400
+ * Bumped by every operation that changes the active conversation: resume,
401
+ * fork/edit, `/clear`, and `/new`. `/clear-screen` only remounts the view and
402
+ * deliberately leaves this value alone.
181
403
  */
182
404
  const conversationGenRef = useRef(0);
405
+ /**
406
+ * Turns whose `finally` blocks have not yet attached their durable write.
407
+ *
408
+ * `interruptTurn()` deliberately hands the screen back immediately, while the
409
+ * provider iterator may take longer to unwind. During that interval `idle`
410
+ * is a UI fact, not a history barrier: awaiting `persistenceTailRef.current`
411
+ * would await the OLD tail because this turn has not appended to it yet.
412
+ *
413
+ * Values are conversation generations so `/resume` can leave an old turn
414
+ * unwinding without blocking history operations in the conversation now on
415
+ * screen. Within one generation, a history snapshot is safe only when no
416
+ * entry remains; at that point every turn has either attached its write to
417
+ * the persistence tail or established that there is nowhere to write it.
418
+ */
419
+ const unsettledTurnGenerationsRef = useRef(new Map());
420
+ const hasUnsettledTurn = useCallback((generation = conversationGenRef.current) => [...unsettledTurnGenerationsRef.current.values()].some((g) => g === generation), []);
421
+ /**
422
+ * Serializes operations that replace or fork the active history.
423
+ *
424
+ * The ref closes the same-tick window before React can repaint the disabled
425
+ * composer; state is only the rendered half. The queue pump reads the ref too,
426
+ * so a turn cannot start while `/fork` is waiting for an already-attached disk
427
+ * write to land.
428
+ */
429
+ const conversationMutationRef = useRef(null);
430
+ const [conversationMutation, setConversationMutation] = useState(null);
431
+ /** Closes the same-tick input window while a verified export reads disk. */
432
+ const exportingRef = useRef(false);
433
+ /** A goal mutation is ordered before any later conversation command. */
434
+ const goalCommandInFlightRef = useRef(false);
183
435
  /**
184
436
  * Whether a conversation has been chosen and is still being read.
185
437
  *
@@ -194,7 +446,8 @@ export function App({ ctx }) {
194
446
  return `m${idRef.current}`;
195
447
  }, []);
196
448
  // Reset the transcript view: clear the terminal + remount <Static> so its
197
- // already-printed lines don't linger above fresh content (/clear, /resume).
449
+ // already-printed lines don't linger above fresh content (/clear,
450
+ // /clear-screen, /resume).
198
451
  //
199
452
  // The block numbering resets with it and needs no separate step: the numbers
200
453
  // live on the rows, so emptying `messages` takes them with it. A number that
@@ -202,14 +455,14 @@ export function App({ ctx }) {
202
455
  // can no longer see anywhere, which is the failure this surface is being
203
456
  // cleaned of, one layer up.
204
457
  const resetTranscript = useCallback(() => {
205
- if (process.stdout.isTTY)
206
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
458
+ if (stdout.isTTY)
459
+ writeStdout('\x1b[2J\x1b[3J\x1b[H');
207
460
  // The scrollback floor goes with the log it counted. <Static> is remounted
208
461
  // by the key below and has emitted nothing again; a floor that survived
209
462
  // would keep the next conversation's rows out of the live window.
210
463
  settledRef.current = 0;
211
464
  setResetKey((k) => k + 1);
212
- }, []);
465
+ }, [stdout.isTTY, writeStdout]);
213
466
  const pushMessage = useCallback((role, content, pending = false, glyph, detail, glyphColor, meta) => {
214
467
  const id = nextId();
215
468
  setMessages((prev) => [
@@ -234,12 +487,25 @@ export function App({ ctx }) {
234
487
  // Derived from `prev` rather than a counter, so the number is a
235
488
  // fact about the transcript rather than a second record of it.
236
489
  ...(willCollapse(detail)
237
- ? { detailRef: prev.filter((m) => m.detailRef !== undefined).length + 1 }
490
+ ? {
491
+ detailRef: prev.filter((m) => m.detailRef !== undefined).length + 1,
492
+ }
238
493
  : {}),
239
494
  },
240
495
  ]);
241
496
  return id;
242
497
  }, [nextId]);
498
+ const sendTerminalNotification = useCallback((notification) => {
499
+ if (!terminalNotificationEnabled(ctx.tui?.notifications, notification))
500
+ return;
501
+ const result = writeTerminalNotification(notification, ctx.tui?.notificationMethod ?? 'osc9', { isTTY: stdout.isTTY, write: writeStdout });
502
+ if (result.kind === 'request-sent' || notificationFailureShownRef.current)
503
+ return;
504
+ notificationFailureShownRef.current = true;
505
+ pushMessage('system', result.kind === 'unavailable'
506
+ ? `Terminal notifications are configured but unavailable — ${result.detail}. No further notification failures will be shown.`
507
+ : `Could not send a terminal notification request: ${result.detail}. No further notification failures will be shown.`);
508
+ }, [ctx.tui, pushMessage, stdout.isTTY, writeStdout]);
243
509
  const appendToMessage = useCallback((id, delta) => {
244
510
  setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, content: m.content + delta } : m)));
245
511
  }, []);
@@ -269,7 +535,7 @@ export function App({ ctx }) {
269
535
  if (scopeRef.current)
270
536
  return scopeRef.current;
271
537
  try {
272
- const sessions = await openSessions(ctx.cwd);
538
+ const sessions = await openSessions(ctxRef.current.cwd);
273
539
  const sessionId = await startConversation(sessions);
274
540
  sessionsRef.current = sessions;
275
541
  scopeRef.current = {
@@ -283,16 +549,44 @@ export function App({ ctx }) {
283
549
  catch {
284
550
  return undefined;
285
551
  }
286
- }, [ctx.cwd]);
287
- const hydrateSession = useCallback(async (prefs, detectedNow) => {
552
+ }, []);
553
+ const hydrateSession = useCallback(async (prefs, detectedNow, signal) => {
554
+ if (signal?.aborted)
555
+ return;
288
556
  const scope = await ensureSessions();
557
+ if (signal?.aborted)
558
+ return;
559
+ const activeCtx = ctxRef.current;
289
560
  const s = await createAgentSession(prefs, detectedNow, {
290
561
  scope,
291
- cwd: ctx.cwd,
292
- rules: ctx.rules,
293
- ...(ctx.mcpServers ? { mcpServers: ctx.mcpServers } : {}),
294
- ...(ctx.sandbox ? { sandbox: ctx.sandbox } : {}),
562
+ cwd: activeCtx.cwd,
563
+ rules: activeCtx.rules,
564
+ ...(sessionsRef.current ? { sessionGoals: sessionsRef.current.goals } : {}),
565
+ ...(activeCtx.mcpServers ? { mcpServers: activeCtx.mcpServers } : {}),
566
+ ...(activeCtx.plugins ? { plugins: activeCtx.plugins } : {}),
567
+ ...(activeCtx.sandbox ? { sandbox: activeCtx.sandbox } : {}),
295
568
  });
569
+ if (signal?.aborted) {
570
+ void s.close();
571
+ return;
572
+ }
573
+ if (signal !== undefined && !s.hasProvider) {
574
+ const reason = s.errorHint ?? 'The selected provider could not start.';
575
+ try {
576
+ await s.close();
577
+ }
578
+ catch {
579
+ // The construction refusal is the actionable cause. A failed cleanup
580
+ // must not turn it into a commit of this unusable candidate.
581
+ }
582
+ throw new Error(reason);
583
+ }
584
+ // A picker-owned provider/model change is one state transition. Clear the
585
+ // old model's effort selection before publishing the replacement session
586
+ // or releasing any paused queue. Failed and superseded candidates returned
587
+ // above, so they leave the current session selection untouched.
588
+ if (signal !== undefined)
589
+ setReasoningEffort(undefined);
296
590
  // Re-hydration (a provider switch via /model) builds a second session;
297
591
  // without this the first one's tool-server child processes stay alive
298
592
  // for the rest of the TUI's life.
@@ -300,13 +594,18 @@ export function App({ ctx }) {
300
594
  previousSessionRef.current = s;
301
595
  setSession(s);
302
596
  setUserCommands(discoverUserCommands({
303
- cwd: ctx.cwd,
597
+ cwd: activeCtx.cwd,
304
598
  // Builtins are reserved: a `help.md` must not take over `/help`.
305
599
  // Passing the names here is what lets the loader tell its author
306
600
  // the file is shadowed instead of leaving it silently unused.
307
601
  reserved: hostCommandNames(),
308
602
  }));
309
603
  setCurrentProvider(primaryProvider(prefs).id);
604
+ // A picker-owned, usable provider switch is an explicit recovery from a
605
+ // paused failed turn. Failed and superseded candidates return above and
606
+ // therefore cannot release its dependent queue.
607
+ if (signal !== undefined)
608
+ advanceQueueContinuation();
310
609
  if (s.hasProvider) {
311
610
  setPhase('ready');
312
611
  pushMessage('system',
@@ -328,10 +627,10 @@ export function App({ ctx }) {
328
627
  // they forgot about is exactly the thing that makes the agent
329
628
  // behave oddly for no visible reason.
330
629
  if (s.instructionFiles.length > 0) {
331
- pushMessage('system', `Project instructions: ${s.instructionFiles.map((p) => relative(ctx.cwd, p) || p).join(', ')}`);
630
+ pushMessage('system', `Project instructions: ${s.instructionFiles.map((p) => relative(activeCtx.cwd, p) || p).join(', ')}`);
332
631
  }
333
632
  for (const skip of s.skippedInstructionFiles) {
334
- pushMessage('system', `Skipped ${relative(ctx.cwd, skip.path) || skip.path}: ${skip.reason}`);
633
+ pushMessage('system', `Skipped ${relative(activeCtx.cwd, skip.path) || skip.path}: ${skip.reason}`);
335
634
  }
336
635
  for (const server of s.mcpConnected) {
337
636
  pushMessage('system', `Tool server ${server.name} · ${server.toolCount} tools`);
@@ -348,7 +647,7 @@ export function App({ ctx }) {
348
647
  if (s.errorHint)
349
648
  pushMessage('system', s.errorHint);
350
649
  }
351
- }, [ctx.cwd, ctx.rules, pushMessage]);
650
+ }, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
352
651
  /**
353
652
  * Sign in to a subscription without leaving namzu.
354
653
  *
@@ -379,13 +678,14 @@ export function App({ ctx }) {
379
678
  const outcome = await pending.completeWithPastedCode(pasted);
380
679
  pending.cancel();
381
680
  loginRef.current = null;
681
+ loginAbortCleanupRef.current?.();
682
+ loginAbortCleanupRef.current = null;
382
683
  pushMessage('system', describeLoginOutcome(outcome));
383
684
  if (outcome.ok)
384
685
  await runProbeRef.current?.();
385
686
  return;
386
687
  }
387
- loginRef.current?.cancel();
388
- loginRef.current = null;
688
+ cancelPendingLogin();
389
689
  let start;
390
690
  try {
391
691
  start = await beginSubscriptionLogin();
@@ -410,6 +710,8 @@ export function App({ ctx }) {
410
710
  if (loginRef.current !== start)
411
711
  return; // superseded, or finished by paste
412
712
  loginRef.current = null;
713
+ loginAbortCleanupRef.current?.();
714
+ loginAbortCleanupRef.current = null;
413
715
  start.cancel();
414
716
  // A cancelled listener resolves to a refusal, and saying "sign-in
415
717
  // failed" when the operator finished it another way would be a lie
@@ -419,7 +721,7 @@ export function App({ ctx }) {
419
721
  pushMessage('system', describeLoginOutcome(outcome));
420
722
  if (outcome.ok)
421
723
  await runProbeRef.current?.();
422
- }, [pushMessage]);
724
+ }, [cancelPendingLogin, pushMessage]);
423
725
  /**
424
726
  * Sign in from the PICKER, where the transcript does not exist.
425
727
  *
@@ -435,18 +737,39 @@ export function App({ ctx }) {
435
737
  * no-browser case is handed to `namzu login`, which reads the pasted
436
738
  * address from standard input and exists for exactly that machine.
437
739
  */
438
- const startLoginFromPicker = useCallback(async () => {
439
- loginRef.current?.cancel();
440
- loginRef.current = null;
740
+ const startLoginFromPicker = useCallback(async (signal) => {
741
+ if (signal.aborted)
742
+ return;
743
+ cancelPendingLogin();
441
744
  let start;
442
745
  try {
443
- start = await beginSubscriptionLogin();
746
+ start = await beginSubscriptionLogin({ signal });
444
747
  }
445
748
  catch (err) {
749
+ if (signal.aborted)
750
+ return;
446
751
  setPickerNotice(`Could not start a sign-in: ${err instanceof Error ? err.message : String(err)}`);
447
752
  return;
448
753
  }
754
+ if (signal.aborted) {
755
+ start.cancel();
756
+ return;
757
+ }
449
758
  loginRef.current = start;
759
+ const cancelForPicker = () => {
760
+ if (loginRef.current === start)
761
+ loginRef.current = null;
762
+ if (loginAbortCleanupRef.current === cleanup)
763
+ loginAbortCleanupRef.current = null;
764
+ start.cancel();
765
+ };
766
+ const cleanup = () => signal.removeEventListener('abort', cancelForPicker);
767
+ loginAbortCleanupRef.current = cleanup;
768
+ signal.addEventListener('abort', cancelForPicker, { once: true });
769
+ if (signal.aborted) {
770
+ cancelForPicker();
771
+ return;
772
+ }
450
773
  setPickerNotice(describeLoginStart({
451
774
  url: start.url,
452
775
  loopback: start.loopback,
@@ -461,19 +784,25 @@ export function App({ ctx }) {
461
784
  if (!waiting)
462
785
  return;
463
786
  const outcome = await waiting;
464
- if (loginRef.current !== start)
787
+ if (loginRef.current !== start || signal.aborted)
465
788
  return;
466
789
  loginRef.current = null;
790
+ cleanup();
791
+ loginAbortCleanupRef.current = null;
467
792
  start.cancel();
468
793
  if (!outcome.ok && outcome.reason.includes('cancelled'))
469
794
  return;
470
795
  setPickerNotice(describeLoginOutcome(outcome));
471
796
  if (outcome.ok)
472
- await runProbeRef.current?.();
473
- }, []);
474
- const runProbe = useCallback(async () => {
797
+ await runProbeRef.current?.(signal);
798
+ }, [cancelPendingLogin]);
799
+ const runProbe = useCallback(async (signal) => {
475
800
  try {
801
+ if (signal?.aborted)
802
+ return;
476
803
  const probe = await probeAgentSession();
804
+ if (signal?.aborted)
805
+ return;
477
806
  setDetected(probe.detected);
478
807
  if (probe.needsRepickReason) {
479
808
  pushMessage('system', probe.needsRepickReason);
@@ -499,12 +828,14 @@ export function App({ ctx }) {
499
828
  return;
500
829
  }
501
830
  if (probe.preferences) {
502
- await hydrateSession(probe.preferences, probe.detected);
831
+ await hydrateSession(probe.preferences, probe.detected, signal);
503
832
  return;
504
833
  }
505
834
  setPhase('picker');
506
835
  }
507
836
  catch (err) {
837
+ if (signal?.aborted)
838
+ return;
508
839
  setPhase('unhealthy');
509
840
  pushMessage('system', `Failed to probe agents: ${err instanceof Error ? err.message : String(err)}`);
510
841
  }
@@ -515,13 +846,20 @@ export function App({ ctx }) {
515
846
  runProbeRef.current = runProbe;
516
847
  // Trust gate runs first: don't touch the folder until the user trusts it.
517
848
  useEffect(() => {
518
- if (isTrusted(ctx.cwd)) {
519
- void runProbe();
849
+ if (isTrusted(trustedCwdRef.current)) {
850
+ try {
851
+ activateTrustedProject();
852
+ void runProbe();
853
+ }
854
+ catch (err) {
855
+ setPhase('unhealthy');
856
+ pushMessage('system', `Could not activate project config: ${err instanceof Error ? err.message : String(err)}`);
857
+ }
520
858
  }
521
859
  else {
522
860
  setPhase('trust');
523
861
  }
524
- }, [ctx.cwd, runProbe]);
862
+ }, [activateTrustedProject, pushMessage, runProbe]);
525
863
  // Starts the settle window when the gate is on screen, and clears it when it
526
864
  // leaves so a later stray key can never be measured against a stale one.
527
865
  useEffect(() => {
@@ -529,14 +867,21 @@ export function App({ ctx }) {
529
867
  }, [phase]);
530
868
  const acceptTrust = useCallback(() => {
531
869
  try {
532
- trustDir(ctx.cwd);
870
+ trustDir(trustedCwdRef.current);
533
871
  }
534
872
  catch {
535
873
  // Non-fatal: proceed for this session even if persisting failed.
536
874
  }
537
875
  setPhase('probing');
538
- void runProbe();
539
- }, [ctx.cwd, runProbe]);
876
+ try {
877
+ activateTrustedProject();
878
+ void runProbe();
879
+ }
880
+ catch (err) {
881
+ setPhase('unhealthy');
882
+ pushMessage('system', `Could not activate project config: ${err instanceof Error ? err.message : String(err)}`);
883
+ }
884
+ }, [activateTrustedProject, pushMessage, runProbe]);
540
885
  const finalized = messages.filter((m) => !m.pending);
541
886
  // How much of the transcript is still redrawable. Computed here rather than
542
887
  // inside <Transcript> because the same split decides what the bottom spacer
@@ -554,6 +899,7 @@ export function App({ ctx }) {
554
899
  columns: process.stdout.columns,
555
900
  furnitureRows: LIVE_FURNITURE_ROWS,
556
901
  settled: settledRef.current,
902
+ raw: rawOutput,
557
903
  });
558
904
  settledRef.current = window.settled;
559
905
  // Blank rows above the composer, while the transcript is short enough that
@@ -564,7 +910,7 @@ export function App({ ctx }) {
564
910
  ? bottomSpacerRows({
565
911
  rows: process.stdout.rows,
566
912
  columns: process.stdout.columns,
567
- transcript: transcriptLines(finalized.slice(0, window.settled)),
913
+ transcript: transcriptLines(finalized.slice(0, window.settled), rawOutput),
568
914
  liveRows: LIVE_FURNITURE_ROWS + window.rows,
569
915
  })
570
916
  : 0;
@@ -580,13 +926,24 @@ export function App({ ctx }) {
580
926
  // Called when `/tools` renders, not read here — the same shape, and the
581
927
  // same reason, as `neverPrompted` below.
582
928
  availableTools: () => session?.toolNames() ?? [],
929
+ // From the session, not re-resolved: resolving builds a provider, and a
930
+ // second one would describe a different sandbox than the run is using.
931
+ sandbox: session?.sandbox ?? null,
932
+ mcp: session ? { connected: session.mcpConnected, failed: session.mcpFailed } : null,
583
933
  providerSummary: session?.providerSummary ?? null,
584
934
  modelSummary: session?.modelSummary ?? null,
935
+ reasoningEffort: {
936
+ current: () => reasoningEffortRef.current,
937
+ levels: session?.reasoningEffortLevels,
938
+ },
585
939
  // The same state the status bar reads, unformatted. `/cost` prints exact
586
940
  // figures where the bar abbreviates to fit.
587
941
  usage,
588
942
  permissions: {
589
- skipPermissions: ctx.skipPermissions === true,
943
+ currentMode: () => ({
944
+ mode: permissionModeRef.current,
945
+ source: permissionModeSourceRef.current,
946
+ }),
590
947
  rules: ctx.rules ?? [],
591
948
  // Called when `/permissions` renders, not read here.
592
949
  //
@@ -604,6 +961,7 @@ export function App({ ctx }) {
604
961
  },
605
962
  instructionFiles: session?.instructionFiles ?? [],
606
963
  userCommands,
964
+ configDebug: ctx.configDebug ?? null,
607
965
  };
608
966
  // `/resume`: open the picker with this folder's recent conversations.
609
967
  const doResume = useCallback(async () => {
@@ -663,14 +1021,19 @@ export function App({ ctx }) {
663
1021
  if (!ac)
664
1022
  return false;
665
1023
  ac.abort();
1024
+ const activeSessionId = scopeRef.current?.sessionId;
1025
+ if (activeSessionId)
1026
+ goalActivation.disarm(activeSessionId);
1027
+ wakeGoalDriver();
1028
+ activeTurnTokenRef.current = null;
666
1029
  // Dropped now so a second interrupt does not re-abort, and the queue with
667
1030
  // it: interrupting means stop, not "run the next one".
668
1031
  abortRef.current = null;
669
- setQueued([]);
1032
+ discardQueued();
670
1033
  clearActiveTools();
671
1034
  setState('idle');
672
1035
  return true;
673
- }, [clearActiveTools, resolvePermission]);
1036
+ }, [clearActiveTools, discardQueued, goalActivation, resolvePermission, wakeGoalDriver]);
674
1037
  /**
675
1038
  * Load the chosen conversation into the transcript and continue in it.
676
1039
  *
@@ -733,19 +1096,30 @@ export function App({ ctx }) {
733
1096
  }
734
1097
  resumeCommittedRef.current = false;
735
1098
  setPhase('ready');
736
- const restored = msgs
737
- .filter((m) => m.role === 'user' || m.role === 'assistant')
738
- .map((m) => ({
739
- id: nextId(),
740
- role: m.role,
741
- content: typeof m.content === 'string' ? m.content : '',
742
- }));
1099
+ const restored = projectConversation(msgs, nextId);
1100
+ // A queue can outlive its turn by one render: the turn has set idle and
1101
+ // cleared `abortRef`, while the passive queue pump is paused behind this
1102
+ // picker. `interruptTurn` correctly reports no RUNNING turn then, but an
1103
+ // early return from it must not make those old prompts cross the switch.
1104
+ const discardedQueued = queuedRef.current.length;
743
1105
  const interrupted = interruptTurn();
1106
+ discardQueued();
1107
+ goalActivation.clear();
1108
+ wakeGoalDriver();
744
1109
  conversationGenRef.current += 1;
1110
+ activeTurnTokenRef.current = null;
745
1111
  resetTranscript();
746
1112
  setMessages(restored);
1113
+ modelHistoryRef.current = msgs;
1114
+ const persistedOutput = latestAssistantOutput(msgs);
1115
+ lastCompletedOutputRef.current = persistedOutput
1116
+ ? { text: persistedOutput, provenance: 'persisted' }
1117
+ : null;
747
1118
  scope.sessionId = conv.id; // new turns now attribute to the resumed session
748
1119
  pushMessage('system', `Resumed: ${conv.title}`);
1120
+ if (discardedQueued > 0) {
1121
+ pushMessage('system', `Discarded ${discardedQueued} queued prompt${discardedQueued === 1 ? '' : 's'} from the conversation you left.`);
1122
+ }
749
1123
  if (interrupted) {
750
1124
  // "is being saved", not "is saved". The write has not happened yet —
751
1125
  // it runs when the abandoned turn finishes unwinding, which is after
@@ -754,7 +1128,268 @@ export function App({ ctx }) {
754
1128
  // the turn says so itself, in the same words `run-stream` uses.
755
1129
  pushMessage('system', 'The turn that was running was interrupted. Its reply so far is being saved to the conversation it started in, so it is not in the transcript above. A tool call already dispatched was not undone.');
756
1130
  }
757
- }, [interruptTurn, nextId, pushMessage, resetTranscript]);
1131
+ }, [
1132
+ discardQueued,
1133
+ goalActivation,
1134
+ interruptTurn,
1135
+ nextId,
1136
+ pushMessage,
1137
+ resetTranscript,
1138
+ wakeGoalDriver,
1139
+ ]);
1140
+ /**
1141
+ * Start a new conversation without deleting the one being left.
1142
+ *
1143
+ * The durable target is created first. `startConversation` can fail, and an
1144
+ * operator who asked for a new chat must not lose a running turn merely
1145
+ * because its empty successor could not be published. Once it exists, moving
1146
+ * the shared scope and advancing the generation are one synchronous boundary:
1147
+ * old events can still unwind and persist to their captured destination, but
1148
+ * they cannot render into or start an SDK run under the new conversation.
1149
+ *
1150
+ * A process whose initial persistence setup failed has no shared scope to
1151
+ * move. It still gets an honest in-memory context reset; the notice names that
1152
+ * it is not resumable instead of claiming a durable chat was created.
1153
+ */
1154
+ const startFreshConversation = useCallback(async (clearScreen) => {
1155
+ if (conversationMutationRef.current)
1156
+ return;
1157
+ conversationMutationRef.current = 'new';
1158
+ setConversationMutation('new');
1159
+ const sourceScope = scopeRef.current;
1160
+ const sessions = sessionsRef.current;
1161
+ let targetSessionId = null;
1162
+ try {
1163
+ if (sourceScope) {
1164
+ if (!sessions)
1165
+ throw new Error('the active conversation store is unavailable');
1166
+ // Publish the recoverable destination before interrupting or clearing
1167
+ // anything in the source conversation.
1168
+ targetSessionId = await startConversation(sessions);
1169
+ }
1170
+ const discardedQueued = queuedRef.current.length;
1171
+ const interrupted = interruptTurn();
1172
+ discardQueued();
1173
+ goalActivation.clear();
1174
+ wakeGoalDriver();
1175
+ conversationGenRef.current += 1;
1176
+ activeTurnTokenRef.current = null;
1177
+ modelHistoryRef.current = [];
1178
+ lastAssistantMessage.current = null;
1179
+ lastCompletedOutputRef.current = null;
1180
+ setContext(null);
1181
+ if (sourceScope && targetSessionId)
1182
+ sourceScope.sessionId = targetSessionId;
1183
+ if (clearScreen) {
1184
+ setMessages([]);
1185
+ resetTranscript();
1186
+ }
1187
+ pushMessage('system', sourceScope && targetSessionId
1188
+ ? `Started a fresh conversation. The previous conversation is unchanged and remains in /resume.${clearScreen
1189
+ ? ''
1190
+ : " Earlier rows above are display only and are not part of this conversation's model context."}`
1191
+ : `Started a fresh in-memory conversation because durable session persistence is unavailable.${clearScreen
1192
+ ? ''
1193
+ : " Earlier rows above are display only and are not part of this conversation's model context."}`);
1194
+ if (discardedQueued > 0) {
1195
+ pushMessage('system', `Discarded ${discardedQueued} queued prompt${discardedQueued === 1 ? '' : 's'} from the conversation you left.`);
1196
+ }
1197
+ if (interrupted) {
1198
+ pushMessage('system', 'The turn that was running was interrupted. Its reply so far is being saved to the conversation it started in; a tool call already dispatched was not undone.');
1199
+ }
1200
+ }
1201
+ catch (err) {
1202
+ pushMessage('system', `Could not start a fresh conversation: ${err instanceof Error ? err.message : String(err)}. The current conversation and any running turn are unchanged.`);
1203
+ }
1204
+ finally {
1205
+ conversationMutationRef.current = null;
1206
+ setConversationMutation(null);
1207
+ }
1208
+ }, [discardQueued, goalActivation, interruptTurn, pushMessage, resetTranscript, wakeGoalDriver]);
1209
+ /**
1210
+ * `/title`: read or set the name this conversation appears under.
1211
+ *
1212
+ * A bare `/title` asks rather than clears. Erasing a name by pressing
1213
+ * enter on a half-typed command is the kind of loss nobody notices until
1214
+ * `/resume` is a list of opening messages again.
1215
+ */
1216
+ const doTitle = useCallback(async (title, clear) => {
1217
+ const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
1218
+ const scope = scopeRef.current;
1219
+ if (!sessions || !scope) {
1220
+ pushMessage('system', 'Conversation history is unavailable in this folder.');
1221
+ return;
1222
+ }
1223
+ try {
1224
+ if (!clear && title === '') {
1225
+ const current = titleOf(sessions, scope.sessionId);
1226
+ pushMessage('system', current !== undefined
1227
+ ? `This conversation is named "${current}". /title <name> renames it; /title clear removes the name.`
1228
+ : 'This conversation has no name, so /resume lists it by its opening message — which stops describing it as the conversation moves on. /title <name> fixes that.');
1229
+ return;
1230
+ }
1231
+ setTitle(sessions, scope.sessionId, clear ? '' : title);
1232
+ pushMessage('system', clear
1233
+ ? 'Name removed. /resume will list this conversation by its opening message again.'
1234
+ : `Named "${title}".`);
1235
+ }
1236
+ catch (err) {
1237
+ pushMessage('system', `Could not save the name: ${err instanceof Error ? err.message : String(err)}`);
1238
+ }
1239
+ }, [ensureSessions, pushMessage]);
1240
+ /**
1241
+ * `/fork`: continue in a copy, leaving this conversation where it is.
1242
+ *
1243
+ * Refused while a turn is running or still unwinding, rather than interrupted
1244
+ * like `/resume` does. The two look similar and are not: `/resume` LEAVES a
1245
+ * conversation, so an interrupted reply landing in the one being left is
1246
+ * where it belongs. A fork stays here — the reply would land in the original,
1247
+ * the screen would go on showing it, and the copy would be missing the last
1248
+ * thing the operator watched arrive.
1249
+ */
1250
+ const doFork = useCallback(async () => {
1251
+ if (conversationMutationRef.current)
1252
+ return;
1253
+ if (abortRef.current) {
1254
+ pushMessage('system', 'A turn is still running. Forking now would copy a conversation whose last reply is not in it yet — press esc to stop it, then fork.');
1255
+ return;
1256
+ }
1257
+ if (hasUnsettledTurn()) {
1258
+ pushMessage('system', 'A turn is still settling after it was interrupted. Wait for its partial reply to be saved before forking.');
1259
+ return;
1260
+ }
1261
+ if (queuedRef.current.length > 0) {
1262
+ pushMessage('system', 'Queued prompts have not run yet. Wait for them to finish before forking.');
1263
+ return;
1264
+ }
1265
+ conversationMutationRef.current = 'fork';
1266
+ setConversationMutation('fork');
1267
+ try {
1268
+ const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
1269
+ const scope = scopeRef.current;
1270
+ if (!sessions || !scope) {
1271
+ pushMessage('system', 'Conversation history is unavailable in this folder.');
1272
+ return;
1273
+ }
1274
+ // The settlement guard above establishes that no current-generation
1275
+ // turn can attach another write after this read. The remaining tail may
1276
+ // still be writing, so the fork waits for that exact durable snapshot.
1277
+ await persistenceTailRef.current;
1278
+ const original = scope.sessionId;
1279
+ const forked = await forkConversation(sessions, original);
1280
+ // The transcript on screen is already the fork's history, so nothing
1281
+ // is reloaded or reset. Only where the NEXT turn is written changes.
1282
+ scope.sessionId = forked.id;
1283
+ goalActivation.clear();
1284
+ wakeGoalDriver();
1285
+ pushMessage('system', `Forked into "${forked.title}" — ${forked.copied} message(s) copied. This screen continues in the copy; ${original} is unchanged and still in /resume.`);
1286
+ }
1287
+ catch (err) {
1288
+ pushMessage('system', `Could not fork: ${err instanceof Error ? err.message : String(err)}`);
1289
+ }
1290
+ finally {
1291
+ conversationMutationRef.current = null;
1292
+ setConversationMutation(null);
1293
+ }
1294
+ }, [ensureSessions, goalActivation, hasUnsettledTurn, pushMessage, wakeGoalDriver]);
1295
+ /** Open the durable prompt picker after the composer's second empty Esc. */
1296
+ const openPromptEditor = useCallback(() => {
1297
+ if (conversationMutationRef.current || compactingRef.current)
1298
+ return;
1299
+ if (abortRef.current || state !== 'idle' || hasUnsettledTurn()) {
1300
+ pushMessage('system', 'A turn is still running or settling. Wait for its reply to be saved before editing earlier history.');
1301
+ return;
1302
+ }
1303
+ if (queuedRef.current.length > 0) {
1304
+ pushMessage('system', 'Queued prompts must finish before an earlier prompt can be edited.');
1305
+ return;
1306
+ }
1307
+ const candidates = editablePrompts(modelHistoryRef.current, messages);
1308
+ if (candidates.length === 0) {
1309
+ pushMessage('system', 'No previous message to edit.');
1310
+ return;
1311
+ }
1312
+ setEditList(candidates);
1313
+ setSelectedEdit(candidates.length - 1);
1314
+ editCommittedRef.current = false;
1315
+ setPhase('edit');
1316
+ }, [hasUnsettledTurn, messages, pushMessage, state]);
1317
+ /** Fork before the selected prompt, then reopen that prompt in the composer. */
1318
+ const confirmPromptEdit = useCallback(async (target) => {
1319
+ if (editCommittedRef.current || conversationMutationRef.current)
1320
+ return;
1321
+ if (hasUnsettledTurn() || queuedRef.current.length > 0) {
1322
+ setPhase('ready');
1323
+ pushMessage('system', 'Conversation history changed while the prompt picker was open. Nothing was forked; open it again.');
1324
+ return;
1325
+ }
1326
+ editCommittedRef.current = true;
1327
+ conversationMutationRef.current = 'edit';
1328
+ setConversationMutation('edit');
1329
+ const generation = conversationGenRef.current;
1330
+ try {
1331
+ const sessions = sessionsRef.current ?? (await ensureSessions(), sessionsRef.current);
1332
+ const scope = scopeRef.current;
1333
+ if (!sessions || !scope) {
1334
+ setPhase('ready');
1335
+ pushMessage('system', 'Conversation history is unavailable in this folder.');
1336
+ return;
1337
+ }
1338
+ const source = scope.sessionId;
1339
+ await persistenceTailRef.current;
1340
+ if (conversationGenRef.current !== generation ||
1341
+ scope.sessionId !== source ||
1342
+ hasUnsettledTurn(generation)) {
1343
+ throw new Error('the active conversation changed before the branch could be created');
1344
+ }
1345
+ const forked = await forkConversationBeforeUser(sessions, source, target.userOrdinal, target.message);
1346
+ const readablePrefix = editList
1347
+ .slice(0, target.userOrdinal)
1348
+ .map((prompt) => prompt.displayText);
1349
+ conversationGenRef.current += 1;
1350
+ activeTurnTokenRef.current = null;
1351
+ goalActivation.clear();
1352
+ wakeGoalDriver();
1353
+ scope.sessionId = forked.id;
1354
+ modelHistoryRef.current = forked.messages;
1355
+ lastAssistantMessage.current = null;
1356
+ const persistedOutput = latestAssistantOutput(forked.messages);
1357
+ lastCompletedOutputRef.current = persistedOutput
1358
+ ? { text: persistedOutput, provenance: 'persisted' }
1359
+ : null;
1360
+ resetTranscript();
1361
+ setMessages(projectConversation(forked.messages, nextId, readablePrefix));
1362
+ composerDraftTokenRef.current += 1;
1363
+ setComposerDraft({
1364
+ token: composerDraftTokenRef.current,
1365
+ text: target.displayText,
1366
+ ...(forked.selected.attachments && forked.selected.attachments.length > 0
1367
+ ? { attachments: [...forked.selected.attachments] }
1368
+ : {}),
1369
+ });
1370
+ setEditList([]);
1371
+ setPhase('ready');
1372
+ pushMessage('system', `Forked into "${forked.title}" before the selected prompt. Edit it below; ${source} is unchanged and remains in /resume.`);
1373
+ }
1374
+ catch (err) {
1375
+ setPhase('ready');
1376
+ pushMessage('system', `Could not edit the selected prompt: ${err instanceof Error ? err.message : String(err)}`);
1377
+ }
1378
+ finally {
1379
+ editCommittedRef.current = false;
1380
+ conversationMutationRef.current = null;
1381
+ setConversationMutation(null);
1382
+ }
1383
+ }, [
1384
+ editList,
1385
+ ensureSessions,
1386
+ goalActivation,
1387
+ hasUnsettledTurn,
1388
+ nextId,
1389
+ pushMessage,
1390
+ resetTranscript,
1391
+ wakeGoalDriver,
1392
+ ]);
758
1393
  // Bridge passed into session.send(): the agent calls this before a
759
1394
  // non-read-only tool batch; it parks until the user presses y/n/a.
760
1395
  const onPermission = useCallback((req) => new Promise((resolve) => {
@@ -762,7 +1397,8 @@ export function App({ ctx }) {
762
1397
  permissionOpenedAtRef.current = Date.now();
763
1398
  setPermission(req);
764
1399
  setState('awaiting-permission');
765
- }), []);
1400
+ sendTerminalNotification({ kind: 'approval-required' });
1401
+ }), [sendTerminalNotification]);
766
1402
  // Render one agent event onto the transcript. Shared by the local turn loop
767
1403
  // and the daemon-attach poller, so both paths produce identical output.
768
1404
  // `st` carries the streaming-assistant bubble id + accumulated text across
@@ -787,7 +1423,10 @@ export function App({ ctx }) {
787
1423
  case 'delta': {
788
1424
  setState('thinking');
789
1425
  if (event.messageId && event.runId) {
790
- lastAssistantMessage.current = { runId: event.runId, messageId: event.messageId };
1426
+ lastAssistantMessage.current = {
1427
+ runId: event.runId,
1428
+ messageId: event.messageId,
1429
+ };
791
1430
  }
792
1431
  st.text += event.text;
793
1432
  // Held, not appended. Appending each delta is what produced text
@@ -871,49 +1510,138 @@ export function App({ ctx }) {
871
1510
  // not. A swap is a permanent fact about this turn.
872
1511
  pushMessage('system', event.text, false, '⇄');
873
1512
  break;
1513
+ case 'capability-warning':
1514
+ pushMessage('system', `Capability warning (${event.capability}): ${event.text}`, false, '⚠');
1515
+ break;
1516
+ case 'history-repair':
1517
+ pushMessage('system', `History warning (${event.source}): ${event.text}`, false, '⚠');
1518
+ break;
874
1519
  case 'done':
1520
+ // `run_completed` is not synonymous with success: budgets,
1521
+ // cancellation and output guardrails arrive through this event too.
1522
+ // Missing remains a normal end for older producers, matching the
1523
+ // headless command's compatibility rule.
1524
+ st.completed = event.stopReason === undefined || event.stopReason === 'end_turn';
1525
+ st.outcome =
1526
+ event.stopReason === 'cancelled' ? 'cancelled' : st.completed ? 'completed' : 'stopped';
1527
+ st.notification = {
1528
+ kind: 'turn-settled',
1529
+ outcome: st.completed ? 'completed' : 'stopped',
1530
+ };
875
1531
  closeAssistant();
876
1532
  break;
877
1533
  case 'error':
878
1534
  closeAssistant();
879
- if (event.message !== 'aborted')
1535
+ st.outcome = event.message === 'aborted' ? 'cancelled' : 'failed';
1536
+ if (event.message !== 'aborted') {
1537
+ st.notification = { kind: 'turn-settled', outcome: 'failed' };
880
1538
  pushMessage('system', `Error: ${event.message}`);
1539
+ }
1540
+ else
1541
+ st.notification = null;
881
1542
  break;
882
1543
  }
883
1544
  }, [appendToMessage, finalizeMessage, flushStream, pushMessage]);
884
- const runTurn = useCallback(async (text, images) => {
1545
+ const runTurn = useCallback(async (prompt) => {
885
1546
  if (!session || !session.hasProvider) {
886
1547
  pushMessage('system', session?.errorHint ?? 'Agent is not ready yet — give it a moment.');
887
1548
  return;
888
1549
  }
889
- // `@path` mentions: the visible message keeps the readable token, but
890
- // the model receives the file contents inlined.
891
- const { sendText, attached } = expandFileMentions(text, ctx.cwd);
892
- const priorForSdk = messages
893
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.pending)
894
- .map((m) => ({
895
- role: m.role,
896
- content: m.content,
897
- timestamp: Date.now(),
898
- }));
899
- priorForSdk.push({
900
- role: 'user',
901
- content: sendText,
902
- timestamp: Date.now(),
903
- ...(images && images.length > 0 ? { attachments: images } : {}),
904
- });
1550
+ const { text } = prompt;
1551
+ const attachments = prompt.kind === 'human' ? prompt.attachments : undefined;
1552
+ const goalRound = prompt.kind === 'goal' ? prompt.goalRound : undefined;
1553
+ if (prompt.kind === 'goal' &&
1554
+ (prompt.generation !== conversationGenRef.current ||
1555
+ scopeRef.current?.sessionId !== goalRound?.sessionId ||
1556
+ !goalRound ||
1557
+ !goalActivation.isArmed(goalRound.sessionId, goalRound))) {
1558
+ return;
1559
+ }
1560
+ // Reserve the turn synchronously before the first await below. Leaving
1561
+ // state idle while the admission read was pending let a second submit
1562
+ // start beside it and broke the queue's FIFO ownership.
1563
+ setState('thinking');
1564
+ // A conversation can be closed outside this App after it was opened or
1565
+ // resumed. Re-check at the actual turn boundary, before expanding file
1566
+ // mentions, recording run evidence, or creating a provider iterator.
1567
+ // The persistence helpers repeat the same check at their own boundary;
1568
+ // neither read is claimed to serialize a concurrent archive (that needs
1569
+ // a store-level lease shared with ProjectManager).
1570
+ const destination = scopeRef.current?.sessionId ?? null;
1571
+ const turnSessions = sessionsRef.current;
1572
+ if (turnSessions && destination) {
1573
+ try {
1574
+ await requireWritableConversation(turnSessions, destination, 'start conversation turn');
1575
+ }
1576
+ catch (err) {
1577
+ if (prompt.kind === 'goal' && prompt.goalRound) {
1578
+ goalActivation.disarm(prompt.goalRound.sessionId, prompt.goalRound);
1579
+ wakeGoalDriver();
1580
+ }
1581
+ pushMessage('system', `This turn was not started: ${err instanceof Error ? err.message : String(err)}`);
1582
+ setState('idle');
1583
+ return;
1584
+ }
1585
+ }
1586
+ // `@path` mentions: the visible human message keeps the readable token,
1587
+ // but the model receives the file contents inlined. An automatic goal
1588
+ // prompt is already host-authored context and is never reinterpreted as
1589
+ // a file-mention command.
1590
+ const expanded = prompt.kind === 'human'
1591
+ ? expandFileMentions(text, ctx.cwd)
1592
+ : { sendText: text, attached: [] };
1593
+ const { sendText, attached } = expanded;
1594
+ const historyBeforeTurn = modelHistoryRef.current;
1595
+ const userMessage = createUserMessage(sendText, attachments, goalRound
1596
+ ? {
1597
+ type: 'goal-round',
1598
+ goalId: goalRound.id,
1599
+ objective: goalRound.objective,
1600
+ goalRevision: goalRound.revision,
1601
+ round: goalRound.round,
1602
+ maxGoalRounds: goalRound.maxGoalRounds,
1603
+ }
1604
+ : undefined);
1605
+ const priorForSdk = [...historyBeforeTurn, userMessage];
1606
+ const runId = generateRunId();
905
1607
  const metaParts = [];
906
1608
  if (attached.length > 0)
907
1609
  metaParts.push(`${attached.length} file${attached.length > 1 ? 's' : ''} attached`);
908
- if (images && images.length > 0)
909
- metaParts.push(`${images.length} image${images.length > 1 ? 's' : ''}`);
910
- pushMessage('user', text, false, undefined, undefined, undefined, metaParts.length > 0 ? metaParts.join(' · ') : undefined);
911
- setState('thinking');
1610
+ if (attachments && attachments.length > 0)
1611
+ metaParts.push(`${attachments.length} attachment${attachments.length > 1 ? 's' : ''}`);
1612
+ if (goalRound) {
1613
+ pushMessage('system', `Goal round ${goalRound.round} / ${goalRound.maxGoalRounds}`, false, '◎', [`Objective: ${goalRound.objective}`]);
1614
+ }
1615
+ else {
1616
+ pushMessage('user', text, false, undefined, undefined, undefined, metaParts.length > 0 ? metaParts.join(' · ') : undefined);
1617
+ }
912
1618
  // The model interleaves text → tool → text across iterations; `applyEvent`
913
1619
  // renders each one in order.
914
- const st = { assistantId: null, text: '', pending: '' };
1620
+ const st = {
1621
+ assistantId: null,
1622
+ text: '',
1623
+ conversationMessages: undefined,
1624
+ pending: '',
1625
+ completed: false,
1626
+ outcome: null,
1627
+ notification: null,
1628
+ };
915
1629
  const ac = new AbortController();
1630
+ const turnToken = {};
1631
+ let abnormalTerminal;
1632
+ const markHumanAbnormal = () => {
1633
+ if (prompt.kind !== 'human' ||
1634
+ abnormalTerminal !== undefined ||
1635
+ (st.outcome !== 'failed' && st.outcome !== 'stopped'))
1636
+ return;
1637
+ abnormalTerminal = {
1638
+ token: turnToken,
1639
+ continuationEpoch: queueContinuationEpochRef.current,
1640
+ outcome: st.outcome,
1641
+ };
1642
+ };
916
1643
  abortRef.current = ac;
1644
+ activeTurnTokenRef.current = turnToken;
917
1645
  // Where this turn will be saved, and which transcript its rows belong
918
1646
  // to. Both fixed HERE, at the one moment they are knowable.
919
1647
  //
@@ -924,33 +1652,101 @@ export function App({ ctx }) {
924
1652
  // conversation happened to be open when it finished, and that record
925
1653
  // outlives the process. A string captured now cannot be reassigned by
926
1654
  // anyone.
927
- const destination = scopeRef.current?.sessionId ?? null;
928
1655
  const turnGeneration = conversationGenRef.current;
1656
+ unsettledTurnGenerationsRef.current.set(turnToken, turnGeneration);
929
1657
  const stillHere = () => conversationGenRef.current === turnGeneration;
1658
+ const turnPermissionMode = permissionModeRef.current;
1659
+ const turnReasoningEffort = reasoningEffortRef.current;
1660
+ // Always carry the guarded callback. `auto` and `strict` decide before
1661
+ // calling it in makeResumeHandler; retaining it is what lets a session
1662
+ // launched with --yolo later return to prompt mode truthfully.
1663
+ const askPermission = (req) => {
1664
+ if (!stillHere() || activeTurnTokenRef.current !== turnToken || ac.signal.aborted) {
1665
+ return Promise.resolve({
1666
+ kind: 'reject',
1667
+ feedback: 'Turn is no longer active.',
1668
+ });
1669
+ }
1670
+ return onPermission(req);
1671
+ };
1672
+ let evidenceTurn;
1673
+ let evidenceReady = goalRound === undefined;
1674
+ if (turnSessions?.turnEvidence && destination) {
1675
+ try {
1676
+ evidenceTurn = await turnSessions.turnEvidence.recordTurnStarted({
1677
+ sessionId: destination,
1678
+ runId,
1679
+ displayText: text,
1680
+ user: userMessage,
1681
+ });
1682
+ evidenceReady = true;
1683
+ }
1684
+ catch (err) {
1685
+ if (goalRound) {
1686
+ goalActivation.disarm(goalRound.sessionId, goalRound);
1687
+ wakeGoalDriver();
1688
+ pushMessage('system', `Goal round ${goalRound.round} was not started because its durable evidence could not be recorded: ${err instanceof Error ? err.message : String(err)}. Automatic continuation is disarmed; /goal resume retries explicitly.`);
1689
+ }
1690
+ else {
1691
+ pushMessage('system', `Could not record durable evidence for this turn before run ${runId}: ${err instanceof Error ? err.message : String(err)}. The turn will continue, but a complete export of conversation ${destination} will refuse rather than omit it.`);
1692
+ }
1693
+ }
1694
+ }
1695
+ else if (goalRound) {
1696
+ goalActivation.disarm(goalRound.sessionId, goalRound);
1697
+ wakeGoalDriver();
1698
+ pushMessage('system', `Goal round ${goalRound.round} was not started because durable turn evidence is unavailable. Automatic continuation is disarmed; /goal resume retries explicitly.`);
1699
+ }
930
1700
  try {
931
- for await (const event of session.send(priorForSdk, {
932
- signal: ac.signal,
933
- // Bypass mode (--dangerously-skip-permissions / --yolo): omit the
934
- // permission callback so every tool batch auto-approves.
935
- onPermission: ctx.skipPermissions ? undefined : onPermission,
936
- extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
937
- })) {
938
- // A turn the operator has left is consumed but not rendered: no row
939
- // from it lands in a transcript it has nothing to do with, and its
940
- // text keeps accumulating so that what gets saved does not depend
941
- // on exactly when the switch happened.
942
- //
943
- // How much arrives after the switch is a property of the SESSION,
944
- // not of this loop. The built-in one checks the signal at the top of
945
- // each iteration and returns after a single `error: aborted`, so in
946
- // practice very little does. The accumulation is here so a session
947
- // that notices later — a different implementation, a generator
948
- // parked in a tool call — still saves a whole reply rather than one
949
- // truncated at the moment the operator happened to leave.
950
- if (stillHere())
951
- applyEvent(event, st);
952
- else if (event.kind === 'delta')
953
- st.text += event.text;
1701
+ // `recordTurnStarted` is an awaited durability boundary. A conversation
1702
+ // switch can happen while it is pending and move the mutable RunScope
1703
+ // captured by `createAgentSession`. Re-admit the turn here, immediately
1704
+ // before the generator exists, or an abandoned prompt can initialize its
1705
+ // reserved SDK run under the new conversation.
1706
+ if (evidenceReady &&
1707
+ !ac.signal.aborted &&
1708
+ stillHere() &&
1709
+ activeTurnTokenRef.current === turnToken &&
1710
+ (!goalRound ||
1711
+ (destination === goalRound.sessionId &&
1712
+ goalActivation.isArmed(goalRound.sessionId, goalRound)))) {
1713
+ for await (const event of session.send(priorForSdk, {
1714
+ signal: ac.signal,
1715
+ runId,
1716
+ permissionMode: turnPermissionMode,
1717
+ ...(turnReasoningEffort !== undefined ? { effort: turnReasoningEffort } : {}),
1718
+ ...(goalRound ? { goalRound } : {}),
1719
+ // The mode above decides whether this callback is consulted.
1720
+ onPermission: askPermission,
1721
+ extraSystem: composeSkillsPrompt(activeSkills) ?? undefined,
1722
+ onConversationMessages: (messages) => {
1723
+ // State-only: opaque reasoning/signatures must reach the next
1724
+ // provider and durable store without becoming transcript text.
1725
+ st.conversationMessages = messages;
1726
+ },
1727
+ })) {
1728
+ // A turn the operator has left is consumed but not rendered: no row
1729
+ // from it lands in a transcript it has nothing to do with, and its
1730
+ // text keeps accumulating so that what gets saved does not depend
1731
+ // on exactly when the switch happened.
1732
+ //
1733
+ // How much arrives after the switch is a property of the SESSION,
1734
+ // not of this loop. The built-in one checks the signal at the top of
1735
+ // each iteration and returns after a single `error: aborted`, so in
1736
+ // practice very little does. The accumulation is here so a session
1737
+ // that notices later — a different implementation, a generator
1738
+ // parked in a tool call — still saves a whole reply rather than one
1739
+ // truncated at the moment the operator happened to leave.
1740
+ if (stillHere()) {
1741
+ applyEvent(event, st);
1742
+ markHumanAbnormal();
1743
+ }
1744
+ else if (event.kind === 'delta')
1745
+ st.text += event.text;
1746
+ }
1747
+ }
1748
+ else {
1749
+ st.outcome = 'cancelled';
954
1750
  }
955
1751
  }
956
1752
  catch (err) {
@@ -959,6 +1755,11 @@ export function App({ ctx }) {
959
1755
  // the generation exists to stop — and it would be the more confusing
960
1756
  // half of it, because an abort reads as an error.
961
1757
  if (stillHere()) {
1758
+ if (!ac.signal.aborted) {
1759
+ st.outcome = 'failed';
1760
+ st.notification = { kind: 'turn-settled', outcome: 'failed' };
1761
+ markHumanAbnormal();
1762
+ }
962
1763
  // Flushed first: this path does not go through `applyEvent`, so
963
1764
  // without it the partial answer the model had produced before
964
1765
  // the failure would be discarded along with the turn.
@@ -969,11 +1770,44 @@ export function App({ ctx }) {
969
1770
  }
970
1771
  }
971
1772
  finally {
1773
+ // Prefer the kernel's settled conversation projection: it retains opaque
1774
+ // reasoning, citations and complete tool turns that the visible delta
1775
+ // stream cannot reconstruct. A fake/legacy session that does not publish
1776
+ // one keeps the old text-only shape. The visible transcript still keeps
1777
+ // the operator's readable `@file` token; both persistence paths keep what
1778
+ // was actually sent, including expanded contents and attachments.
1779
+ const fallbackTurn = goalRound && !evidenceReady ? [] : [userMessage];
1780
+ if (st.text.trim().length > 0)
1781
+ fallbackTurn.push(createAssistantMessage(st.text));
1782
+ const conversationMessages = goalRound && !evidenceReady ? undefined : st.conversationMessages;
1783
+ const publication = conversationMessages
1784
+ ? planTurnPublication(historyBeforeTurn, userMessage, conversationMessages)
1785
+ : { kind: 'append', messages: fallbackTurn };
1786
+ const nextModelHistory = conversationMessages ?? [...historyBeforeTurn, ...fallbackTurn];
1787
+ if (goalRound && (ac.signal.aborted || st.outcome !== 'completed')) {
1788
+ goalActivation.disarm(goalRound.sessionId, goalRound);
1789
+ wakeGoalDriver();
1790
+ }
972
1791
  // The screen belongs to the conversation on it, which after a
973
1792
  // `/resume` is no longer this turn's. `interruptTurn` already did this
974
1793
  // cleanup at the moment it decided to stop; repeating it here would
975
1794
  // clear the state of whatever has started since.
976
1795
  if (stillHere()) {
1796
+ const ownsTurn = activeTurnTokenRef.current === turnToken;
1797
+ const shouldPauseQueued = ownsTurn &&
1798
+ abnormalTerminal?.token === turnToken &&
1799
+ abnormalTerminal.continuationEpoch === queueContinuationEpochRef.current &&
1800
+ queuedRef.current.length > 0;
1801
+ if (shouldPauseQueued && abnormalTerminal) {
1802
+ setQueuePause({ outcome: abnormalTerminal.outcome });
1803
+ }
1804
+ modelHistoryRef.current = nextModelHistory;
1805
+ if (st.completed && st.text.trim().length > 0) {
1806
+ lastCompletedOutputRef.current = {
1807
+ text: st.text,
1808
+ provenance: 'normal-completion',
1809
+ };
1810
+ }
977
1811
  // Unguarded, and it can be: a second turn cannot start in the
978
1812
  // conversation this one is running in — the composer queues instead
979
1813
  // — so within one generation this handle is still ours. An
@@ -981,11 +1815,20 @@ export function App({ ctx }) {
981
1815
  // could not fail, which teaches the next reader that the checks
982
1816
  // around it are decoration too.
983
1817
  abortRef.current = null;
1818
+ if (ownsTurn)
1819
+ activeTurnTokenRef.current = null;
984
1820
  permissionResolveRef.current = null;
985
1821
  permissionOpenedAtRef.current = null;
986
1822
  setPermission(null);
987
1823
  clearActiveTools();
988
1824
  setState('idle');
1825
+ if (ownsTurn &&
1826
+ !ac.signal.aborted &&
1827
+ !goalRound &&
1828
+ st.notification &&
1829
+ (queuedRef.current.length === 0 || shouldPauseQueued)) {
1830
+ sendTerminalNotification(st.notification);
1831
+ }
989
1832
  }
990
1833
  // Persisted either way, and into the conversation this turn was
991
1834
  // started in — captured above, never re-read.
@@ -1002,18 +1845,81 @@ export function App({ ctx }) {
1002
1845
  // conversation, because there is no other channel and it is news they
1003
1846
  // need. Naming the conversation is what keeps it from reading as a
1004
1847
  // fault of the one in front of them.
1005
- const sessions = sessionsRef.current;
1006
- if (sessions && destination) {
1007
- const turn = [createUserMessage(text)];
1008
- if (st.text.trim().length > 0)
1009
- turn.push(createAssistantMessage(st.text));
1010
- void appendMessages(sessions, destination, turn).catch((err) => {
1011
- pushMessage('system', `A turn was not saved to conversation ${destination}: ${err instanceof Error ? err.message : String(err)}. That conversation's history will not include it, and its next turn will not have it as context.`);
1848
+ if (turnSessions &&
1849
+ destination &&
1850
+ (publication.kind === 'replace' || publication.messages.length > 0 || evidenceTurn)) {
1851
+ persistenceTailRef.current = persistenceTailRef.current.then(async () => {
1852
+ if (evidenceTurn && turnSessions.turnEvidence) {
1853
+ try {
1854
+ await turnSessions.turnEvidence.recordTurnSettled({
1855
+ sessionId: destination,
1856
+ turnId: evidenceTurn.turnId,
1857
+ runId,
1858
+ outcome: ac.signal.aborted
1859
+ ? 'cancelled'
1860
+ : (st.outcome ?? (st.completed ? 'completed' : 'stopped')),
1861
+ assistantText: st.text,
1862
+ });
1863
+ }
1864
+ catch (err) {
1865
+ pushMessage('system', `Could not finish the durable evidence for turn ${evidenceTurn.turnId}: ${err instanceof Error ? err.message : String(err)}. A complete export of conversation ${destination} will refuse if the SDK run record cannot prove the missing text.`);
1866
+ }
1867
+ }
1868
+ try {
1869
+ if (publication.kind === 'replace') {
1870
+ await replaceConversation(turnSessions, destination, publication.messages);
1871
+ }
1872
+ else if (publication.messages.length > 0) {
1873
+ await appendMessages(turnSessions, destination, publication.messages);
1874
+ }
1875
+ }
1876
+ catch (err) {
1877
+ if (goalRound) {
1878
+ goalActivation.disarm(goalRound.sessionId, goalRound);
1879
+ wakeGoalDriver();
1880
+ }
1881
+ pushMessage('system', `A turn was not saved to conversation ${destination}: ${err instanceof Error ? err.message : String(err)}. Its durable history will not include it. It remains only in this process's in-memory context if that conversation is still open; resuming or restarting will lose it.`);
1882
+ }
1012
1883
  });
1013
1884
  }
1885
+ // Removed only AFTER the write has been attached to the tail. A history
1886
+ // operation that sees no current-generation entries can now await that
1887
+ // tail without a turn appearing behind its read later.
1888
+ unsettledTurnGenerationsRef.current.delete(turnToken);
1889
+ }
1890
+ }, [
1891
+ activeSkills,
1892
+ applyEvent,
1893
+ clearActiveTools,
1894
+ ctx.cwd,
1895
+ finalizeMessage,
1896
+ flushStream,
1897
+ goalActivation,
1898
+ onPermission,
1899
+ pushMessage,
1900
+ sendTerminalNotification,
1901
+ session,
1902
+ setQueuePause,
1903
+ wakeGoalDriver,
1904
+ ]);
1905
+ const handleSubmit = useCallback((value, attachments) => {
1906
+ if (goalCommandInFlightRef.current) {
1907
+ pushMessage('system', 'A goal command is still reaching durable session state. Wait for its result before sending another command or prompt.');
1908
+ return;
1909
+ }
1910
+ if (exportingRef.current) {
1911
+ pushMessage('system', 'A verified conversation export is still being written. Wait for its result before sending another command or prompt.');
1912
+ return;
1913
+ }
1914
+ if (conversationMutationRef.current) {
1915
+ const operation = conversationMutationRef.current === 'fork'
1916
+ ? 'forked'
1917
+ : conversationMutationRef.current === 'edit'
1918
+ ? 'branched for prompt editing'
1919
+ : 'moved to a fresh conversation';
1920
+ pushMessage('system', `Conversation history is being ${operation}. Wait for it to finish before sending another command or prompt.`);
1921
+ return;
1014
1922
  }
1015
- }, [activeSkills, applyEvent, ctx.cwd, ctx.skipPermissions, finalizeMessage, messages, onPermission, pushMessage, session]);
1016
- const handleSubmit = useCallback((value, images) => {
1017
1923
  setHistory((prev) => [...prev, value]);
1018
1924
  // What actually gets sent. A `prompt` action replaces it with text the
1019
1925
  // command composed, and then takes the ordinary send path below —
@@ -1026,10 +1932,13 @@ export function App({ ctx }) {
1026
1932
  case 'message':
1027
1933
  pushMessage(slash.role, slash.content);
1028
1934
  return;
1029
- case 'clear':
1935
+ case 'clear-screen':
1030
1936
  setMessages([]);
1031
1937
  resetTranscript();
1032
1938
  return;
1939
+ case 'new-conversation':
1940
+ void startFreshConversation(slash.clearScreen);
1941
+ return;
1033
1942
  case 'exit':
1034
1943
  exit();
1035
1944
  return;
@@ -1041,6 +1950,58 @@ export function App({ ctx }) {
1041
1950
  setKeyEntryFor(null);
1042
1951
  setPhase('picker');
1043
1952
  return;
1953
+ case 'permission-mode': {
1954
+ if (!session?.hasProvider) {
1955
+ pushMessage('system', 'No active session — pick a provider before changing permissions.');
1956
+ return;
1957
+ }
1958
+ if (state !== 'idle' ||
1959
+ abortRef.current !== null ||
1960
+ hasUnsettledTurn() ||
1961
+ queuedRef.current.length > 0 ||
1962
+ permissionResolveRef.current !== null ||
1963
+ compactingRef.current) {
1964
+ pushMessage('system', 'Permission mode was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
1965
+ return;
1966
+ }
1967
+ if (!session.resetApprovalLatch) {
1968
+ pushMessage('system', 'Permission mode was not changed: this embedded session cannot revoke an earlier "approve all" choice. Reconnect it before changing modes.');
1969
+ return;
1970
+ }
1971
+ session.resetApprovalLatch();
1972
+ permissionModeRef.current = slash.mode;
1973
+ permissionModeSourceRef.current = 'session';
1974
+ setPermissionModeState(slash.mode);
1975
+ pushMessage('system', `Permission mode changed to ${slash.mode}. Any earlier "approve all" choice was revoked. Declarative deny rules and the built-in safety gate still take precedence.`);
1976
+ return;
1977
+ }
1978
+ case 'reasoning-effort': {
1979
+ if (!session?.hasProvider) {
1980
+ pushMessage('system', 'No active session — pick a provider before changing reasoning effort.');
1981
+ return;
1982
+ }
1983
+ if (state !== 'idle' ||
1984
+ abortRef.current !== null ||
1985
+ hasUnsettledTurn() ||
1986
+ queuedRef.current.length > 0 ||
1987
+ permissionResolveRef.current !== null ||
1988
+ compactingRef.current) {
1989
+ pushMessage('system', 'Reasoning effort was not changed: wait for the active turn, prompt, compaction, and queued work to settle.');
1990
+ return;
1991
+ }
1992
+ if (slash.effort === null) {
1993
+ setReasoningEffort(undefined);
1994
+ pushMessage('system', 'Reasoning effort reset to the provider default for future main-query turns.');
1995
+ return;
1996
+ }
1997
+ if (!session.reasoningEffortLevels?.includes(slash.effort)) {
1998
+ pushMessage('system', `Reasoning effort was not changed: ${slash.effort} is not offered by every usable provider-chain member for this model.`);
1999
+ return;
2000
+ }
2001
+ setReasoningEffort(slash.effort);
2002
+ pushMessage('system', `Reasoning effort changed to ${slash.effort} for future main-query turns in this session.`);
2003
+ return;
2004
+ }
1044
2005
  case 'login':
1045
2006
  void startOrFinishLogin(slash.pasted);
1046
2007
  return;
@@ -1114,6 +2075,12 @@ export function App({ ctx }) {
1114
2075
  case 'resume':
1115
2076
  void doResume();
1116
2077
  return;
2078
+ case 'title':
2079
+ void doTitle(slash.title, slash.clear);
2080
+ return;
2081
+ case 'fork':
2082
+ void doFork();
2083
+ return;
1117
2084
  case 'expand': {
1118
2085
  // Resolved INSIDE the updater, against `prev`.
1119
2086
  //
@@ -1203,13 +2170,48 @@ export function App({ ctx }) {
1203
2170
  // THIS session can answer from. The descriptors used for
1204
2171
  // the merge above carry no store — they are names — so the
1205
2172
  // registry is rebuilt here with the live one.
2173
+ const durableSessions = sessionsRef.current;
2174
+ const runScope = scopeRef.current;
2175
+ const generation = conversationGenRef.current;
2176
+ const goalCommand = slash.name === 'goal';
2177
+ if (goalCommand)
2178
+ goalCommandInFlightRef.current = true;
1206
2179
  const registry = new HostCommandRegistry();
1207
- registry.register(kernelHostCommands({ allowedAgentIds: session?.agentIds ?? [] }));
2180
+ registry.register(kernelHostCommands({
2181
+ allowedAgentIds: session?.agentIds ?? [],
2182
+ ...(durableSessions && runScope
2183
+ ? {
2184
+ goal: {
2185
+ store: durableSessions.goals,
2186
+ sessionId: runScope.sessionId,
2187
+ tenantId: durableSessions.tenantId,
2188
+ activation: goalActivation,
2189
+ },
2190
+ }
2191
+ : {}),
2192
+ }));
1208
2193
  void (async () => {
1209
- const outcome = await registry.dispatch(`/${slash.name} ${slash.args.join(' ')}`.trim());
1210
- pushMessage('system', outcome
1211
- ? renderOutcome(outcome)
1212
- : `/${slash.name} is registered but this session cannot run it.`);
2194
+ try {
2195
+ const outcome = await registry.dispatch(`/${slash.name} ${slash.args.join(' ')}`.trim());
2196
+ // A late readout from the conversation just left must not be
2197
+ // painted as state of the one now on screen.
2198
+ if (conversationGenRef.current !== generation)
2199
+ return;
2200
+ pushMessage('system', outcome
2201
+ ? renderOutcome(outcome)
2202
+ : `/${slash.name} is registered but this session cannot run it.`);
2203
+ }
2204
+ catch (error) {
2205
+ if (conversationGenRef.current !== generation)
2206
+ return;
2207
+ pushMessage('system', `Could not run /${slash.name}: ${error instanceof Error ? error.message : String(error)}`);
2208
+ }
2209
+ finally {
2210
+ if (goalCommand) {
2211
+ goalCommandInFlightRef.current = false;
2212
+ wakeGoalDriver();
2213
+ }
2214
+ }
1213
2215
  })();
1214
2216
  return;
1215
2217
  }
@@ -1253,6 +2255,211 @@ export function App({ ctx }) {
1253
2255
  })();
1254
2256
  return;
1255
2257
  }
2258
+ case 'review': {
2259
+ void (async () => {
2260
+ const diff = await workspaceDiff(ctx.cwd);
2261
+ if (diff === null) {
2262
+ pushMessage('system', 'Cannot review here — this is not a git repository, or git is unavailable.');
2263
+ return;
2264
+ }
2265
+ if (diff.stat.length === 0 && diff.untracked.length === 0) {
2266
+ // Refused rather than sent. A review request over an
2267
+ // unchanged tree burns a turn and comes back with a
2268
+ // review of nothing, which reads exactly like a review
2269
+ // of something.
2270
+ pushMessage('system', 'Nothing to review — the working tree is clean.');
2271
+ return;
2272
+ }
2273
+ // The same FIFO a typed message enters. A review composed
2274
+ // while a turn is in flight must not jump the queue, and
2275
+ // must not be dropped either.
2276
+ const text = reviewPrompt(diff.stat, diff.untracked);
2277
+ enqueueQueued({ kind: 'human', text });
2278
+ })();
2279
+ return;
2280
+ }
2281
+ case 'diff': {
2282
+ // Fire-and-forget, like its neighbours: a git invocation is not
2283
+ // something to block a keystroke on.
2284
+ void (async () => {
2285
+ const { summary, detail } = renderWorkspaceDiff(await workspaceDiff(ctx.cwd));
2286
+ setMessages((prev) => [
2287
+ ...prev,
2288
+ {
2289
+ id: `diff-${Date.now()}`,
2290
+ role: 'system',
2291
+ content: summary,
2292
+ ...(detail.length > 0 ? { detail } : {}),
2293
+ },
2294
+ ]);
2295
+ })();
2296
+ return;
2297
+ }
2298
+ case 'compact': {
2299
+ if (!session) {
2300
+ pushMessage('system', 'No session yet — nothing to compact.');
2301
+ return;
2302
+ }
2303
+ if (abortRef.current || state !== 'idle' || hasUnsettledTurn()) {
2304
+ pushMessage('system', 'A turn is still running or settling. Compacting now would summarize a conversation while its next message is being written — wait for it to finish, or press esc to stop it and wait for its partial reply to be saved.');
2305
+ return;
2306
+ }
2307
+ if (compactingRef.current)
2308
+ return;
2309
+ compactingRef.current = true;
2310
+ setCompacting(true);
2311
+ setState('thinking');
2312
+ // Fire-and-forget, the same shape `feedback` above uses: this
2313
+ // switch is synchronous while compaction and its optional
2314
+ // verifier are asynchronous. The transcript reports the outcome.
2315
+ void (async () => {
2316
+ try {
2317
+ // The same lossless record `runTurn` reads — including an earlier
2318
+ // compaction summary and attachments. The transcript is only its
2319
+ // rendered view and is never reverse-engineered into history.
2320
+ const result = await session.compact(modelHistoryRef.current);
2321
+ if (!result) {
2322
+ // Not an error, and not silence: a conversation too short
2323
+ // to shed anything is a real answer to a question the
2324
+ // operator asked on purpose.
2325
+ pushMessage('system', 'Nothing to compact yet — this conversation is short enough that adding a summary would save no messages.');
2326
+ return;
2327
+ }
2328
+ // Durable first, screen second. Reporting success and only then
2329
+ // discovering `/resume` still has the old history is the exact
2330
+ // false-success state this command used to create.
2331
+ const sessions = sessionsRef.current;
2332
+ const destination = scopeRef.current?.sessionId;
2333
+ if (sessions && destination) {
2334
+ await persistenceTailRef.current;
2335
+ await replaceConversation(sessions, destination, result.messages);
2336
+ }
2337
+ modelHistoryRef.current = result.messages;
2338
+ // The old gauge measured the history that was just replaced.
2339
+ // Clear it only after the durable projection and live model
2340
+ // history agree; until then the old measurement is still the
2341
+ // truthful one. The next run publishes a fresh measurement.
2342
+ setContext(null);
2343
+ // How many user/assistant turns survived the pass.
2344
+ // `keepRecentRows` explains why the transcript is trimmed
2345
+ // to match rather than rebuilt from `result.messages`.
2346
+ const keptTurns = result.messages.filter((m) => m.role === 'user' || m.role === 'assistant').length;
2347
+ // The summary belongs before the surviving turns. `<Static>` only
2348
+ // emits items beyond the index it has already printed, so prepending
2349
+ // into the mounted transcript makes the summary invisible. Remount
2350
+ // the projection just as `/resume` does when it replaces the log.
2351
+ resetTranscript();
2352
+ setMessages((prev) => {
2353
+ const summaryRow = {
2354
+ id: `compact-${Date.now()}`,
2355
+ role: 'system',
2356
+ content: `Compacted ${result.shed} earlier message(s) into a summary.${result.usage.totalTokens > 0
2357
+ ? ` Verifier used ${result.usage.totalTokens.toLocaleString('en-US')} tokens.`
2358
+ : ''}${sessions && destination
2359
+ ? ''
2360
+ : ' Conversation persistence is unavailable, so this compacted history lasts only for this process.'}`,
2361
+ detail: summaryDetail(result.summary),
2362
+ };
2363
+ return [summaryRow, ...keepRecentRows(prev, keptTurns)];
2364
+ });
2365
+ }
2366
+ catch (err) {
2367
+ pushMessage('system', `Compaction failed: ${err instanceof Error ? err.message : String(err)}`);
2368
+ }
2369
+ finally {
2370
+ compactingRef.current = false;
2371
+ setCompacting(false);
2372
+ setState('idle');
2373
+ }
2374
+ })();
2375
+ return;
2376
+ }
2377
+ case 'copy': {
2378
+ const target = lastCompletedOutputRef.current;
2379
+ if (!target) {
2380
+ pushMessage('system', 'Nothing to copy yet — /copy applies to the latest assistant answer that finished normally.');
2381
+ return;
2382
+ }
2383
+ const result = writeClipboardText(target.text, {
2384
+ isTTY: stdout.isTTY,
2385
+ write: writeStdout,
2386
+ });
2387
+ switch (result.kind) {
2388
+ case 'request-sent':
2389
+ pushMessage('system', `Copy request sent for the ${target.provenance === 'persisted'
2390
+ ? 'latest persisted assistant output'
2391
+ : 'latest normally completed answer'} (${result.bytes.toLocaleString()} bytes). Terminal, multiplexer or remote-session policy may ignore OSC 52; if the clipboard did not change, enable terminal clipboard access.`);
2392
+ break;
2393
+ case 'unavailable':
2394
+ pushMessage('system', `Cannot send a copy request here — ${result.detail}. /copy needs an interactive terminal with OSC 52 clipboard support.`);
2395
+ break;
2396
+ case 'too-large':
2397
+ pushMessage('system', `Cannot send this answer to the terminal clipboard — it is ${result.bytes.toLocaleString()} bytes and the OSC 52 safety limit is ${result.limit.toLocaleString()}. Nothing was truncated.`);
2398
+ break;
2399
+ case 'write-failed':
2400
+ pushMessage('system', `Could not send the terminal copy request: ${result.detail}`);
2401
+ break;
2402
+ default: {
2403
+ const exhaustive = result;
2404
+ void exhaustive;
2405
+ }
2406
+ }
2407
+ return;
2408
+ }
2409
+ case 'raw': {
2410
+ const enabled = slash.enabled === 'toggle' ? !rawOutput : slash.enabled;
2411
+ // Rows already emitted by <Static> cannot observe a new render
2412
+ // function. Clear the terminal, reset its monotone floor and
2413
+ // remount the retained rows so this is a transcript-wide mode,
2414
+ // not a flag that reaches only future output.
2415
+ setRawOutput(enabled);
2416
+ resetTranscript();
2417
+ pushMessage('system', enabled
2418
+ ? 'Raw output mode on — transcript source is shown without Markdown styling or collapsed bodies; terminal controls remain visible escapes.'
2419
+ : 'Raw output mode off — rich transcript rendering restored.');
2420
+ return;
2421
+ }
2422
+ case 'export': {
2423
+ const sessions = sessionsRef.current;
2424
+ const destination = scopeRef.current?.sessionId;
2425
+ if (!sessions || !destination) {
2426
+ pushMessage('system', 'Cannot export this conversation because durable session persistence is unavailable.');
2427
+ return;
2428
+ }
2429
+ if (abortRef.current ||
2430
+ state !== 'idle' ||
2431
+ hasUnsettledTurn() ||
2432
+ compactingRef.current ||
2433
+ queuedRef.current.length > 0) {
2434
+ pushMessage('system', 'A turn, compaction, or queued prompt is still running or settling. Export waits for a stable durable boundary; try again when the composer is idle.');
2435
+ return;
2436
+ }
2437
+ exportingRef.current = true;
2438
+ setState('thinking');
2439
+ const generation = conversationGenRef.current;
2440
+ const path = slash.path ?? `namzu-conversation-${destination}.md`;
2441
+ void (async () => {
2442
+ try {
2443
+ await persistenceTailRef.current;
2444
+ if (conversationGenRef.current !== generation ||
2445
+ scopeRef.current?.sessionId !== destination) {
2446
+ throw new Error('The active conversation changed before the export reached its durable boundary.');
2447
+ }
2448
+ const projected = await conversationMarkdown(sessions, destination);
2449
+ const written = await writeConversationExport(projected.markdown, path, ctx.cwd);
2450
+ pushMessage('system', `Exported ${projected.turns} turn${projected.turns === 1 ? '' : 's'} to ${written.path} (${written.bytes.toLocaleString()} bytes). Existing files are never overwritten.`);
2451
+ }
2452
+ catch (err) {
2453
+ pushMessage('system', `Conversation export failed: ${err instanceof Error ? err.message : String(err)}`);
2454
+ }
2455
+ finally {
2456
+ exportingRef.current = false;
2457
+ if (conversationGenRef.current === generation)
2458
+ setState('idle');
2459
+ }
2460
+ })();
2461
+ return;
2462
+ }
1256
2463
  default: {
1257
2464
  // Exhaustive on purpose. Without it a `SlashAction` kind added
1258
2465
  // and not handled here falls out of the switch into the send
@@ -1266,24 +2473,161 @@ export function App({ ctx }) {
1266
2473
  }
1267
2474
  }
1268
2475
  }
1269
- // A turn is in flight → queue the message; it auto-sends when idle.
1270
- // (Queued messages are text-only; pasted images aren't carried.)
1271
- if (state !== 'idle') {
1272
- setQueued((q) => [...q, outgoing]);
2476
+ // Every model-bound prompt enters one FIFO, including one submitted while
2477
+ // idle. If idle submissions bypassed it, a prompt arriving after a turn's
2478
+ // `setState('idle')` but before the passive drain effect could start ahead
2479
+ // of an older queued prompt. The drain below is the only turn starter.
2480
+ //
2481
+ // Keep the attachment array beside its text. Reconstructing a prompt from
2482
+ // the transcript later cannot recover bytes whose composer chip is gone.
2483
+ advanceQueueContinuation();
2484
+ enqueueQueued({
2485
+ kind: 'human',
2486
+ text: outgoing,
2487
+ ...(attachments && attachments.length > 0 ? { attachments: [...attachments] } : {}),
2488
+ });
2489
+ }, [
2490
+ activeSkills,
2491
+ advanceQueueContinuation,
2492
+ ctx.cwd,
2493
+ doResume,
2494
+ enqueueQueued,
2495
+ exit,
2496
+ hasUnsettledTurn,
2497
+ nextId,
2498
+ pushMessage,
2499
+ rawOutput,
2500
+ resetTranscript,
2501
+ setReasoningEffort,
2502
+ slashCtx,
2503
+ startFreshConversation,
2504
+ state,
2505
+ ]);
2506
+ // Admit automatic work only at a whole-App durable boundary. This effect
2507
+ // reserves; it never starts a turn itself. The one queue pump below remains
2508
+ // the sole starter, which is what keeps a human prompt that arrives during
2509
+ // admission ahead of the resulting goal round.
2510
+ useEffect(() => {
2511
+ void goalDriveVersion;
2512
+ // The ref is the synchronous gate; this state read is the wake-up edge.
2513
+ // A failed fork/edit can close its mutation boundary while the screen stays
2514
+ // idle, so no other dependency would necessarily re-run this driver.
2515
+ void conversationMutation;
2516
+ if (goalDriveInFlightRef.current ||
2517
+ state !== 'idle' ||
2518
+ phase !== 'ready' ||
2519
+ !session?.hasProvider ||
2520
+ queuedRef.current.length > 0 ||
2521
+ abortRef.current ||
2522
+ hasUnsettledTurn() ||
2523
+ conversationMutationRef.current ||
2524
+ exportingRef.current ||
2525
+ compactingRef.current ||
2526
+ goalCommandInFlightRef.current) {
1273
2527
  return;
1274
2528
  }
1275
- void runTurn(outgoing, images);
1276
- }, [activeSkills, doResume, exit, nextId, pushMessage, runTurn, slashCtx, state]);
2529
+ const durableSessions = sessionsRef.current;
2530
+ const scope = scopeRef.current;
2531
+ if (!durableSessions?.turnEvidence || !scope)
2532
+ return;
2533
+ const generation = conversationGenRef.current;
2534
+ const sessionId = scope.sessionId;
2535
+ const armed = goalActivation.get(sessionId);
2536
+ if (!armed)
2537
+ return;
2538
+ goalDriveInFlightRef.current = true;
2539
+ void (async () => {
2540
+ try {
2541
+ await persistenceTailRef.current;
2542
+ if (conversationGenRef.current !== generation ||
2543
+ scopeRef.current?.sessionId !== sessionId ||
2544
+ state !== 'idle' ||
2545
+ abortRef.current ||
2546
+ hasUnsettledTurn(generation) ||
2547
+ conversationMutationRef.current ||
2548
+ exportingRef.current ||
2549
+ compactingRef.current ||
2550
+ goalCommandInFlightRef.current ||
2551
+ queuedRef.current.length > 0 ||
2552
+ !goalActivation.isArmed(sessionId, armed)) {
2553
+ return;
2554
+ }
2555
+ const current = await durableSessions.goals.getGoal(sessionId, durableSessions.tenantId);
2556
+ if (!current ||
2557
+ current.phase !== 'active' ||
2558
+ current.id !== armed.id ||
2559
+ current.revision !== armed.revision) {
2560
+ goalActivation.disarm(sessionId, armed);
2561
+ return;
2562
+ }
2563
+ const authority = await durableSessions.goals.admitRound(sessionId, durableSessions.tenantId, armed);
2564
+ // Human input may have entered or even started while admission was on
2565
+ // disk; keep it. The goal round appends after that FIFO. A conversation
2566
+ // or goal command is different: it revoked this scheduling boundary.
2567
+ if (conversationGenRef.current !== generation ||
2568
+ scopeRef.current?.sessionId !== sessionId ||
2569
+ conversationMutationRef.current ||
2570
+ goalCommandInFlightRef.current ||
2571
+ !goalActivation.isArmed(sessionId, armed)) {
2572
+ goalActivation.disarm(sessionId, armed);
2573
+ return;
2574
+ }
2575
+ goalActivation.arm(authority);
2576
+ enqueueQueued({
2577
+ kind: 'goal',
2578
+ text: goalRoundPrompt(authority),
2579
+ goalRound: authority,
2580
+ generation,
2581
+ });
2582
+ }
2583
+ catch (error) {
2584
+ goalActivation.disarm(sessionId, armed);
2585
+ if (error instanceof GoalRoundLimitError) {
2586
+ pushMessage('system', `Goal blocked after ${error.goal.roundsAdmitted} admitted round${error.goal.roundsAdmitted === 1 ? '' : 's'}: ${error.goal.blockedReason?.message ?? 'the round limit was reached'}`);
2587
+ }
2588
+ else if (!(error instanceof StaleGoalError)) {
2589
+ pushMessage('system', `Automatic goal continuation was disarmed because round admission failed: ${error instanceof Error ? error.message : String(error)}. Run /goal resume to retry explicitly.`);
2590
+ }
2591
+ }
2592
+ finally {
2593
+ goalDriveInFlightRef.current = false;
2594
+ wakeGoalDriver();
2595
+ }
2596
+ })();
2597
+ }, [
2598
+ enqueueQueued,
2599
+ goalActivation,
2600
+ goalDriveVersion,
2601
+ hasUnsettledTurn,
2602
+ phase,
2603
+ pushMessage,
2604
+ conversationMutation,
2605
+ session,
2606
+ state,
2607
+ wakeGoalDriver,
2608
+ ]);
1277
2609
  // Drain the queue: when a turn settles (idle) and nothing is running,
1278
2610
  // send the next queued message automatically.
1279
2611
  useEffect(() => {
1280
- if (state !== 'idle' || phase !== 'ready' || queued.length === 0 || abortRef.current)
2612
+ if (state !== 'idle' ||
2613
+ phase !== 'ready' ||
2614
+ queuedRef.current.length === 0 ||
2615
+ queuePauseRef.current !== null ||
2616
+ abortRef.current ||
2617
+ hasUnsettledTurn() ||
2618
+ conversationMutationRef.current ||
2619
+ exportingRef.current ||
2620
+ compactingRef.current ||
2621
+ goalCommandInFlightRef.current)
1281
2622
  return;
1282
- const [next, ...rest] = queued;
1283
- setQueued(rest);
2623
+ // Read and remove from the synchronous source of truth. `queued` is the
2624
+ // render snapshot that scheduled this effect; a later input may already
2625
+ // have appended to the ref, and replacing from that old snapshot would
2626
+ // erase it.
2627
+ const next = dequeueQueued();
1284
2628
  if (next !== undefined)
1285
2629
  void runTurn(next);
1286
- }, [state, phase, queued, runTurn]);
2630
+ }, [state, phase, queued, queuePause, dequeueQueued, hasUnsettledTurn, runTurn]);
1287
2631
  // One-shot update check on launch.
1288
2632
  // Best-effort; surfaces a single notice when something newer is out.
1289
2633
  const updateCheckedRef = useRef(false);
@@ -1298,6 +2642,19 @@ export function App({ ctx }) {
1298
2642
  pushMessage('system', `⬆ Update available:\n${lines.join('\n')}`);
1299
2643
  });
1300
2644
  }, [phase, ctx.version, pushMessage]);
2645
+ const hydrateFromPicker = useCallback(async (prefs, detectedNow, signal) => {
2646
+ try {
2647
+ await hydrateSession(prefs, detectedNow, signal);
2648
+ }
2649
+ catch (err) {
2650
+ // A superseded choice no longer owns even its failure message. Its
2651
+ // eventual session object is disposed inside `hydrateSession`; a live
2652
+ // choice stays on the picker with the actionable construction error.
2653
+ if (signal.aborted)
2654
+ return;
2655
+ setPickerNotice(`Could not start the selected provider: ${err instanceof Error ? err.message : String(err)}`);
2656
+ }
2657
+ }, [hydrateSession]);
1301
2658
  /**
1302
2659
  * A credential the operator typed. Held in memory for this process only.
1303
2660
  *
@@ -1306,7 +2663,7 @@ export function App({ ctx }) {
1306
2663
  * provider choice is not persisted either, because persisting it would leave
1307
2664
  * a preference pointing at a credential that will not exist next launch.
1308
2665
  */
1309
- const handleTypedCredential = useCallback((credential, disposition) => {
2666
+ const handleTypedCredential = useCallback((credential, disposition, signal) => {
1310
2667
  const next = [credential, ...detected.filter((d) => d.entry.id !== credential.entry.id)];
1311
2668
  setDetected(next);
1312
2669
  setKeyEntryFor(null);
@@ -1327,9 +2684,9 @@ export function App({ ctx }) {
1327
2684
  providers: [{ id: credential.entry.id }],
1328
2685
  subagents: { active: [] },
1329
2686
  };
1330
- void hydrateSession(prefs, next);
1331
- }, [detected, hydrateSession, pushMessage]);
1332
- const handlePickerSubmit = useCallback((selection) => {
2687
+ void hydrateFromPicker(prefs, next, signal);
2688
+ }, [detected, hydrateFromPicker, pushMessage]);
2689
+ const handlePickerSubmit = useCallback((selection, signal) => {
1333
2690
  // One member for now. The picker builds a longer chain in a later
1334
2691
  // change; the shape it writes into is already the chain.
1335
2692
  const prefs = {
@@ -1351,8 +2708,8 @@ export function App({ ctx }) {
1351
2708
  pushMessage('system', `Could not save preferences: ${err instanceof Error ? err.message : String(err)}`);
1352
2709
  return;
1353
2710
  }
1354
- void hydrateSession(prefs, detected);
1355
- }, [detected, hydrateSession, pushMessage]);
2711
+ void hydrateFromPicker(prefs, detected, signal);
2712
+ }, [detected, hydrateFromPicker, pushMessage]);
1356
2713
  /**
1357
2714
  * Leaving the picker returns to whatever was on screen before it.
1358
2715
  *
@@ -1396,6 +2753,31 @@ export function App({ ctx }) {
1396
2753
  exit();
1397
2754
  return;
1398
2755
  }
2756
+ // Previous-prompt picker owns the keyboard. Esc keeps stepping toward
2757
+ // older prompts, matching the second Esc that opened it; q is the cancel.
2758
+ if (phase === 'edit') {
2759
+ if (editCommittedRef.current)
2760
+ return;
2761
+ if ((key.ctrl && input === 'c') || input.toLowerCase() === 'q') {
2762
+ setEditList([]);
2763
+ setPhase('ready');
2764
+ return;
2765
+ }
2766
+ if (key.escape || key.leftArrow || key.upArrow) {
2767
+ setSelectedEdit((index) => Math.max(0, index - 1));
2768
+ return;
2769
+ }
2770
+ if (key.rightArrow || key.downArrow) {
2771
+ setSelectedEdit((index) => Math.min(editList.length - 1, index + 1));
2772
+ return;
2773
+ }
2774
+ if (key.return) {
2775
+ const target = editList[selectedEdit];
2776
+ if (target)
2777
+ void confirmPromptEdit(target);
2778
+ }
2779
+ return;
2780
+ }
1399
2781
  // Resume picker owns the keyboard while open.
1400
2782
  if (phase === 'resume') {
1401
2783
  // Once a conversation is being read, the keyboard does nothing here.
@@ -1548,19 +2930,34 @@ export function App({ ctx }) {
1548
2930
  // Background is left natural — we inherit the terminal's own background
1549
2931
  // and only theme the foreground. Forcing
1550
2932
  // a filled bg left mismatched patches around bordered areas, so we don't.
1551
- return (_jsxs(Box, { flexDirection: "column", children: [phase !== 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, bypass: ctx.skipPermissions === true, cwd: ctx.cwd })) : null, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [phase === 'trust' ? (_jsx(TrustPrompt, { cwd: ctx.cwd })) : phase === 'resume' ? (_jsx(ResumePicker, { conversations: resumeList, selected: selectedResume })) : phase === 'picker' ? (_jsx(Picker, { detected: detected, currentProvider: currentProvider, currentModel: session?.modelSummary ?? null, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: () => void startLoginFromPicker(), keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, bypass: ctx.skipPermissions === true, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? _jsx(PermissionOverlay, { toolCalls: permission.toolCalls }) : null, _jsxs(ComposerFrame, { focus: state === 'idle' && phase === 'ready', hidden: permission !== null, children: [queued.length > 0 && permission === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: ["\u23CE ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014 sending when ready"] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' || state === 'awaiting-permission', hidden: permission !== null,
2933
+ return (_jsxs(Box, { flexDirection: "column", children: [phase !== 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : null, _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [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: detected, currentProvider: currentProvider, currentModel: session?.modelSummary ?? null, onSubmit: handlePickerSubmit, onCancel: handlePickerCancel, onCredential: handleTypedCredential, onLogin: (signal) => void startLoginFromPicker(signal), keyEntryFor: keyEntryFor, notice: pickerNotice })) : (_jsxs(_Fragment, { children: [_jsx(TranscriptFrame, { children: _jsx(Transcript, { messages: finalized, pending: messages.find((m) => m.pending) ?? null, state: state, settled: window.settled, resetKey: resetKey, raw: rawOutput, header: phase === 'ready' ? (_jsx(Banner, { version: ctx.version, session: session, permissionMode: permissionMode, cwd: ctx.cwd })) : undefined }) }), spacerRows > 0 ? _jsx(Box, { height: spacerRows }) : null, _jsx(LiveActivity, { activeTools: activeTools, thinking: state === 'thinking' && !messages.some((m) => m.pending) }), permission ? _jsx(PermissionOverlay, { toolCalls: permission.toolCalls }) : null, _jsxs(ComposerFrame, { focus: state === 'idle' && phase === 'ready' && conversationMutation === null, hidden: permission !== null, children: [queued.length > 0 && permission === null ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.text.muted, children: [queuePause ? '⏸' : '⏎', " ", queued.length, " message", queued.length > 1 ? 's' : '', " queued \u2014", ' ', queuePause
2934
+ ? `paused after a ${queuePause.outcome} turn; send a message or change model to continue`
2935
+ : 'sending when ready'] }) })) : null, _jsx(Composer, { disabled: phase !== 'ready' ||
2936
+ state === 'awaiting-permission' ||
2937
+ compacting ||
2938
+ conversationMutation !== null, hidden: permission !== null,
1552
2939
  // A turn is running, so Esc is the interrupt and not
1553
2940
  // the composer's clear.
1554
- escapeInterrupts: state === 'thinking' || state === 'tool', onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary ?? null, state: state, hint: hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
2941
+ escapeInterrupts: !compacting && (state === 'thinking' || state === 'tool'), onSubmit: handleSubmit, onNotice: (text) => pushMessage('system', text), onEditPrevious: openPromptEditor, draftToRestore: composerDraft, onDraftRestored: (token) => setComposerDraft((draft) => (draft?.token === token ? null : draft)), userCommands: userCommands, history: history })] })] })), _jsx(Box, { paddingTop: 1, children: _jsx(StatusBar, { cwd: ctx.cwd, provider: session?.providerSummary ?? null, model: session?.modelSummary
2942
+ ? `${session.modelSummary}${reasoningEffort ? ` · effort ${reasoningEffort}` : ''}`
2943
+ : null, state: state, hint: conversationMutation === 'fork'
2944
+ ? 'forking conversation — input is paused'
2945
+ : conversationMutation === 'edit'
2946
+ ? 'branching before prompt — input is paused'
2947
+ : conversationMutation === 'new'
2948
+ ? 'starting a fresh conversation — input is paused'
2949
+ : compacting
2950
+ ? 'compacting conversation — input is paused'
2951
+ : hintForPhase(phase, state, session?.hasProvider === true), usage: usage, context: context }) })] })] }));
1555
2952
  }
1556
- function Banner({ version, session, bypass, cwd, }) {
2953
+ function Banner({ version, session, permissionMode, cwd, }) {
1557
2954
  const cols = process.stdout.columns ?? 80;
1558
2955
  const wide = cols >= NAMZU_WORDMARK_MIN_WIDTH;
1559
2956
  const provider = session?.providerSummary;
1560
2957
  const model = session?.modelSummary;
1561
2958
  const home = process.env.HOME;
1562
2959
  const prettyCwd = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd;
1563
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, paddingBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [wide ? (_jsx(Box, { flexDirection: "column", marginRight: 2, children: NAMZU_WORDMARK.map((line, i) => (_jsx(Text, { color: NAMZU_WORDMARK_GRADIENT[i], bold: true, children: line }, `wm-${i}`))) })) : (_jsxs(Text, { color: NAMZU_MARK_COLOR, children: [NAMZU_MARK, " "] })), _jsxs(Box, { flexDirection: "column", marginTop: wide ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.text.secondary, children: "Cogitave" }), wide ? null : (_jsxs(Text, { color: NAMZU_MARK_COLOR, bold: true, children: [' ', "Namzu"] })), _jsxs(Text, { color: theme.text.muted, children: [" v", version] })] }), _jsx(Text, { color: theme.text.secondary, children: provider ? `${provider}${model ? ` · ${model}` : ''}` : 'the agent in your terminal' }), _jsx(Text, { color: theme.text.muted, children: prettyCwd })] })] }), bypass ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.status.error, bold: true, children: "\u26A0 bypass permissions \u2014 tools run without asking" }) })) : null] }));
2960
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, paddingBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [wide ? (_jsx(Box, { flexDirection: "column", marginRight: 2, children: NAMZU_WORDMARK.map((line, i) => (_jsx(Text, { color: NAMZU_WORDMARK_GRADIENT[i], bold: true, children: line }, `wm-${i}`))) })) : (_jsxs(Text, { color: NAMZU_MARK_COLOR, children: [NAMZU_MARK, " "] })), _jsxs(Box, { flexDirection: "column", marginTop: wide ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.text.secondary, children: "Cogitave" }), wide ? null : (_jsxs(Text, { color: NAMZU_MARK_COLOR, bold: true, children: [' ', "Namzu"] })), _jsxs(Text, { color: theme.text.muted, children: [" v", version] })] }), _jsx(Text, { color: theme.text.secondary, children: provider ? `${provider}${model ? ` · ${model}` : ''}` : 'the agent in your terminal' }), _jsx(Text, { color: theme.text.muted, children: prettyCwd })] })] }), permissionMode === 'auto' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.status.error, bold: true, children: "\u26A0 launched in auto permission mode \u2014 undecided tools run without asking until /permissions changes it" }) })) : null] }));
1564
2961
  }
1565
2962
  function TranscriptFrame({ children }) {
1566
2963
  // Borderless, edge-to-edge — the message glyph gutter provides structure.
@@ -1587,6 +2984,8 @@ canReturn = false) {
1587
2984
  return 'y trust this folder · n / esc exit';
1588
2985
  if (phase === 'resume')
1589
2986
  return '↑↓ navigate · enter resume · esc cancel';
2987
+ if (phase === 'edit')
2988
+ return 'Esc / ← older · → newer · enter fork and edit · q cancel';
1590
2989
  if (phase === 'probing')
1591
2990
  return 'discovering providers…';
1592
2991
  // Esc does two different things here depending on how the picker was reached,
@@ -1605,6 +3004,19 @@ canReturn = false) {
1605
3004
  return 'y approve · n / esc reject · a approve all';
1606
3005
  if (state !== 'idle')
1607
3006
  return 'agent is working — esc to interrupt';
1608
- return '/help · @file / Ctrl+V to attach · Ctrl+C ×2 to exit';
3007
+ return '/help · Esc×2 edit previous · @file / Ctrl+V attach · Ctrl+C ×2 to exit';
3008
+ }
3009
+ /**
3010
+ * The summary text, as collapsible detail under the compaction row.
3011
+ *
3012
+ * Collapsed rather than printed: a structured summary is long by design, and a
3013
+ * transcript that dumps it in full turns "I freed up context" into a wall taller
3014
+ * than the thing it replaced. Kept ON the row rather than dropped, because the
3015
+ * summary IS what the model will read from here on — an operator who wants to
3016
+ * check what survived has to be able to.
3017
+ */
3018
+ function summaryDetail(summary) {
3019
+ const text = typeof summary.content === 'string' ? summary.content : '';
3020
+ return text.length > 0 ? text.split('\n') : ['(the summary was empty)'];
1609
3021
  }
1610
3022
  //# sourceMappingURL=App.js.map