@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.
- package/README.md +35 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +119 -60
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts +24 -8
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +221 -80
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +45 -9
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +79 -21
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/prior-messages.d.ts +11 -0
- package/dist/commands/prior-messages.d.ts.map +1 -0
- package/dist/commands/prior-messages.js +280 -0
- package/dist/commands/prior-messages.js.map +1 -0
- package/dist/commands/resume.d.ts.map +1 -1
- package/dist/commands/resume.js +21 -19
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/run-stream.d.ts +5 -3
- package/dist/commands/run-stream.d.ts.map +1 -1
- package/dist/commands/run-stream.js +66 -62
- package/dist/commands/run-stream.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +50 -37
- package/dist/commands/run.js.map +1 -1
- package/dist/config/debug.d.ts +55 -0
- package/dist/config/debug.d.ts.map +1 -0
- package/dist/config/debug.js +126 -0
- package/dist/config/debug.js.map +1 -0
- package/dist/config/load.d.ts +88 -4
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +386 -56
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +87 -5
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +5 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config/trusted-project-context.d.ts +11 -0
- package/dist/config/trusted-project-context.d.ts.map +1 -0
- package/dist/config/trusted-project-context.js +28 -0
- package/dist/config/trusted-project-context.js.map +1 -0
- package/dist/context/project-path.d.ts +3 -0
- package/dist/context/project-path.d.ts.map +1 -0
- package/dist/context/project-path.js +16 -0
- package/dist/context/project-path.js.map +1 -0
- package/dist/context/project-tracker.d.ts +30 -0
- package/dist/context/project-tracker.d.ts.map +1 -0
- package/dist/context/project-tracker.js +217 -0
- package/dist/context/project-tracker.js.map +1 -0
- package/dist/context/sandbox.d.ts +29 -9
- package/dist/context/sandbox.d.ts.map +1 -1
- package/dist/context/sandbox.js +8 -0
- package/dist/context/sandbox.js.map +1 -1
- package/dist/doctor/checks/credentials.d.ts.map +1 -1
- package/dist/doctor/checks/credentials.js +7 -1
- package/dist/doctor/checks/credentials.js.map +1 -1
- package/dist/exit-codes.d.ts +5 -4
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +5 -4
- package/dist/exit-codes.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/clipboard/text.d.ts +43 -0
- package/dist/integrations/clipboard/text.d.ts.map +1 -0
- package/dist/integrations/clipboard/text.js +43 -0
- package/dist/integrations/clipboard/text.js.map +1 -0
- package/dist/integrations/mcp/servers.d.ts +9 -0
- package/dist/integrations/mcp/servers.d.ts.map +1 -1
- package/dist/integrations/mcp/servers.js +11 -4
- package/dist/integrations/mcp/servers.js.map +1 -1
- package/dist/integrations/notifications/terminal.d.ts +32 -0
- package/dist/integrations/notifications/terminal.d.ts.map +1 -0
- package/dist/integrations/notifications/terminal.js +46 -0
- package/dist/integrations/notifications/terminal.js.map +1 -0
- package/dist/integrations/plugins/runtime.d.ts +17 -0
- package/dist/integrations/plugins/runtime.d.ts.map +1 -0
- package/dist/integrations/plugins/runtime.js +100 -0
- package/dist/integrations/plugins/runtime.js.map +1 -0
- package/dist/integrations/providers/codex-device-login.d.ts +27 -0
- package/dist/integrations/providers/codex-device-login.d.ts.map +1 -0
- package/dist/integrations/providers/codex-device-login.js +195 -0
- package/dist/integrations/providers/codex-device-login.js.map +1 -0
- package/dist/integrations/providers/codex-oauth.d.ts +10 -0
- package/dist/integrations/providers/codex-oauth.d.ts.map +1 -0
- package/dist/integrations/providers/codex-oauth.js +95 -0
- package/dist/integrations/providers/codex-oauth.js.map +1 -0
- package/dist/integrations/providers/credential-store.d.ts +40 -4
- package/dist/integrations/providers/credential-store.d.ts.map +1 -1
- package/dist/integrations/providers/credential-store.js +236 -18
- package/dist/integrations/providers/credential-store.js.map +1 -1
- package/dist/integrations/providers/discover.d.ts +25 -13
- package/dist/integrations/providers/discover.d.ts.map +1 -1
- package/dist/integrations/providers/discover.js +98 -44
- package/dist/integrations/providers/discover.js.map +1 -1
- package/dist/integrations/providers/harness-credentials.d.ts +36 -0
- package/dist/integrations/providers/harness-credentials.d.ts.map +1 -0
- package/dist/integrations/providers/harness-credentials.js +146 -0
- package/dist/integrations/providers/harness-credentials.js.map +1 -0
- package/dist/integrations/providers/identity.d.ts +8 -15
- package/dist/integrations/providers/identity.d.ts.map +1 -1
- package/dist/integrations/providers/identity.js +8 -15
- package/dist/integrations/providers/identity.js.map +1 -1
- package/dist/integrations/providers/index.d.ts +7 -4
- package/dist/integrations/providers/index.d.ts.map +1 -1
- package/dist/integrations/providers/index.js +6 -3
- package/dist/integrations/providers/index.js.map +1 -1
- package/dist/integrations/providers/keychain.d.ts +0 -13
- package/dist/integrations/providers/keychain.d.ts.map +1 -1
- package/dist/integrations/providers/keychain.js +0 -76
- package/dist/integrations/providers/keychain.js.map +1 -1
- package/dist/integrations/providers/oauth.d.ts +46 -17
- package/dist/integrations/providers/oauth.d.ts.map +1 -1
- package/dist/integrations/providers/oauth.js +175 -45
- package/dist/integrations/providers/oauth.js.map +1 -1
- package/dist/integrations/providers/register.d.ts.map +1 -1
- package/dist/integrations/providers/register.js +9 -1
- package/dist/integrations/providers/register.js.map +1 -1
- package/dist/integrations/providers/registry.d.ts +8 -1
- package/dist/integrations/providers/registry.d.ts.map +1 -1
- package/dist/integrations/providers/registry.js +29 -0
- package/dist/integrations/providers/registry.js.map +1 -1
- package/dist/integrations/providers/subscription-login.d.ts +2 -0
- package/dist/integrations/providers/subscription-login.d.ts.map +1 -1
- package/dist/integrations/providers/subscription-login.js +117 -14
- package/dist/integrations/providers/subscription-login.js.map +1 -1
- package/dist/integrations/sessions/store.d.ts +111 -1
- package/dist/integrations/sessions/store.d.ts.map +1 -1
- package/dist/integrations/sessions/store.js +400 -7
- package/dist/integrations/sessions/store.js.map +1 -1
- package/dist/integrations/sessions/transcript-export.d.ts +25 -0
- package/dist/integrations/sessions/transcript-export.d.ts.map +1 -0
- package/dist/integrations/sessions/transcript-export.js +441 -0
- package/dist/integrations/sessions/transcript-export.js.map +1 -0
- package/dist/integrations/sessions/turn-evidence.d.ts +138 -0
- package/dist/integrations/sessions/turn-evidence.d.ts.map +1 -0
- package/dist/integrations/sessions/turn-evidence.js +506 -0
- package/dist/integrations/sessions/turn-evidence.js.map +1 -0
- package/dist/integrations/subagents/runtime.d.ts +7 -12
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +19 -10
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/integrations/trust/store.d.ts.map +1 -1
- package/dist/integrations/trust/store.js +27 -13
- package/dist/integrations/trust/store.js.map +1 -1
- package/dist/permissions/canonical-project.d.ts +9 -0
- package/dist/permissions/canonical-project.d.ts.map +1 -0
- package/dist/permissions/canonical-project.js +13 -0
- package/dist/permissions/canonical-project.js.map +1 -0
- package/dist/permissions/checks.d.ts +65 -0
- package/dist/permissions/checks.d.ts.map +1 -0
- package/dist/permissions/checks.js +122 -0
- package/dist/permissions/checks.js.map +1 -0
- package/dist/permissions/headless-trust.d.ts +9 -5
- package/dist/permissions/headless-trust.d.ts.map +1 -1
- package/dist/permissions/headless-trust.js +18 -4
- package/dist/permissions/headless-trust.js.map +1 -1
- package/dist/permissions/rules.d.ts +16 -7
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +78 -13
- package/dist/permissions/rules.js.map +1 -1
- package/dist/tui/App.d.ts +5 -2
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +1621 -196
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/Composer.d.ts +14 -3
- package/dist/tui/Composer.d.ts.map +1 -1
- package/dist/tui/Composer.js +53 -15
- package/dist/tui/Composer.js.map +1 -1
- package/dist/tui/EditPromptPicker.d.ts +8 -0
- package/dist/tui/EditPromptPicker.d.ts.map +1 -0
- package/dist/tui/EditPromptPicker.js +18 -0
- package/dist/tui/EditPromptPicker.js.map +1 -0
- package/dist/tui/LiveActivity.d.ts.map +1 -1
- package/dist/tui/LiveActivity.js +2 -1
- package/dist/tui/LiveActivity.js.map +1 -1
- package/dist/tui/Markdown.d.ts.map +1 -1
- package/dist/tui/Markdown.js +2 -1
- package/dist/tui/Markdown.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +7 -1
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/Picker.d.ts +8 -6
- package/dist/tui/Picker.d.ts.map +1 -1
- package/dist/tui/Picker.js +145 -18
- package/dist/tui/Picker.js.map +1 -1
- package/dist/tui/ResumePicker.d.ts.map +1 -1
- package/dist/tui/ResumePicker.js +1 -1
- package/dist/tui/ResumePicker.js.map +1 -1
- package/dist/tui/Transcript.d.ts +4 -2
- package/dist/tui/Transcript.d.ts.map +1 -1
- package/dist/tui/Transcript.js +31 -5
- package/dist/tui/Transcript.js.map +1 -1
- package/dist/tui/agent.d.ts +123 -19
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +757 -144
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/compact-transcript.d.ts +24 -0
- package/dist/tui/compact-transcript.d.ts.map +1 -0
- package/dist/tui/compact-transcript.js +46 -0
- package/dist/tui/compact-transcript.js.map +1 -0
- package/dist/tui/conversation-history.d.ts +33 -0
- package/dist/tui/conversation-history.d.ts.map +1 -0
- package/dist/tui/conversation-history.js +42 -0
- package/dist/tui/conversation-history.js.map +1 -0
- package/dist/tui/edit-prompts.d.ts +23 -0
- package/dist/tui/edit-prompts.d.ts.map +1 -0
- package/dist/tui/edit-prompts.js +35 -0
- package/dist/tui/edit-prompts.js.map +1 -0
- package/dist/tui/live-window.d.ts +3 -1
- package/dist/tui/live-window.d.ts.map +1 -1
- package/dist/tui/live-window.js +20 -8
- package/dist/tui/live-window.js.map +1 -1
- package/dist/tui/login-prompt.d.ts +5 -0
- package/dist/tui/login-prompt.d.ts.map +1 -1
- package/dist/tui/login-prompt.js +14 -0
- package/dist/tui/login-prompt.js.map +1 -1
- package/dist/tui/slashCommands.d.ts +178 -9
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +340 -16
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/terminal-display.d.ts +12 -0
- package/dist/tui/terminal-display.d.ts.map +1 -0
- package/dist/tui/terminal-display.js +65 -0
- package/dist/tui/terminal-display.js.map +1 -0
- package/dist/tui/types.d.ts +14 -2
- package/dist/tui/types.d.ts.map +1 -1
- package/dist/tui/workspace-diff.d.ts +40 -0
- package/dist/tui/workspace-diff.d.ts.map +1 -0
- package/dist/tui/workspace-diff.js +101 -0
- package/dist/tui/workspace-diff.js.map +1 -0
- 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 {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
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 {
|
|
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 {
|
|
30
|
-
import {
|
|
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 {
|
|
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 {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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,
|
|
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 (
|
|
206
|
-
|
|
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
|
-
? {
|
|
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(
|
|
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
|
-
}, [
|
|
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:
|
|
292
|
-
rules:
|
|
293
|
-
...(
|
|
294
|
-
...(
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
}, [
|
|
655
|
+
}, [advanceQueueContinuation, ensureSessions, pushMessage, setReasoningEffort]);
|
|
352
656
|
/**
|
|
353
657
|
* Sign in to a subscription without leaving namzu.
|
|
354
658
|
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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(
|
|
519
|
-
|
|
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
|
-
}, [
|
|
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(
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
}, [
|
|
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 = {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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 (
|
|
909
|
-
metaParts.push(`${
|
|
910
|
-
|
|
911
|
-
|
|
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 = {
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
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' ||
|
|
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
|
-
|
|
1283
|
-
|
|
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
|
|
1331
|
-
}, [detected,
|
|
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
|
|
1355
|
-
}, [detected,
|
|
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,
|
|
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
|
|
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,
|
|
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 })] })] }),
|
|
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
|
|
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
|