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