@toddzheng024/dscode-bundle 0.7.11 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +1 -1
  3. package/package.json +5 -4
  4. package/plugins/i18n/messages.mjs +6 -6
  5. package/plugins/session-metrics/view.mjs +16 -5
  6. package/plugins/tui-tools/index.mjs +14 -1
  7. package/vendor/tui/lib/app.mjs +6674 -0
  8. package/vendor/tui/lib/approval.mjs +122 -0
  9. package/vendor/tui/lib/attachments.mjs +218 -0
  10. package/vendor/tui/lib/authorization-panel.mjs +242 -0
  11. package/vendor/tui/lib/authorization.mjs +111 -0
  12. package/vendor/tui/lib/commands.mjs +123 -0
  13. package/vendor/tui/lib/dscode/chat.mjs +64 -0
  14. package/vendor/tui/lib/dscode/flags.mjs +32 -0
  15. package/vendor/tui/lib/dscode/model-search.mjs +68 -0
  16. package/vendor/tui/lib/dscode/paste.mjs +87 -0
  17. package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
  18. package/vendor/tui/lib/dscode/welcome.mjs +100 -0
  19. package/vendor/tui/lib/editor-keys.mjs +346 -0
  20. package/vendor/tui/lib/editor.mjs +50 -0
  21. package/vendor/tui/lib/fork.mjs +24 -0
  22. package/vendor/tui/lib/git-workflow.mjs +278 -0
  23. package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
  24. package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
  25. package/vendor/tui/lib/index.mjs +2198 -0
  26. package/vendor/tui/lib/input-split.mjs +170 -0
  27. package/vendor/tui/lib/internals.mjs +90 -0
  28. package/vendor/tui/lib/invariant.mjs +22 -0
  29. package/vendor/tui/lib/kernel-panels.mjs +1265 -0
  30. package/vendor/tui/lib/keyboard.mjs +283 -0
  31. package/vendor/tui/lib/language-panel.mjs +41 -0
  32. package/vendor/tui/lib/locales/en.mjs +495 -0
  33. package/vendor/tui/lib/locales/zh.mjs +495 -0
  34. package/vendor/tui/lib/mentions.mjs +150 -0
  35. package/vendor/tui/lib/model-capabilities.mjs +245 -0
  36. package/vendor/tui/lib/models.mjs +215 -0
  37. package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
  38. package/vendor/tui/lib/permissions.mjs +45 -0
  39. package/vendor/tui/lib/plugin-inventory.mjs +27 -0
  40. package/vendor/tui/lib/presets.mjs +46 -0
  41. package/vendor/tui/lib/provider-settings.mjs +600 -0
  42. package/vendor/tui/lib/questions.mjs +116 -0
  43. package/vendor/tui/lib/rainbow.mjs +181 -0
  44. package/vendor/tui/lib/render/animations.mjs +665 -0
  45. package/vendor/tui/lib/render/editor.mjs +437 -0
  46. package/vendor/tui/lib/render/export.mjs +119 -0
  47. package/vendor/tui/lib/render/fuzzy.mjs +79 -0
  48. package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
  49. package/vendor/tui/lib/render/inspector.mjs +93 -0
  50. package/vendor/tui/lib/render/lines.mjs +538 -0
  51. package/vendor/tui/lib/render/markdown.mjs +592 -0
  52. package/vendor/tui/lib/render/projection.mjs +1862 -0
  53. package/vendor/tui/lib/render/status.mjs +591 -0
  54. package/vendor/tui/lib/render/text.mjs +157 -0
  55. package/vendor/tui/lib/render/tool-detail.mjs +187 -0
  56. package/vendor/tui/lib/render/tool-preview.mjs +79 -0
  57. package/vendor/tui/lib/render/usage.mjs +336 -0
  58. package/vendor/tui/lib/render/width.mjs +191 -0
  59. package/vendor/tui/lib/session-directory.mjs +329 -0
  60. package/vendor/tui/lib/session-query.mjs +184 -0
  61. package/vendor/tui/lib/session-switch.mjs +51 -0
  62. package/vendor/tui/lib/settings-file.mjs +75 -0
  63. package/vendor/tui/lib/skills.mjs +119 -0
  64. package/vendor/tui/lib/startup.mjs +111 -0
  65. package/vendor/tui/lib/store.mjs +129 -0
  66. package/vendor/tui/lib/subagents.mjs +200 -0
  67. package/vendor/tui/lib/terminal-title.mjs +186 -0
  68. package/vendor/tui/lib/theme-panel.mjs +60 -0
  69. package/vendor/tui/lib/theme.mjs +379 -0
  70. package/vendor/tui/lib/update-panel.mjs +239 -0
  71. package/vendor/tui/lib/update.mjs +101 -0
  72. package/vendor/tui/lib/version.mjs +105 -0
  73. package/vendor/tui/lib/whale-glyph.mjs +20 -0
  74. package/vendor/tui/LICENSE +0 -21
  75. package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
  76. package/vendor/tui/dscode-email/cli.mjs +0 -32
  77. package/vendor/tui/dscode-email/contacts.mjs +0 -36
  78. package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
  79. package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
  80. package/vendor/tui/dscode-email/gmail.mjs +0 -194
  81. package/vendor/tui/dscode-email/imap.mjs +0 -136
  82. package/vendor/tui/dscode-email/inbox.d.mts +0 -25
  83. package/vendor/tui/dscode-email/inbox.mjs +0 -76
  84. package/vendor/tui/dscode-email/smtp.mjs +0 -69
  85. package/vendor/tui/dscode-email/store.mjs +0 -32
  86. package/vendor/tui/dscode-providers/catalog.mjs +0 -136
  87. package/vendor/tui/dscode-providers/effort.mjs +0 -35
  88. package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
  89. package/vendor/tui/index.mjs +0 -44483
  90. package/vendor/tui/invariant.mjs +0 -21
  91. package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
  92. package/vendor/tui/session-query.mjs +0 -150
  93. package/vendor/tui/startup.mjs +0 -109
  94. package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
  95. package/vendor/tui/types/app.d.ts +0 -348
  96. package/vendor/tui/types/approval.d.ts +0 -59
  97. package/vendor/tui/types/attachments.d.ts +0 -52
  98. package/vendor/tui/types/authorization-panel.d.ts +0 -22
  99. package/vendor/tui/types/authorization.d.ts +0 -36
  100. package/vendor/tui/types/commands.d.ts +0 -52
  101. package/vendor/tui/types/editor-keys.d.ts +0 -105
  102. package/vendor/tui/types/editor.d.ts +0 -6
  103. package/vendor/tui/types/fork.d.ts +0 -8
  104. package/vendor/tui/types/git-workflow.d.ts +0 -121
  105. package/vendor/tui/types/index.d.ts +0 -223
  106. package/vendor/tui/types/input-split.d.ts +0 -54
  107. package/vendor/tui/types/internals.d.ts +0 -26
  108. package/vendor/tui/types/invariant.d.ts +0 -15
  109. package/vendor/tui/types/kernel-panels.d.ts +0 -245
  110. package/vendor/tui/types/keyboard.d.ts +0 -80
  111. package/vendor/tui/types/language-panel.d.ts +0 -12
  112. package/vendor/tui/types/locales/en.d.ts +0 -450
  113. package/vendor/tui/types/locales/zh.d.ts +0 -9
  114. package/vendor/tui/types/mentions.d.ts +0 -85
  115. package/vendor/tui/types/model-capabilities.d.ts +0 -82
  116. package/vendor/tui/types/models.d.ts +0 -133
  117. package/vendor/tui/types/permissions.d.ts +0 -27
  118. package/vendor/tui/types/plugin-inventory.d.ts +0 -11
  119. package/vendor/tui/types/presets.d.ts +0 -22
  120. package/vendor/tui/types/provider-settings.d.ts +0 -239
  121. package/vendor/tui/types/questions.d.ts +0 -54
  122. package/vendor/tui/types/rainbow.d.ts +0 -69
  123. package/vendor/tui/types/render/animations.d.ts +0 -307
  124. package/vendor/tui/types/render/editor.d.ts +0 -163
  125. package/vendor/tui/types/render/export.d.ts +0 -9
  126. package/vendor/tui/types/render/fuzzy.d.ts +0 -21
  127. package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
  128. package/vendor/tui/types/render/inspector.d.ts +0 -62
  129. package/vendor/tui/types/render/lines.d.ts +0 -86
  130. package/vendor/tui/types/render/markdown.d.ts +0 -29
  131. package/vendor/tui/types/render/projection.d.ts +0 -587
  132. package/vendor/tui/types/render/status.d.ts +0 -196
  133. package/vendor/tui/types/render/text.d.ts +0 -64
  134. package/vendor/tui/types/render/tool-detail.d.ts +0 -94
  135. package/vendor/tui/types/render/tool-preview.d.ts +0 -28
  136. package/vendor/tui/types/render/usage.d.ts +0 -113
  137. package/vendor/tui/types/render/width.d.ts +0 -29
  138. package/vendor/tui/types/session-directory.d.ts +0 -190
  139. package/vendor/tui/types/session-query.d.ts +0 -92
  140. package/vendor/tui/types/session-switch.d.ts +0 -17
  141. package/vendor/tui/types/settings-file.d.ts +0 -41
  142. package/vendor/tui/types/skills.d.ts +0 -47
  143. package/vendor/tui/types/startup.d.ts +0 -65
  144. package/vendor/tui/types/store.d.ts +0 -58
  145. package/vendor/tui/types/subagents.d.ts +0 -70
  146. package/vendor/tui/types/terminal-title.d.ts +0 -66
  147. package/vendor/tui/types/theme-panel.d.ts +0 -24
  148. package/vendor/tui/types/theme.d.ts +0 -434
  149. package/vendor/tui/types/update-panel.d.ts +0 -49
  150. package/vendor/tui/types/update.d.ts +0 -75
  151. package/vendor/tui/types/version.d.ts +0 -19
  152. package/vendor/tui/types/whale-glyph.d.ts +0 -6
  153. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
  154. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
@@ -0,0 +1,2198 @@
1
+ /**
2
+ * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
3
+ * rides over dsh-base without Host, HTTP, or browser plugins; this runner
4
+ * creates or resumes preset-composed Agents through the core registry, keeps
5
+ * one Ink owner while the active session changes, folds submitted prompts
6
+ * into the selected durable session, answers approval asks with a y/n bar,
7
+ * dispatches slash commands, and on quit flushes and requests process exit.
8
+ *
9
+ * @module @deepseek-ai/dsh-code
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { appendFile as appendFileAsync, mkdir, readdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises';
15
+ import { basename, dirname, join } from 'node:path';
16
+ import { createElement } from 'react';
17
+ import z from '@deepseek-ai/schemastery';
18
+ import { installModelSelection } from '@deepseek-ai/dsh-agent';
19
+ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm';
20
+ import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
21
+ import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client';
22
+ import { App } from './app.mjs';
23
+ import { mountApprovalAnswerer } from './approval.mjs';
24
+ import { isSlashLine, submissionPayload, watchCommands } from './commands.mjs';
25
+ import { internals } from './internals.mjs';
26
+ import { syncModelCapabilities } from './model-capabilities.mjs';
27
+ import { ensureProviderRoute as dscodeEnsureProviderRoute, migrateOpenRouterProfile as dscodeMigrateOpenRouter } from '../../../plugins/providers/catalog.mjs';
28
+ import { compactionPreview as dscodeCompactionPreview, pricedThresholdRatio as dscodePricedThresholdRatio } from '../../../plugins/compaction/threshold.mjs';
29
+ import { dscodeLoadOpenRouterAccountFor, dscodeManagementKeyStatus, dscodeSaveManagementKey } from './app.mjs';
30
+ import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, pendingModelSelection, resolveEffectiveSelection } from './models.mjs';
31
+ import { discoverProviderModels, loadProviderSettings, removeProviderSettings, saveProviderCredential, saveProviderConfiguration, subscribeProviderSettings, unsetProviderCredential, } from './provider-settings.mjs';
32
+ import { createMentions } from './mentions.mjs';
33
+ import { mountQuestionProvider } from './questions.mjs';
34
+ import { createTranscriptStore } from './store.mjs';
35
+ import { createSubagentFeed } from './subagents.mjs';
36
+ import { parseStatuslineItems } from './render/status.mjs';
37
+ import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.mjs';
38
+ import { watchSkills } from './skills.mjs';
39
+ import { toolArgumentsPreview } from './render/tool-preview.mjs';
40
+ import { buildExportMarkdown } from './render/export.mjs';
41
+ import { inspectFilePaths, inspectImagePaths, saveFilePaths, saveImagePaths } from './attachments.mjs';
42
+ import { copyText, latestAssistantText } from './editor.mjs';
43
+ import { applyCtrlRPassthrough, resolveEditorKeysStartupHint } from './editor-keys.mjs';
44
+ import { beginProviderAuthorization, cancelProviderAuthorization, loadProviderAuthorizations, logoutProviderAuthorization, openAuthorizationUrl, subscribeProviderAuthorizations, } from './authorization.mjs';
45
+ import { selectForkSeed } from './fork.mjs';
46
+ import { buildReviewPrompt, listReviewBranches, listReviewCommits, loadCommitDiff, loadGitDiff, mergeBaseWith, } from './git-workflow.mjs';
47
+ import { SessionSwitchQueue } from './session-switch.mjs';
48
+ import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.mjs';
49
+ import { applyPendingPermission, effectivePermission, listPermissionRows, permissionPresetsFrom, selectPermission, } from './permissions.mjs';
50
+ import { listPluginRows } from './plugin-inventory.mjs';
51
+ import { applyLauncherUpdate, probeLauncherUpdate } from './update.mjs';
52
+ import { parseAnimationsPref } from './render/animations.mjs';
53
+ import { parseThemeName, setTheme } from './theme.mjs';
54
+ import { parseLanguageName, setLanguage, t } from './i18n.mjs';
55
+ import { isSubagentSession, matchSessionId, mergeSessionTitles, newestRootForCwd, isSessionArtifactName, jsonlSessionRoot, planSessionDeletion, projectSessionRows, sessionArtifactDirectory, sessionDirectoryFor, } from './session-directory.mjs';
56
+ import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.mjs';
57
+ import { turnUsages } from './render/usage.mjs';
58
+ /** Stable Cordis plugin name. */
59
+ export const name = 'tui-runner';
60
+ /** Core services required before the interactive session can start. */
61
+ export const inject = ['agentDefaultModel', 'agents', 'sessions'];
62
+ export const Config = z.object({
63
+ startup: z.object({
64
+ kind: z.string().required(),
65
+ sessionId: z.string(),
66
+ mode: z.string(),
67
+ theme: z.string(),
68
+ prompt: z.string(),
69
+ images: z.array(z.string()),
70
+ }),
71
+ });
72
+ /** Report an unexpected direct-driver failure and request a failing exit. */
73
+ function fail(io, error) {
74
+ internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
75
+ io.exit(1);
76
+ }
77
+ /**
78
+ * Snapshot caller-visible background jobs for the /jobs panel. Jobs the agent
79
+ * started through run_in_background are fenced by their owner, so the CURRENT
80
+ * agent is the caller. A missing registry is a harmless absence (the base
81
+ * composition may not mount one) and collapses to the empty panel state —
82
+ * the documented degradation for harmless probes, not an error.
83
+ * @param ctx - context carrying the optional `jobs` registry.
84
+ * @param caller - the active agent (undefined sees only unowned jobs).
85
+ * @returns job rows in registration order; never throws.
86
+ */
87
+ function listJobs(ctx, caller) {
88
+ const jobs = ctx.get('jobs');
89
+ if (jobs === undefined)
90
+ return [];
91
+ try {
92
+ return jobs.list(caller).map((job) => ({
93
+ id: job.id,
94
+ kind: job.kind,
95
+ label: job.label,
96
+ status: job.status,
97
+ detail: job.detail,
98
+ startedAt: job.startedAt,
99
+ finishedAt: job.finishedAt,
100
+ }));
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ /**
107
+ * Read one user-level settings file as a plain object. The callers all treat a
108
+ * missing file as "unset" and a corrupt one as "warn and fall back", so this
109
+ * helper owns the one distinction they share: readable JSON that is not an
110
+ * object is corruption, not an absent preference, and must not surface as a
111
+ * cryptic property access on `null`.
112
+ * @param path - absolute path of the settings file.
113
+ * @returns the parsed object; the caller narrows each field itself.
114
+ */
115
+ function readSettingsObject(path) {
116
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
117
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
118
+ throw new Error(`${basename(path)} must contain a JSON object`);
119
+ }
120
+ return parsed;
121
+ }
122
+ /**
123
+ * Resolve the working directory's git branch for the status line.
124
+ * @param cwd - the session's working directory.
125
+ * @returns the branch name, or '' outside a repository or on a detached HEAD.
126
+ */
127
+ function gitBranch(cwd) {
128
+ try {
129
+ const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/);
130
+ return ref?.[1] ?? '';
131
+ }
132
+ catch {
133
+ // Only the single HEAD read is attempted, so the sole reachable failure is
134
+ // a missing repository (or unreadable HEAD file): the branch group drops out.
135
+ return '';
136
+ }
137
+ }
138
+ /**
139
+ * Reduce a session id to a filename-safe /export default-name suffix. Session
140
+ * ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
141
+ * user text: path separators must never leak into the default export filename
142
+ * (which would escape the session cwd).
143
+ * @param id - the session id.
144
+ * @returns at most the last 8 filename-safe characters.
145
+ */
146
+ export function exportSessionIdSuffix(id) {
147
+ return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8);
148
+ }
149
+ /**
150
+ * Run the ordered quit cleanup, then request exit. Every step rejection is
151
+ * contained (reported through `onError`) so a failed flush or dispose never
152
+ * skips the remaining cleanup; the exit request is always reached exactly
153
+ * once.
154
+ * @param steps - the cleanup steps in dependency order (settle the visible
155
+ * session, await the final in-flight composition, await durable recall).
156
+ * @param exit - the terminal exit request (code 0).
157
+ * @param onError - optional failure sink; called once per failing step and
158
+ * itself contained, so a throwing sink cannot abort the sequence.
159
+ * @returns the names of the steps that started, in order (for tests).
160
+ */
161
+ export async function runQuitSequence(steps, exit, onError) {
162
+ const started = [];
163
+ for (const step of steps) {
164
+ started.push(step.name);
165
+ try {
166
+ await step.run();
167
+ }
168
+ catch (error) {
169
+ try {
170
+ onError?.(step.name, error);
171
+ }
172
+ catch {
173
+ // The failure sink must never abort the cleanup sequence.
174
+ }
175
+ }
176
+ }
177
+ try {
178
+ exit(0);
179
+ }
180
+ catch {
181
+ // The exit request itself must not become an unhandled rejection.
182
+ }
183
+ return started;
184
+ }
185
+ /**
186
+ * Replace one queued message's text while keeping its attachments. A queue
187
+ * edit rewrites what the user typed, not what they attached: image and file
188
+ * blocks ride through in delivery order (text first, then attachments, the
189
+ * shape {@link deliverLine} submits). Dropping them here would silently strip
190
+ * an attachment the user already confirmed, so this is the edit's single
191
+ * definition and the panel's read-only marker only mirrors it.
192
+ */
193
+ export function queueEditContent(content, text) {
194
+ const attachments = content.filter(block => block.type !== 'text');
195
+ return [{ type: 'text', text }, ...attachments];
196
+ }
197
+ /**
198
+ * Apply one terminal queue mutation to the live inbox. The decision and the
199
+ * inbox change are pure over the supplied handles so every branch is testable
200
+ * without an agent; steering itself is injected because it wakes the driver
201
+ * rather than mutating the inbox. The durable inbox splices remain the UI's
202
+ * single source of truth — this helper never reports a state the inbox did not
203
+ * actually reach.
204
+ * @param inbox - the live agent inbox (pending lists plus its mutators).
205
+ * @param status - the agent's lifecycle status; steering needs `running`.
206
+ * @param messageId - identity of the queued message to mutate.
207
+ * @param action - the requested mutation.
208
+ * @param steer - submits the removed message as next-step steering.
209
+ * @returns the outcome the caller reports.
210
+ */
211
+ export function applyQueueMutation(inbox, status, messageId, action, steer) {
212
+ const id = MessageId(messageId);
213
+ const message = inbox.nextTurn.find(candidate => candidate.id === id);
214
+ if (message === undefined)
215
+ return 'unavailable';
216
+ switch (action.kind) {
217
+ case 'remove':
218
+ return inbox.remove(id) ? 'removed' : 'unavailable';
219
+ case 'edit':
220
+ if (action.text.trim() === '')
221
+ return 'empty';
222
+ inbox.replace(id, createUserMessage({
223
+ content: queueEditContent(message.content, action.text),
224
+ source: message.source,
225
+ }));
226
+ return 'edited';
227
+ case 'steer':
228
+ if (status !== 'running')
229
+ return 'steerUnavailable';
230
+ // Steer promotes the message out of next-turn, so a failing submit must
231
+ // put it back: the row the user was looking at never just disappears.
232
+ if (!inbox.remove(id))
233
+ return 'unavailable';
234
+ try {
235
+ steer(message);
236
+ }
237
+ catch (error) {
238
+ inbox.append('next-turn', message);
239
+ throw error;
240
+ }
241
+ return 'steered';
242
+ }
243
+ }
244
+ /**
245
+ * Cancel the active turn while keeping the next-turn queue, then wake the
246
+ * driver again so the preserved messages actually run. `cancel` clears
247
+ * pending work by default and never wakes the driver on its own, so the queue
248
+ * is captured first and re-submitted afterwards: a waking submission latches
249
+ * the wake while the aborted activity converges to idle, which is what turns
250
+ * "preserved" into "sent next" instead of "parked forever". Next-step
251
+ * steering is deliberately dropped — it belonged to the cancelled turn.
252
+ * @param agent - the live agent handle.
253
+ * @returns how many queued messages were preserved across the abort.
254
+ */
255
+ export function cancelPreservingQueue(agent) {
256
+ const queued = [...agent.inbox.nextTurn];
257
+ agent.cancel({ kind: 'user' });
258
+ for (const message of queued)
259
+ agent.followup(message);
260
+ return queued.length;
261
+ }
262
+ /**
263
+ * Whether a tagged submission still belongs to the active session. Attachment
264
+ * prepares resolve on the microtask timeline, while a queued session switch
265
+ * remounts the app asynchronously — the composing instance's unmount cleanup
266
+ * runs too late to abort, so the delivery itself carries the composing
267
+ * session's full id and the runner drops it here when the world moved on.
268
+ * An untagged (synchronous) or pending-session ('') submission always passes.
269
+ */
270
+ export function submissionBelongsToSession(origin, activeSessionId) {
271
+ return origin === undefined || origin === '' || origin === activeSessionId;
272
+ }
273
+ /**
274
+ * Root-log catalog facts a resumed session must replay into the subagent
275
+ * feed: constructor seeds never fire on the live bus, so without this the
276
+ * children of a resumed session vanish behind a restart. The empty-child
277
+ * placeholder row (childId '') is a placeholder, not a child, and stays out.
278
+ */
279
+ export function subagentCatalogSeed(events) {
280
+ return events.filter((event) => event.type === 'subagent/catalog' && event.data.childId !== '');
281
+ }
282
+ /**
283
+ * Map one cross-session full-text hit onto the /search panel's row (pure).
284
+ * Labels fall back to the short id form — the engine's hit carries the
285
+ * strongest matching event, not the title observation.
286
+ */
287
+ export function searchHitToRow(hit) {
288
+ const subagent = hit.header.origin === 'subagent';
289
+ const cwd = hit.header.cwd ?? '';
290
+ // Session cwds may arrive in either separator style regardless of the
291
+ // observing host (a workspace synced from Windows), so split on both.
292
+ const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? '';
293
+ const preset = hit.header.agentPreset ?? '';
294
+ const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim();
295
+ return {
296
+ id: hit.header.id,
297
+ label: hit.header.id.slice(-12),
298
+ detail: [workspace, preset].filter(part => part !== '').join(' · '),
299
+ snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
300
+ updatedAt: hit.bestMatch.time,
301
+ subagent,
302
+ resumable: !subagent,
303
+ };
304
+ }
305
+ /**
306
+ * Decide the next Shift+Tab station. The cycle keeps the preset table's
307
+ * own order (most restrictive first) and inserts ONE plan station between
308
+ * the most restrictive preset and the wrap target: with the shipped three
309
+ * presets the user sees workspace-write → danger-full-access → read-only
310
+ * → plan → workspace-write. Plan IS the most restrictive preset plus the
311
+ * plan prompt layer — entering it switches nothing (the cycle is already
312
+ * parked on read-only), and leaving it lands on the next preset after the
313
+ * most restrictive one. Without the /plan command the cycle is exactly the
314
+ * preset table.
315
+ *
316
+ * `planIntent` covers the committed fold's commit lag: upstream queues a
317
+ * plan switch during an open turn (and the command pipeline is async even
318
+ * idle), so the durable plan/mode event lands AFTER the press that chose
319
+ * it. While an intent from an earlier press is in flight it — not the
320
+ * stale committed fold — decides the station, so repeated presses advance
321
+ * the cycle instead of re-issuing the same plan transition (the stuck
322
+ * plan-on/plan-off toggle). Undefined falls back to the committed fold.
323
+ */
324
+ export function planCycleDecision(input) {
325
+ const names = input.names;
326
+ if (names.length === 0)
327
+ return undefined;
328
+ const first = names[0];
329
+ if ((input.planIntent ?? input.inPlan) === true)
330
+ return { kind: 'plan-off', preset: names[1] ?? first };
331
+ const at = names.indexOf(input.current);
332
+ if (at === 0 && input.planAvailable)
333
+ return { kind: 'plan-on' };
334
+ return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first };
335
+ }
336
+ /**
337
+ * Order-preserving gate for composer input while the startup prompt/images
338
+ * are still preparing. Anything submitted before the startup delivery settles
339
+ * queues and flushes afterwards in submit order, so the initial request can
340
+ * never be overtaken by typing that raced a slow image preparation. The flush
341
+ * also runs when the startup delivery fails: user input is never stranded.
342
+ */
343
+ export class StartupInputGate {
344
+ queued = [];
345
+ pending = false;
346
+ deliver;
347
+ constructor(deliver) {
348
+ this.deliver = deliver;
349
+ }
350
+ /** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
351
+ submit(submission) {
352
+ if (this.pending)
353
+ this.queued.push(submission);
354
+ else
355
+ this.deliver(submission);
356
+ }
357
+ /**
358
+ * Run the startup delivery — the callback receives the direct-delivery sink
359
+ * for the startup prompt itself — then flush everything that queued behind
360
+ * it, in order, even when the callback rejects.
361
+ */
362
+ async run(startup) {
363
+ this.pending = true;
364
+ try {
365
+ await startup(submission => this.deliver(submission));
366
+ }
367
+ finally {
368
+ this.pending = false;
369
+ const queued = this.queued.splice(0);
370
+ for (const submission of queued)
371
+ this.deliver(submission);
372
+ }
373
+ }
374
+ }
375
+ /**
376
+ * Resolve the invocation's target session against the persisted headers.
377
+ * @param startup - the parsed startup flags.
378
+ * @param persistence - the persistence service; required for resume/latest.
379
+ * @param cwd - the working directory `--continue` filters by.
380
+ * @returns the target identity.
381
+ * @throws with a user-facing message when the flags name nothing resolvable.
382
+ */
383
+ export async function resolveTarget(startup, persistence, cwd) {
384
+ if (startup.kind === 'fresh')
385
+ return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode };
386
+ if (startup.kind === 'named') {
387
+ // The id must not exist yet: reject before any Agent composition when the
388
+ // backend can tell us (a live collision is still caught by the session
389
+ // store at create time).
390
+ if (persistence !== undefined) {
391
+ const headers = (await persistence.list()).map(snapshot => snapshot.header);
392
+ if (headers.some(header => header.id === startup.sessionId)) {
393
+ throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`);
394
+ }
395
+ }
396
+ return { sessionId: startup.sessionId, resume: false, mode: startup.mode };
397
+ }
398
+ if (persistence === undefined) {
399
+ throw new Error('cannot resolve the requested session: session persistence is not configured');
400
+ }
401
+ const headers = (await persistence.list()).map(snapshot => snapshot.header);
402
+ if (startup.kind === 'resume') {
403
+ const matched = matchSessionId(headers, startup.sessionId);
404
+ // Subagent conversations are read-only everywhere else; the CLI must not
405
+ // be a back door into appending root turns to a child's durable log.
406
+ if (isSubagentSession(matched)) {
407
+ throw new Error('subagent conversations are read-only; resume a root session');
408
+ }
409
+ return { sessionId: matched.id, resume: true };
410
+ }
411
+ // --continue: the newest persisted ROOT session whose header pins this cwd.
412
+ const newest = newestRootForCwd(headers, cwd);
413
+ if (newest === undefined)
414
+ throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
415
+ return { sessionId: newest.id, resume: true };
416
+ }
417
+ /**
418
+ * Resolve a bounded command preview for one pending approval: the request
419
+ * contract carries no arguments, so the bar self-serves from the transcript
420
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
421
+ * @param events - the transcript entries to search.
422
+ * @param callId - the tool call the question is about, when the asker had one.
423
+ * @param toolName - the tool the question is about.
424
+ * @returns a bounded preview line, '' when nothing useful resolves.
425
+ */
426
+ function approvalCommandPreview(events, callId, toolName) {
427
+ if (callId === undefined)
428
+ return '';
429
+ const entry = events.find(candidate => candidate.kind === 'tool' && candidate.callId === callId);
430
+ if (entry === undefined)
431
+ return '';
432
+ const args = entry.arguments ?? '';
433
+ return toolArgumentsPreview(args, toolName);
434
+ }
435
+ /**
436
+ * Run the interactive terminal session: resolve the target session, create or
437
+ * resume one Agent, mount the app, and keep the process alive until the user
438
+ * quits.
439
+ * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
440
+ * @param startup - the parsed invocation flags.
441
+ * @param io - process-facing effects.
442
+ */
443
+ async function run(ctx, startup, io) {
444
+ // Loader siblings mount concurrently. Await the complete application before
445
+ // creating an Agent so its scoped tools and adapters are not half-composed.
446
+ await ctx.get('loader')?.await();
447
+ const agents = ctx.get('agents');
448
+ const defaultModel = ctx.get('agentDefaultModel');
449
+ const sessions = ctx.get('sessions');
450
+ const persistence = ctx.get('sessionPersistence');
451
+ const sessionQuery = ctx.get('sessionQuery');
452
+ // Early process shutdown can dispose the tree while settlement is pending.
453
+ if (agents === undefined || defaultModel === undefined || sessions === undefined)
454
+ return;
455
+ const cwd = process.cwd();
456
+ // Live deployment default (web selectModel parity): read on every use, not
457
+ // snapshotted at launch, so a /model pick this process saves becomes the
458
+ // default for sessions composed afterwards without a restart.
459
+ const currentDefaults = () => defaultModel.currentSelection();
460
+ const presets = agentPresetsFrom(ctx);
461
+ if (presets === undefined)
462
+ throw new Error('agent preset service is unavailable; check the dsh-code bundle patch');
463
+ const permissionPresets = permissionPresetsFrom(ctx);
464
+ // A bare fresh launch stays transient: no Agent or session is composed, and
465
+ // nothing is persisted, until the user's first real input. Explicit flags
466
+ // (--resume/--continue/--session/--mode) keep the eager create/resume path.
467
+ const lazy = startup.kind === 'fresh' && startup.mode === undefined;
468
+ /** Prepare a complete next session before disturbing the currently visible one. */
469
+ const prepare = async (next) => {
470
+ const nextCwd = next.cwd ?? cwd;
471
+ // A bare launch can pick a model before any session exists: the process
472
+ // keeps that explicit choice and every prepared session starts from it
473
+ // (the documented precedence: explicit pick > session header > default).
474
+ const selectionState = pendingSelection === undefined
475
+ ? {}
476
+ : { picked: pendingSelection };
477
+ let mode = next.resume ? next.mode : next.mode ?? pendingMode;
478
+ // An explicit `--mode` or the settings-layer service default may still name
479
+ // an id an upstream rename retired (code → ptc); normalize both.
480
+ if (!next.resume)
481
+ mode = (await presets.resolve(normalizePresetId(mode ?? presets.defaultId))).id;
482
+ // 0.1.5 AgentSetup passes the composed agent as its second argument (the
483
+ // former `ctx.agent` accessor is gone); the preset mount still needs the
484
+ // agent-scoped context.
485
+ const setup = async (agentCtx, agent) => {
486
+ const sessionPreset = next.resume
487
+ ? resolvePreset(agent.session)
488
+ : mode;
489
+ const mounted = await presets.mount(agentCtx, sessionPreset);
490
+ mode = mounted.id;
491
+ const selection = {
492
+ get current() {
493
+ return resolveEffectiveSelection(selectionState.picked, agent.session.requestHeader()?.config, currentDefaults());
494
+ },
495
+ set current(value) { selectionState.picked = value; },
496
+ assembled: undefined,
497
+ };
498
+ installModelSelection(agentCtx, selection);
499
+ };
500
+ // AgentOptions seed the loop's fallback route; effort rides the selection
501
+ // ref (installModelSelection), so only the provider/model pair is seeded.
502
+ const seedOptions = pendingSelection === undefined
503
+ ? { provider: currentDefaults().provider, model: currentDefaults().model }
504
+ : { provider: pendingSelection.provider, model: pendingSelection.model };
505
+ const handle = next.resume
506
+ ? await agents.resume({
507
+ resumeSessionId: SessionId(next.sessionId),
508
+ agentOptions: seedOptions,
509
+ // Quit aborts an in-flight composition so the exit wait never hangs
510
+ // on a prepare that cannot settle; upstream rolls the creation back.
511
+ signal: quitAbort.signal,
512
+ setup,
513
+ })
514
+ : await agents.create({
515
+ sessionId: SessionId(next.sessionId),
516
+ meta: {
517
+ cwd: nextCwd,
518
+ agentPreset: mode,
519
+ ...(next.parentSession === undefined ? {} : { parentSession: next.parentSession }),
520
+ ...(next.origin === undefined ? {} : { origin: next.origin }),
521
+ // 0.1.5 fork lineage: the seed marker lives on the metadata and the
522
+ // inherited prefix length on the top-level option (the v0 header's
523
+ // numeric `seedLength` field is gone from the create contract).
524
+ ...(next.seedLength === undefined ? {} : { isSeeded: true }),
525
+ },
526
+ ...(next.seedLength === undefined ? {} : { inheritedEventCount: SessionLogOffset(next.seedLength) }),
527
+ ...(next.seed === undefined ? {} : { seed: next.seed }),
528
+ agentOptions: seedOptions,
529
+ signal: quitAbort.signal,
530
+ setup,
531
+ });
532
+ const session = handle.agent.session;
533
+ if (!next.resume && permissionPresets !== undefined) {
534
+ applyPendingPermission(permissionPresets, session, pendingPermission);
535
+ }
536
+ const seedEvents = session.snapshotEvents();
537
+ // Resume precedence, middle layer: the log's unconsumed `model/selection`
538
+ // (a pick the web host recorded that no request ever assembled) outranks
539
+ // the older request header; an in-process pick still outranks both.
540
+ if (next.resume && selectionState.picked === undefined) {
541
+ const pending = pendingModelSelection(seedEvents);
542
+ if (pending !== undefined)
543
+ selectionState.picked = pending;
544
+ }
545
+ return {
546
+ handle,
547
+ agent: handle.agent,
548
+ session,
549
+ store: createTranscriptStore(seedEvents),
550
+ mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
551
+ mode: mode ?? 'standard',
552
+ selection: selectionState,
553
+ resumed: next.resume,
554
+ catalogSeed: subagentCatalogSeed(seedEvents),
555
+ };
556
+ };
557
+ let active;
558
+ let agent;
559
+ let session;
560
+ let store = createTranscriptStore();
561
+ // Live subagent activity (child sessions of the current root): one bounded
562
+ // row per child, folded from the same event bus the transcript feeds on.
563
+ const subagents = createSubagentFeed();
564
+ // Pre-session @file completion runs the official search over the launch
565
+ // cwd (model- and session-independent); the prepare/activate paths replace
566
+ // this with the agent-scoped instance once a session exists.
567
+ let mentions = createMentions(ctx, undefined, cwd);
568
+ /** Explicit model pick made before any session exists (a bare launch). */
569
+ let pendingSelection;
570
+ /** Agent preset selected before the first session exists. */
571
+ let pendingMode;
572
+ /** Ordered pre-session preset resolutions; first composition awaits them. */
573
+ let pendingModeWork = Promise.resolve();
574
+ /** Permission preset selected before the first session exists. */
575
+ let pendingPermission;
576
+ /**
577
+ * Plan-mode choice made before the first session exists: materialized as a
578
+ * /plan registry command delivered ahead of the first queued input when the
579
+ * session composes, so the first assembled step already plans.
580
+ */
581
+ let pendingPlan = false;
582
+ /**
583
+ * In-flight mid-session plan choice from the Shift+Tab cycle. Upstream
584
+ * queues a plan switch during an open turn (and the command pipeline is
585
+ * async even idle), so the committed plan/mode fold lags the press that
586
+ * chose it; the cycle reads this intent until the durable event lands,
587
+ * then the session/event funnel clears it.
588
+ */
589
+ let planIntent;
590
+ /**
591
+ * Whether the pre-session effective preset composes plan mode, answered by
592
+ * the presets service composition inventory (minimal does not). Cached and
593
+ * refreshed whenever the pending mode moves; unknown reads as unavailable
594
+ * so one keypress at most lands before the answer arrives.
595
+ */
596
+ let preSessionPlanAvailable = false;
597
+ let preSessionPlanKnown = false;
598
+ const refreshPreSessionPlan = () => {
599
+ if (presets === undefined) {
600
+ preSessionPlanAvailable = false;
601
+ preSessionPlanKnown = true;
602
+ return;
603
+ }
604
+ preSessionPlanKnown = false;
605
+ void presets.compositionInventory().then(inventory => {
606
+ const id = pendingMode ?? normalizePresetId(presets.defaultId);
607
+ preSessionPlanAvailable = inventory.some(composition => composition.id === id
608
+ && composition.rows.some(row => row.moduleName === '@deepseek-ai/dsh-plan-mode' && row.enabled !== false));
609
+ preSessionPlanKnown = true;
610
+ }, () => {
611
+ preSessionPlanAvailable = false;
612
+ preSessionPlanKnown = true;
613
+ });
614
+ };
615
+ refreshPreSessionPlan();
616
+ /**
617
+ * Monotonic session epoch: bumped on every successful activation, on every
618
+ * first-session creation, and on quit. Async callbacks (mention prepares,
619
+ * command executions) capture it at call time and drop their result when it
620
+ * changed, so a stale callback can never deliver to an agent that is no
621
+ * longer on screen.
622
+ */
623
+ let epoch = 0;
624
+ /** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
625
+ const quitAbort = new AbortController();
626
+ /** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
627
+ const pendingControllers = new Set();
628
+ const abortPendingControllers = () => {
629
+ for (const controller of [...pendingControllers]) {
630
+ pendingControllers.delete(controller);
631
+ controller.abort();
632
+ }
633
+ };
634
+ /** The in-flight session-composition turn (create/resume/activate), if any. */
635
+ let composing;
636
+ /**
637
+ * Run one session composition exclusively: concurrent compositions wait
638
+ * their turn, so a bare-launch first-session creation and a /resume
639
+ * activation can never compose agents in parallel (the loser would leak its
640
+ * agent or mis-deliver). Errors propagate to the caller; the shared slot
641
+ * always continues.
642
+ */
643
+ const compose = (work) => {
644
+ const turn = (composing ?? Promise.resolve()).catch(() => { }).then(work);
645
+ composing = turn.catch(() => { });
646
+ return turn;
647
+ };
648
+ if (!lazy) {
649
+ const target = await resolveTarget(startup, persistence, cwd);
650
+ const prepared = await prepare(target);
651
+ active = prepared;
652
+ agent = prepared.agent;
653
+ session = prepared.session;
654
+ store = prepared.store;
655
+ mentions = prepared.mentions;
656
+ // Replayed catalog facts rebuild the resumed session's child rows before
657
+ // the first render (the live handler only folds events from now on).
658
+ for (const event of prepared.catalogSeed)
659
+ subagents.apply(event.data.childId, event);
660
+ }
661
+ // Seed the transcript from the full session log: constructor seeds never
662
+ // fire on `session/event`, so a resumed session paints its history once
663
+ // before the first render. The handler reads the current session/store, so
664
+ // the deferred first session of a bare launch is covered by the same feed.
665
+ const off = ctx.on('session/event', (subject, event) => {
666
+ if (session === undefined)
667
+ return;
668
+ if (subject.id === session.id) {
669
+ store.apply(event);
670
+ // The committed plan fold caught up (or diverged via a typed /plan or
671
+ // an approved plan review): the durable event is the live truth again,
672
+ // so the cycle's in-flight intent retires.
673
+ if (event.type === 'plan/mode')
674
+ planIntent = undefined;
675
+ // The parent-owned subagent catalog rides the ROOT log (0.1.5); each
676
+ // fact describes one child, so it feeds that child's live row.
677
+ if (event.type === 'subagent/catalog' && event.data.childId !== '')
678
+ subagents.apply(event.data.childId, event);
679
+ return;
680
+ }
681
+ // Child sessions (subagent conversations this root spawned) fold into
682
+ // the bounded live-activity feed, never the transcript: the root stays
683
+ // the only durable transcript truth while a running subagent remains
684
+ // visible. Lineage comes from the child header, same field the session
685
+ // directory uses to tag `↳` rows.
686
+ if (subject.header.parentSession === session.id && subject.header.origin === 'subagent')
687
+ subagents.apply(subject.id, event);
688
+ });
689
+ // Live assistant typing (session-log v2+): durable logs are settlement-only,
690
+ // so the streaming tails ride the process-local `agent/assistant-stream`
691
+ // frames of the current root agent. Settlement events clear the tails when
692
+ // they land (always before a committed end frame); an abandoned attempt's
693
+ // partial tail is dropped by the store on its end frame.
694
+ ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
695
+ if (agent === undefined || source.id !== agent.id)
696
+ return;
697
+ store.applyStreamFrame(frame);
698
+ });
699
+ const commands = watchCommands(ctx);
700
+ if (agent !== undefined)
701
+ commands.setAgent(agent);
702
+ const skills = watchSkills(ctx, cwd);
703
+ if (agent !== undefined)
704
+ skills.setAgent(agent);
705
+ // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
706
+ // claimed, every other ask falls through to the fail-closed waterfall. The
707
+ // owner predicate is empty until the first session exists.
708
+ const approval = mountApprovalAnswerer(ctx, candidate => agent !== undefined && candidate.id === agent.id, request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
709
+ // Subagent model routing. The kernel seeds child agents from the parent's
710
+ // CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
711
+ // /model switch never touches — delegated work would keep running on the
712
+ // launch-time route. This plugin-level listener mirrors installModelSelection
713
+ // for subagent-origin requests (scope filtering delivers the agent subject
714
+ // inside the payload): the explicit /subagent override wins, else the root's
715
+ // effective selection (explicit pick > session header > deployment default).
716
+ // Effort rides the selection exactly like the kernel listener applies it.
717
+ let subagentOverride;
718
+ ctx.on('agent/request', (payload, next) => {
719
+ const subject = payload.agent;
720
+ const header = subject.session.header;
721
+ if (header.parentSession === undefined && header.origin !== 'subagent')
722
+ return next();
723
+ // Only the ACTIVE session's explicit pick may steer a subagent request.
724
+ // During a switch window the old agent can still be mid-flight; routing
725
+ // it by the NEW session's pick sent one of its requests to the wrong
726
+ // model. A subject outside the active tree falls back to its own request
727
+ // header (plus any explicit /subagent override, which is user intent).
728
+ const activeAgent = active;
729
+ const belongsToActive = activeAgent !== undefined
730
+ && (header.parentSession ?? subject.session.id) === activeAgent.session.id;
731
+ const picked = subagentOverride
732
+ ?? resolveEffectiveSelection(belongsToActive && activeAgent !== undefined ? (activeAgent.selection.picked ?? pendingSelection) : undefined, subject.session.requestHeader()?.config, currentDefaults());
733
+ return next().then(resolved => applyModelSelectionToConfig(resolved, picked));
734
+ });
735
+ // ask_user_question answerer: one waterfall listener, one request on
736
+ // screen at a time. Plan reviews (exit_plan_mode) arrive through this same
737
+ // pipe; sibling answerers stay usable through the claim/defer split.
738
+ const questions = mountQuestionProvider(ctx, candidate => agent !== undefined && candidate.id === agent.id);
739
+ // The bridge the React app registers on mount: local notices from the
740
+ // process side (unknown commands, switch confirmations, cancels).
741
+ const bridge = { notify: () => { } };
742
+ // Same-id capability inheritance. Catalog capabilities flow by route key,
743
+ // not model id, so a hand-declared relay model without an explicit
744
+ // reasoningEfforts declaration serves no reasoning levels and offers no
745
+ // effort picker. This background pass materializes declarations from
746
+ // same-id donors (sibling settings entries first, then other routes'
747
+ // advertised levels) over the panel's settings.mutate path, where the
748
+ // upstream serviceability gate still rejects invalid writes atomically.
749
+ // The debounce coalesces the settings/adapters event pair; the applier
750
+ // skips only a same-source same-revision echo of its own write, so the
751
+ // loop converges without ever ignoring a real external edit.
752
+ const capabilitySyncDebounceMs = 400;
753
+ const runCapabilitySync = () => {
754
+ void syncModelCapabilities(ctx, bridge.notify);
755
+ };
756
+ let capabilitySyncTimer;
757
+ const scheduleCapabilitySync = () => {
758
+ if (capabilitySyncTimer !== undefined)
759
+ clearTimeout(capabilitySyncTimer);
760
+ capabilitySyncTimer = setTimeout(() => {
761
+ capabilitySyncTimer = undefined;
762
+ runCapabilitySync();
763
+ }, capabilitySyncDebounceMs);
764
+ };
765
+ const offCapabilitySync = [
766
+ ctx.on('settings/document-updated', scheduleCapabilitySync),
767
+ ctx.on('llm/adapters-updated', scheduleCapabilitySync),
768
+ ];
769
+ scheduleCapabilitySync();
770
+ // /statusline persistence: one user-level JSON file under the DSH home.
771
+ // Missing file means defaults; a corrupt file degrades to defaults with a
772
+ // surfaced warning (the customization is user-authored, never silent).
773
+ const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json');
774
+ let statuslineWarning;
775
+ let statuslineItems = [];
776
+ try {
777
+ statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items);
778
+ }
779
+ catch (error) {
780
+ statuslineItems = parseStatuslineItems(undefined);
781
+ if (error.code !== 'ENOENT') {
782
+ statuslineWarning = error instanceof Error ? error.message : String(error);
783
+ }
784
+ }
785
+ // Serialized, crash-atomic writes for the user-level JSON files: the chain
786
+ // orders rapid consecutive saves (the LAST snapshot wins on disk), each
787
+ // write goes through a sibling temp file + rename, and quit waits for the
788
+ // flush exactly like it waits for the recall history.
789
+ const settingsPersistence = createUserSettingsPersistence();
790
+ const saveStatusline = (items) => {
791
+ statuslineItems = [...items];
792
+ void settingsPersistence.save(statuslinePath, JSON.stringify({ items }, null, 2) + '\n')
793
+ .catch((writeError) => {
794
+ bridge.notify(t('notice.statuslineSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error');
795
+ });
796
+ };
797
+ // /vscode-keys: detect the hosting editor's user keybindings.json and pass
798
+ // Ctrl+R through the workbench. One marker file under the DSH home keeps
799
+ // the startup hint a once-per-install event.
800
+ const editorKeysEnv = {
801
+ env: process.env,
802
+ paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
803
+ flagPath: join(homedir(), '.dsh', 'dsh-code', 'editor-keys.json'),
804
+ };
805
+ const applyEditorKeys = () => applyCtrlRPassthrough(editorKeysEnv);
806
+ // /theme persistence: one user-level JSON file under the DSH home, mirroring
807
+ // the statusline file. A missing file means the dark default; a corrupt file
808
+ // degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
809
+ // auto detection > dark (auto detection itself is a later enhancement and
810
+ // currently falls back to dark inside theme.ts).
811
+ const themePath = join(homedir(), '.dsh', 'dsh-code', 'theme.json');
812
+ let themeWarning;
813
+ if (startup.theme === undefined) {
814
+ try {
815
+ setTheme(parseThemeName(readSettingsObject(themePath).theme));
816
+ }
817
+ catch (error) {
818
+ if (error.code !== 'ENOENT') {
819
+ themeWarning = error instanceof Error ? error.message : String(error);
820
+ }
821
+ }
822
+ }
823
+ else {
824
+ setTheme(startup.theme);
825
+ }
826
+ const saveTheme = (name) => {
827
+ setTheme(name);
828
+ void settingsPersistence.save(themePath, JSON.stringify({ theme: name }, null, 2) + '\n')
829
+ .catch((writeError) => {
830
+ bridge.notify(t('notice.themeSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error');
831
+ });
832
+ };
833
+ // /language persistence: one user-level JSON file beside theme.json. A
834
+ // missing file means English; a corrupt file degrades to English with a
835
+ // surfaced warning.
836
+ const languagePath = join(homedir(), '.dsh', 'dsh-code', 'language.json');
837
+ let languageWarning;
838
+ try {
839
+ setLanguage(parseLanguageName(readSettingsObject(languagePath).language));
840
+ }
841
+ catch (error) {
842
+ if (error.code !== 'ENOENT') {
843
+ languageWarning = error instanceof Error ? error.message : String(error);
844
+ }
845
+ }
846
+ const saveLanguage = (name) => {
847
+ setLanguage(name);
848
+ void settingsPersistence.save(languagePath, JSON.stringify({ language: name }, null, 2) + '\n')
849
+ .catch((writeError) => {
850
+ bridge.notify(t('notice.languageSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error');
851
+ });
852
+ };
853
+ // /animation persistence: one user-level JSON file under the DSH home,
854
+ // mirroring the theme file. A missing file means animations are on; a
855
+ // corrupt file degrades to on with a surfaced warning. Only an explicit
856
+ // `false` disables (parseAnimationsPref), so hand-edited or partial files
857
+ // never silently freeze the UI.
858
+ const animationsPath = join(homedir(), '.dsh', 'dsh-code', 'animations.json');
859
+ let animationsEnabled = true;
860
+ let animationsWarning;
861
+ try {
862
+ // A literal `null` file reads as corruption and surfaces the warning the
863
+ // block above promises, instead of a property access on `null`.
864
+ animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations);
865
+ }
866
+ catch (error) {
867
+ if (error.code !== 'ENOENT') {
868
+ animationsWarning = error instanceof Error ? error.message : String(error);
869
+ }
870
+ }
871
+ const saveAnimations = (enabled) => {
872
+ void settingsPersistence.save(animationsPath, JSON.stringify({ animations: enabled }, null, 2) + '\n')
873
+ .catch((writeError) => {
874
+ bridge.notify(t('notice.animationsSaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error');
875
+ });
876
+ };
877
+ // Global input recall (Codex composer-history contract): one JSONL file
878
+ // under the DSH home. A missing file means an empty history; unreadable or
879
+ // corrupt content degrades to the valid lines it could parse, silently —
880
+ // recall is a convenience surface, never a gate.
881
+ const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl');
882
+ let inputHistory = [];
883
+ let historyWriteChain = Promise.resolve();
884
+ try {
885
+ const rawHistory = readFileSync(historyPath, 'utf8');
886
+ inputHistory = parseHistoryFile(rawHistory);
887
+ // Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
888
+ // accumulate in an append-only file; rewrite the canonical form once
889
+ // per boot. The rewrite rides the same chain, so it lands before any
890
+ // submission the user types next. An entry another terminal appends
891
+ // inside the read-to-rename window is dropped — a millisecond-scale
892
+ // gap at boot that recall tolerates by design.
893
+ if (needsCompaction(rawHistory)) {
894
+ historyWriteChain = historyWriteChain
895
+ .then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
896
+ .catch(() => { });
897
+ }
898
+ }
899
+ catch {
900
+ inputHistory = [];
901
+ }
902
+ /**
903
+ * Serialized history writes: each submission appends one JSON line at the
904
+ * end of the file, so concurrent terminals add entries after each other
905
+ * instead of overwriting snapshots they read at their own boot. A
906
+ * multi-line draft still occupies one physical line (JSON escapes the
907
+ * newline), and a regular-length line reaches the disk as one positioned
908
+ * write; an oversized paste may interleave mid-line, which the next
909
+ * parse simply drops.
910
+ */
911
+ const recordHistory = (text) => {
912
+ if (text === '')
913
+ return;
914
+ inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES);
915
+ historyWriteChain = historyWriteChain
916
+ .then(() => mkdir(dirname(historyPath), { recursive: true }))
917
+ .then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
918
+ .catch((writeError) => {
919
+ bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error');
920
+ });
921
+ };
922
+ /** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
923
+ const updateQueued = (messageId, action) => {
924
+ const current = agent;
925
+ if (current === undefined)
926
+ return;
927
+ try {
928
+ const outcome = applyQueueMutation(current.inbox, current.status, messageId, action, message => current.steer(message));
929
+ switch (outcome) {
930
+ case 'removed':
931
+ bridge.notify(t('notice.queueCancelled'));
932
+ return;
933
+ case 'edited':
934
+ bridge.notify(t('notice.queueEdited'));
935
+ return;
936
+ case 'steered':
937
+ bridge.notify(t('notice.queueSteered'));
938
+ return;
939
+ case 'empty':
940
+ bridge.notify(t('notice.queueEditEmpty'), 'warning');
941
+ return;
942
+ case 'steerUnavailable':
943
+ bridge.notify(t('notice.queueSteerUnavailable'), 'warning');
944
+ return;
945
+ case 'unavailable':
946
+ bridge.notify(t('notice.queueUnavailable'), 'warning');
947
+ return;
948
+ }
949
+ }
950
+ catch (error) {
951
+ bridge.notify(t('notice.queueActionFailed', { message: error instanceof Error ? error.message : String(error) }), 'error');
952
+ }
953
+ };
954
+ // The mount handle lives in a box: quit closes over it, while the mount
955
+ // itself is created after quit (the App element needs quit as a prop).
956
+ const mountRef = {};
957
+ let quitting = false;
958
+ const quit = (fast = false) => {
959
+ if (quitting)
960
+ return;
961
+ quitting = true;
962
+ switchQueue.cancel();
963
+ // Stale prepares/commands die with the session they were for. Aborting
964
+ // the composition signal lets a never-settling prepare reject, so the
965
+ // exit wait below cannot hang (upstream rolls the creation back).
966
+ abortPendingControllers();
967
+ quitAbort.abort();
968
+ epoch += 1;
969
+ off();
970
+ for (const dispose of offCapabilitySync)
971
+ dispose();
972
+ if (capabilitySyncTimer !== undefined)
973
+ clearTimeout(capabilitySyncTimer);
974
+ const currentSession = session;
975
+ const currentActive = active;
976
+ const report = (name, error) => {
977
+ internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`);
978
+ };
979
+ // A throwing unmount must not strand the terminal (stdin tap alive,
980
+ // keyboard protocol stacks unpopped) or skip the exit sequence below.
981
+ try {
982
+ mountRef.current?.unmount();
983
+ }
984
+ catch (error) {
985
+ report('unmount', error);
986
+ }
987
+ // One ordered cleanup: settle the visible session (if any — a bare launch
988
+ // that never composed one resolves immediately), then wait for the final
989
+ // in-flight composition (its work swallows errors and the quitting guard
990
+ // disposes any half-prepared agent), then flush the durable recall and
991
+ // the queued user-level settings writes, then request exit. `composing`
992
+ // and `historyWriteChain` are read at step run
993
+ // time, so a turn that was still being queued when quit ran is included.
994
+ // A failing step must never skip the remaining cleanup.
995
+ const steps = [
996
+ ...(currentSession === undefined || currentActive === undefined
997
+ ? []
998
+ : [
999
+ { name: 'flush', run: async () => { await sessions.flush(currentSession); internals.stderr.write('\nResume this session: dscode resume ' + currentSession.id + '\n'); } },
1000
+ { name: 'dispose', run: () => currentActive.handle.dispose() },
1001
+ ]),
1002
+ { name: 'composing', run: () => composing ?? Promise.resolve() },
1003
+ { name: 'history', run: () => historyWriteChain },
1004
+ { name: 'settings', run: () => settingsPersistence.flush() },
1005
+ ];
1006
+ if (fast)
1007
+ setTimeout(() => process.exit(0), 250);
1008
+ void runQuitSequence(steps, io.exit, report);
1009
+ };
1010
+ /** Run one slash line through the command registry (closed namespace). */
1011
+ const runSlash = (line) => {
1012
+ const currentAgent = agent;
1013
+ if (currentAgent === undefined)
1014
+ return;
1015
+ if (line.startsWith('/resume ')) {
1016
+ requestResume(line.slice(8).trim());
1017
+ return;
1018
+ }
1019
+ const registry = ctx.get('commands');
1020
+ if (registry === undefined) {
1021
+ bridge.notify(t('notice.commandRegistryMissing'), 'error');
1022
+ return;
1023
+ }
1024
+ const controller = new AbortController();
1025
+ const atEpoch = epoch;
1026
+ pendingControllers.add(controller);
1027
+ const finish = () => {
1028
+ pendingControllers.delete(controller);
1029
+ };
1030
+ // 0.1.5 registry.execute's third parameter admits submitted attachments
1031
+ // (images and file receipts); the TUI composer never attaches images to a
1032
+ // slash line, so every invocation is the empty batch (commands declaring
1033
+ // input.attachments still run attachment-free).
1034
+ void Promise.resolve().then(() => registry.execute(currentAgent, line, [], controller.signal)).then((execution) => {
1035
+ finish();
1036
+ // A switch/quit landed while the command ran: its fall-through must not
1037
+ // reach an agent that is no longer on screen.
1038
+ if (epoch !== atEpoch || agent !== currentAgent)
1039
+ return;
1040
+ if (execution === undefined) {
1041
+ // No command owns this line: send it verbatim so a user-invocable
1042
+ // skill gesture (`/skill-name`) reaches the host's tool-skill
1043
+ // pre-step injection — the web composer's same fall-through.
1044
+ try {
1045
+ currentAgent.followup(createUserMessage({
1046
+ content: [{ type: 'text', text: line }],
1047
+ source: { kind: 'user' },
1048
+ }));
1049
+ }
1050
+ catch (error) {
1051
+ bridge.notify(t('notice.commandFallbackFailed', { message: error instanceof Error ? error.message : String(error) }), 'error');
1052
+ }
1053
+ }
1054
+ }, (error) => {
1055
+ finish();
1056
+ if (epoch !== atEpoch || agent !== currentAgent)
1057
+ return;
1058
+ // A failed plan switch never appends the plan/mode event the cycle's
1059
+ // intent retirement waits for, so the in-flight choice dies here too —
1060
+ // otherwise every later Shift+Tab reads a phantom plan state.
1061
+ if (line === '/plan' || line === '/plan off')
1062
+ planIntent = undefined;
1063
+ bridge.notify(t('notice.commandFailed', { message: error instanceof Error ? error.message : String(error) }), 'error');
1064
+ });
1065
+ };
1066
+ /** Delivery serialization state: the chain's epoch pins it to one session. */
1067
+ let deliveryChain = { epoch: 0, tail: Promise.resolve() };
1068
+ /** Deliver one trimmed line to the live session, expanding mentions first. */
1069
+ const deliverLine = (line, images = [], mode = 'followup') => {
1070
+ const currentAgent = agent;
1071
+ const currentMentions = mentions;
1072
+ // The command registry is a closed namespace: slash lines run out of
1073
+ // band and never reach the model through this path.
1074
+ if (images.length === 0 && isSlashLine(line)) {
1075
+ runSlash(line);
1076
+ return;
1077
+ }
1078
+ let parsed;
1079
+ try {
1080
+ parsed = currentMentions.parse(line);
1081
+ }
1082
+ catch (error) {
1083
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error');
1084
+ return;
1085
+ }
1086
+ // Ordered delivery: the inbox order IS the user's message order. A line
1087
+ // with session mentions prepares asynchronously, and a later plain line
1088
+ // used to deliver synchronously past it. Every line now waits for the
1089
+ // previous line of the same session; an epoch change (switch/quit)
1090
+ // abandons the chain instead of gating the next session on the old one.
1091
+ if (deliveryChain.epoch !== epoch)
1092
+ deliveryChain = { epoch, tail: Promise.resolve() };
1093
+ const enqueueDelivery = (run) => {
1094
+ deliveryChain.tail = deliveryChain.tail.then(run);
1095
+ };
1096
+ const atEpoch = epoch;
1097
+ const deliver = (readable, context) => {
1098
+ // A switch/quit landed while the snapshot was being prepared: never
1099
+ // deliver to an agent that is no longer on screen.
1100
+ if (epoch !== atEpoch || agent !== currentAgent)
1101
+ return;
1102
+ // Session snapshots ride the inbox as model-facing context ahead of
1103
+ // the readable message (upstream README wiring: inject before the
1104
+ // followup/steer that wakes the driver).
1105
+ try {
1106
+ if (context !== undefined)
1107
+ currentAgent.inject(context);
1108
+ const content = [
1109
+ ...(readable === '' ? [] : [{ type: 'text', text: readable }]),
1110
+ ...images,
1111
+ ];
1112
+ const message = createUserMessage({
1113
+ content,
1114
+ source: { kind: 'user' },
1115
+ });
1116
+ // Steering is consumed at the next step boundary of the turn already
1117
+ // running; a followup becomes its own turn instead.
1118
+ if (mode === 'steer')
1119
+ currentAgent.steer(message);
1120
+ else
1121
+ currentAgent.followup(message);
1122
+ }
1123
+ catch (error) {
1124
+ bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1125
+ }
1126
+ };
1127
+ if (parsed.references.length === 0) {
1128
+ enqueueDelivery(() => deliver(parsed.text));
1129
+ return;
1130
+ }
1131
+ const controller = new AbortController();
1132
+ pendingControllers.add(controller);
1133
+ // `enqueueDelivery` returns nothing; the delivery chain only orders the
1134
+ // work, so the promise is consumed here with an explicit void.
1135
+ enqueueDelivery(() => {
1136
+ void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
1137
+ pendingControllers.delete(controller);
1138
+ deliver(prepared.text, prepared.additionalContext);
1139
+ }, (error) => {
1140
+ pendingControllers.delete(controller);
1141
+ if (controller.signal.aborted || epoch !== atEpoch)
1142
+ return;
1143
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1144
+ });
1145
+ });
1146
+ };
1147
+ // Deferred first-session creation for a bare launch: the session is composed
1148
+ // only when the user submits real input (or /new), and every line that
1149
+ // arrives during creation is delivered in order afterwards. A creation
1150
+ // failure reports and clears the queue, leaving the transient state ready
1151
+ // for the next attempt.
1152
+ const pendingInputs = [];
1153
+ // A creation is queued/running: further submissions must not mint more
1154
+ // fresh sessions (their lines queue into pendingInputs instead).
1155
+ let creating = false;
1156
+ const ensureSession = (mode) => {
1157
+ if (creating)
1158
+ return;
1159
+ creating = true;
1160
+ void compose(async () => {
1161
+ try {
1162
+ // A direct `/mode <preset>` resolves asynchronously. Preserve submit
1163
+ // order so the first composition cannot race ahead with the old mode.
1164
+ await pendingModeWork;
1165
+ // Another composition (e.g. a /resume activated while this creation
1166
+ // waited its turn) may have published a session already: deliver the
1167
+ // queued lines there instead of minting a competing fresh session
1168
+ // (which would orphan the live one without a dispose).
1169
+ if (session !== undefined) {
1170
+ const queued = pendingInputs.splice(0);
1171
+ for (const item of queued)
1172
+ deliverLine(item.text, item.images, item.mode);
1173
+ return;
1174
+ }
1175
+ const next = await prepare({
1176
+ sessionId: `session-${randomUUID()}`,
1177
+ resume: false,
1178
+ ...(mode === undefined ? {} : { mode }),
1179
+ });
1180
+ if (quitting) {
1181
+ void next.handle.dispose().catch(() => { });
1182
+ return;
1183
+ }
1184
+ const previous = { active, agent, session, store, mentions };
1185
+ try {
1186
+ active = next;
1187
+ agent = next.agent;
1188
+ session = next.session;
1189
+ store = next.store;
1190
+ mentions = next.mentions;
1191
+ subagents.reset();
1192
+ for (const event of next.catalogSeed)
1193
+ subagents.apply(event.data.childId, event);
1194
+ pendingMode = undefined;
1195
+ pendingPermission = undefined;
1196
+ commands.setAgent(agent);
1197
+ skills.setAgent(agent);
1198
+ // The App mounts with a placeholder key until the first input; the
1199
+ // key-change remount below must start from a clean screen or the ghost
1200
+ // static header stays visible above the new one (same source-backed
1201
+ // clear the session-switch path performs).
1202
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H');
1203
+ renderCurrent();
1204
+ }
1205
+ catch (error) {
1206
+ // The session composed but the screen handoff threw (stdout EPIPE,
1207
+ // a render-time failure). Roll the published state back exactly
1208
+ // like the switch path does — otherwise the runner reports "session
1209
+ // creation failed" while the new session is actually live, clears
1210
+ // the queued inputs, and every later line lands in the ghost. The
1211
+ // queued inputs are KEPT for the next attempt.
1212
+ active = previous.active;
1213
+ agent = previous.agent;
1214
+ session = previous.session;
1215
+ store = previous.store === undefined ? createTranscriptStore() : previous.store;
1216
+ mentions = previous.mentions === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions;
1217
+ if (agent !== undefined) {
1218
+ commands.setAgent(agent);
1219
+ skills.setAgent(agent);
1220
+ }
1221
+ await next.handle.dispose().catch(() => { });
1222
+ if (!quitting)
1223
+ renderCurrent();
1224
+ bridge.notify(`session activation failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1225
+ return;
1226
+ }
1227
+ abortPendingControllers();
1228
+ epoch += 1;
1229
+ const queued = pendingInputs.splice(0);
1230
+ if (pendingPlan) {
1231
+ pendingPlan = false;
1232
+ // A pre-session plan choice materializes as the registry command
1233
+ // delivered AHEAD of the queued lines, so the first assembled step
1234
+ // of the user's opening message already runs in plan mode.
1235
+ deliverLine('/plan');
1236
+ }
1237
+ for (const item of queued)
1238
+ deliverLine(item.text, item.images, item.mode);
1239
+ }
1240
+ finally {
1241
+ creating = false;
1242
+ }
1243
+ }).catch((error) => {
1244
+ pendingInputs.length = 0;
1245
+ bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1246
+ });
1247
+ };
1248
+ /** Deliver one readable line to the agent, expanding session mentions first. */
1249
+ const sendNow = (text, images = [], mode = 'followup') => {
1250
+ // Blank check on the trimmed form; the payload itself keeps the draft's
1251
+ // exact whitespace unless the line is a syntactic slash command.
1252
+ const line = submissionPayload(text);
1253
+ if (line.trim() === '' && images.length === 0)
1254
+ return;
1255
+ if (images.length === 0 && line.startsWith('/mode ')) {
1256
+ void switchModeAction(line.slice(6).trim()).then(selected => bridge.notify(`mode → ${selected}`), error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'));
1257
+ return;
1258
+ }
1259
+ if (images.length === 0 && line.startsWith('/permission ')) {
1260
+ try {
1261
+ const selected = setPermissionAction(line.slice(12).trim());
1262
+ bridge.notify(`permission → ${selected}`);
1263
+ }
1264
+ catch (error) {
1265
+ bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1266
+ }
1267
+ return;
1268
+ }
1269
+ if (session === undefined) {
1270
+ // The delivery mode rides the buffered line: a steer picked before the
1271
+ // first session exists must still steer once that session composes.
1272
+ pendingInputs.push({ text: line, mode, images });
1273
+ ensureSession();
1274
+ return;
1275
+ }
1276
+ deliverLine(line, images, mode);
1277
+ };
1278
+ // Startup serialization: input submitted while the startup prompt/images
1279
+ // are still preparing queues behind the initial request.
1280
+ const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, images, mode));
1281
+ const send = (text, images = [], mode = 'followup') => {
1282
+ inputGate.submit({ text, mode, images });
1283
+ };
1284
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
1285
+ const dispatch = (text, images = [], origin) => {
1286
+ // An attachment prepare resolved after the app remounted onto another
1287
+ // session (queued switch): the composing session is gone, so the stale
1288
+ // delivery is dropped instead of landing in the new session's inbox.
1289
+ if (!submissionBelongsToSession(origin, session?.id))
1290
+ return;
1291
+ send(text, images);
1292
+ };
1293
+ /**
1294
+ * Deliver one line as steering: a running driver consumes it at its next
1295
+ * step boundary, an idle one starts a turn with it. The composer's Tab
1296
+ * toggle picks this over {@link dispatch} for the next submission.
1297
+ */
1298
+ const steer = (text, images = [], origin) => {
1299
+ if (!submissionBelongsToSession(origin, session?.id))
1300
+ return;
1301
+ send(text, images, 'steer');
1302
+ };
1303
+ /**
1304
+ * Interrupt the running turn (Esc); true when a turn was actually
1305
+ * cancelled. {@link cancelPreservingQueue} keeps the next-turn queue alive
1306
+ * AND re-wakes the driver, so the preserved messages run instead of
1307
+ * parking; next-step steering dies with the turn.
1308
+ */
1309
+ const interrupt = () => {
1310
+ if (agent === undefined || agent.status !== 'running')
1311
+ return false;
1312
+ try {
1313
+ const preserved = cancelPreservingQueue(agent);
1314
+ bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'));
1315
+ return true;
1316
+ }
1317
+ catch (error) {
1318
+ bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1319
+ return false;
1320
+ }
1321
+ };
1322
+ /** Select one permission preset before the first session or on the active one. */
1323
+ const setPermissionAction = (id) => {
1324
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
1325
+ throw new Error('permission presets are not mounted in this composition');
1326
+ }
1327
+ if (id === '')
1328
+ throw new Error('usage: /permission <preset>');
1329
+ const selected = selectPermission(permissionPresets, session, id);
1330
+ if (session === undefined) {
1331
+ pendingPermission = selected;
1332
+ renderCurrent();
1333
+ }
1334
+ return selected;
1335
+ };
1336
+ /**
1337
+ * Shift+Tab mode cycle: permission presets in table order, then the plan
1338
+ * station when the composition offers the /plan command (preset-mounted,
1339
+ * so minimal sessions and the pre-session state cycle permissions only).
1340
+ * Plan transitions submit the upstream registry command — it stays the
1341
+ * single owner of plan state; the TUI renders the durable plan/mode event
1342
+ * it appends. Because that event lags the press (upstream queues the
1343
+ * switch during an open turn), each mid-session plan decision records the
1344
+ * choice in `planIntent` and the next press reads it back, so the cycle
1345
+ * advances stations instead of re-issuing one transition. Returns the
1346
+ * notice label, or '' when nothing changed.
1347
+ */
1348
+ const cycleMode = () => {
1349
+ if (permissionPresets === undefined || permissionPresets.names.length === 0) {
1350
+ bridge.notify('permission presets are not mounted in this composition', 'warning');
1351
+ return '';
1352
+ }
1353
+ try {
1354
+ // Pre-session the plan station rides the pending choice; once a
1355
+ // session exists the scoped /plan command descriptor decides, and the
1356
+ // durable plan/mode event is the live truth.
1357
+ const preSession = session === undefined;
1358
+ if (preSession && !preSessionPlanKnown)
1359
+ refreshPreSessionPlan();
1360
+ const decision = planCycleDecision({
1361
+ names: permissionPresets.names,
1362
+ current: effectivePermission(permissionPresets, session, pendingPermission),
1363
+ inPlan: preSession ? pendingPlan : store.getView().plan === true,
1364
+ ...(preSession ? {} : { planIntent }),
1365
+ planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
1366
+ });
1367
+ if (decision === undefined)
1368
+ return '';
1369
+ if (decision.kind === 'permission') {
1370
+ const next = selectPermission(permissionPresets, session, decision.preset);
1371
+ if (preSession) {
1372
+ pendingPermission = next;
1373
+ renderCurrent();
1374
+ }
1375
+ return `permission → ${next}`;
1376
+ }
1377
+ if (decision.kind === 'plan-on') {
1378
+ // Plan IS the most restrictive preset plus the plan prompt layer:
1379
+ // the cycle arrives here from that preset, so permission needs no
1380
+ // switch — only the plan mode itself toggles.
1381
+ if (preSession) {
1382
+ pendingPlan = true;
1383
+ renderCurrent();
1384
+ return 'plan → on (applies to the first session)';
1385
+ }
1386
+ planIntent = true;
1387
+ send('/plan');
1388
+ return 'plan → on';
1389
+ }
1390
+ // Leaving plan lands on the station after the most restrictive
1391
+ // preset (workspace-write with the shipped table).
1392
+ if (preSession) {
1393
+ pendingPlan = false;
1394
+ pendingPermission = decision.preset;
1395
+ renderCurrent();
1396
+ return `plan → off · permission → ${decision.preset}`;
1397
+ }
1398
+ planIntent = false;
1399
+ send('/plan off');
1400
+ selectPermission(permissionPresets, session, decision.preset);
1401
+ return `plan → off · permission → ${decision.preset}`;
1402
+ }
1403
+ catch (error) {
1404
+ bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1405
+ return '';
1406
+ }
1407
+ };
1408
+ /**
1409
+ * Apply one /model selection: takes effect from the next assembled step.
1410
+ * The optional reasoning effort must be one the row advertises (the picker
1411
+ * only offers those), so an unsupported value cannot reach the request
1412
+ * pipeline; an absent effort restores the model's own default.
1413
+ */
1414
+ const selectModel = (row, effortId) => {
1415
+ const selection = buildModelSelection(row, effortId);
1416
+ if (active === undefined) {
1417
+ // A bare launch has no session yet: keep the pick process-wide so the
1418
+ // first composed session starts from it.
1419
+ pendingSelection = selection;
1420
+ }
1421
+ else {
1422
+ active.selection.picked = selection;
1423
+ }
1424
+ // Global default (web selectModel parity): every pick is persisted as the
1425
+ // deployment default through the same agentDefaultModel service the web
1426
+ // host writes, so the choice survives restarts and other surfaces read
1427
+ // it. Save failures degrade to a notice — the in-session switch already
1428
+ // took effect and must not roll back (the web contract).
1429
+ void defaultModel.saveSelection(selection).catch((error) => {
1430
+ bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning');
1431
+ });
1432
+ // Advisory immediate validation (web selectModel parity): run the same
1433
+ // local resolveCallConfig check the request pipeline would, so a stale
1434
+ // directory — an effort the adapter withdrew since /model loaded —
1435
+ // surfaces as a pick-time notice instead of failing the next assembled
1436
+ // step. Best-effort: an llm service without the resolver keeps the
1437
+ // existing request-boundary rejection. Called as a method (`this`-bound)
1438
+ // like resolveModelInfo in models.ts.
1439
+ const llm = ctx.get('llm');
1440
+ const resolveCallConfig = llm?.resolveCallConfig;
1441
+ if (llm !== undefined && typeof resolveCallConfig === 'function') {
1442
+ void Promise.resolve(resolveCallConfig.call(llm, {
1443
+ provider: selection.provider,
1444
+ model: selection.model,
1445
+ ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
1446
+ })).catch((error) => {
1447
+ bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error');
1448
+ });
1449
+ }
1450
+ return `${row.provider}/${row.model}`;
1451
+ };
1452
+ // dscode: measure the picked route against the live context so /model can ask
1453
+ // before a switch that would compact the conversation.
1454
+ const dscodeCompactionPreviewFor = async (row) => {
1455
+ const meter = ctx.get('tokenMeter');
1456
+ const llm = ctx.get('llm');
1457
+ const session = active?.session;
1458
+ if (session === undefined || typeof meter?.measure !== 'function' || typeof llm?.resolveModelInfo !== 'function')
1459
+ return undefined;
1460
+ const used = meter.measure(session).totalTokens;
1461
+ const info = await llm.resolveModelInfo(row.provider, row.model);
1462
+ return dscodeCompactionPreview({
1463
+ used,
1464
+ contextWindow: info?.context?.contextWindow,
1465
+ thresholdRatio: await dscodePricedThresholdRatio(row.provider, row.model),
1466
+ label: row.provider + '/' + row.model,
1467
+ });
1468
+ };
1469
+ /** The /subagent override label, '' when delegated agents follow the current model. */
1470
+ const subagentModelLabel = () => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride);
1471
+ /** Apply one /subagent model pick; returns the override label. */
1472
+ const setSubagentModel = (row, effortId) => {
1473
+ subagentOverride = buildModelSelection(row, effortId);
1474
+ renderCurrent();
1475
+ return modelSelectionLabel(subagentOverride);
1476
+ };
1477
+ /** Drop the /subagent override: delegated agents follow the current model again. */
1478
+ const clearSubagentModel = () => {
1479
+ subagentOverride = undefined;
1480
+ renderCurrent();
1481
+ };
1482
+ /**
1483
+ * Export the folded transcript to a markdown file (/export). The default
1484
+ * target sits beside the session's cwd so the file lands in the user's
1485
+ * workspace; an absolute or cwd-relative argument overrides it.
1486
+ */
1487
+ const exportTranscript = async (argument) => {
1488
+ if (session === undefined) {
1489
+ bridge.notify('no session yet — submit a message to start', 'warning');
1490
+ return;
1491
+ }
1492
+ const wanted = argument.trim();
1493
+ const sessionCwd = session.header.cwd ?? cwd;
1494
+ // The default name derives from the session id, which `--session` lets the
1495
+ // user spell freely: reduce it to filename-safe characters first so the
1496
+ // default target can never escape the session cwd.
1497
+ const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`;
1498
+ const target = wanted === ''
1499
+ ? join(sessionCwd, defaultName)
1500
+ : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
1501
+ ? wanted
1502
+ : join(sessionCwd, wanted);
1503
+ const markdown = buildExportMarkdown(store.getView(), session.id);
1504
+ try {
1505
+ await writeFileAsync(target, `${markdown}\n`, 'utf8');
1506
+ bridge.notify(`exported to ${target}`);
1507
+ }
1508
+ catch (error) {
1509
+ bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1510
+ }
1511
+ };
1512
+ /**
1513
+ * Rename the session (/title): a user title pins the session and stops
1514
+ * automatic generation (the service's own contract). The appended
1515
+ * `session/title` event flows back through the store into the status line.
1516
+ */
1517
+ const renameTitle = (argument) => {
1518
+ const title = argument.trim();
1519
+ if (title === '')
1520
+ return 'usage: /title <text>';
1521
+ if (session === undefined)
1522
+ return 'no session yet — submit a message to start';
1523
+ const service = ctx.get('sessionTitle');
1524
+ if (service === undefined)
1525
+ return 'session titles are unavailable in this profile';
1526
+ try {
1527
+ service.rename(session, title);
1528
+ return `title → ${title}`;
1529
+ }
1530
+ catch (error) {
1531
+ return `rename failed: ${error instanceof Error ? error.message : String(error)}`;
1532
+ }
1533
+ };
1534
+ const loadSessions = async (options, signal) => {
1535
+ if (sessionQuery === undefined)
1536
+ throw new Error('session query is unavailable in this profile');
1537
+ const records = await sessionQuery.listSessions(signal);
1538
+ // Last-activity timestamps for sorting (codex UpdatedAt default): the
1539
+ // newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
1540
+ // the persistence `locate()` query, so paths are derived from the
1541
+ // backend's public config root. Backends without a JSONL config (or
1542
+ // vanished directories) fall back to createdAt inside the projection.
1543
+ const root = jsonlSessionRoot(persistence);
1544
+ const updated = new Map();
1545
+ if (root !== undefined) {
1546
+ await Promise.all(records.map(async (record) => {
1547
+ try {
1548
+ const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id);
1549
+ const entries = await readdir(dir, { withFileTypes: true });
1550
+ const stats = await Promise.all(entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
1551
+ .map(entry => stat(join(dir, entry.name))));
1552
+ const newest = Math.max(...stats.map(info => info.mtimeMs));
1553
+ if (Number.isFinite(newest))
1554
+ updated.set(record.header.id, newest);
1555
+ }
1556
+ catch {
1557
+ // Artifact gone or unreadable: the projection falls back to createdAt.
1558
+ }
1559
+ }));
1560
+ }
1561
+ const projected = projectSessionRows(records, options, updated);
1562
+ // Titles are the expensive fold. Fetch only the first bounded picker page;
1563
+ // navigation/filter changes trigger a fresh, cancellable observation.
1564
+ const page = projected.slice(0, 32);
1565
+ if (page.length === 0)
1566
+ return projected;
1567
+ const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal);
1568
+ return mergeSessionTitles(projected, observations);
1569
+ };
1570
+ /**
1571
+ * Delete one session subtree (/delete, codex semantics: subagent threads go
1572
+ * with their root). The kernel persistence seam has NO deletion API by
1573
+ * design — logs accumulate "until removed externally" — so this is the
1574
+ * controlled external removal, in three phases with a hard boundary
1575
+ * between planning and touching the filesystem:
1576
+ *
1577
+ * 1. `planSessionDeletion` collects the subtree and refuses when the root
1578
+ * or ANY member is live (a live child would outlive its deleted
1579
+ * parent), ordering the plan children-first.
1580
+ * 2. Every plan node must derive to a guarded artifact directory
1581
+ * (`encodeSegment(id)` layout beneath the backend's config root).
1582
+ * Backends without a derivable artifact (non-JSONL) refuse the WHOLE
1583
+ * deletion here — no file has been touched yet, so a backend or layout
1584
+ * surprise can never strand a half-deleted subtree.
1585
+ * 3. Artifacts are removed children-first: only an I/O error mid-delete
1586
+ * can stop it short (reported with removed/total counts), leaving the
1587
+ * shallowest lineage intact.
1588
+ *
1589
+ * @param id - the root session id to delete.
1590
+ * @returns the outcome line for the panel/notice.
1591
+ */
1592
+ const deleteSession = async (id) => {
1593
+ if (sessionQuery === undefined)
1594
+ return 'session query is unavailable in this profile';
1595
+ if (session !== undefined && session.id === id)
1596
+ return 'cannot delete the session you are using — switch or /new first';
1597
+ const records = await sessionQuery.listSessions();
1598
+ const plan = planSessionDeletion(records, id);
1599
+ if (!plan.ok)
1600
+ return plan.reason;
1601
+ // Phase 2 completes the plan before the first rm: derive and
1602
+ // layout-check every node up front, so a refusal never leaves a
1603
+ // partially removed subtree behind.
1604
+ const root = jsonlSessionRoot(persistence);
1605
+ if (root === undefined) {
1606
+ return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)';
1607
+ }
1608
+ const byId = new Map(records.map(record => [record.header.id, record]));
1609
+ const dirs = new Map();
1610
+ for (const node of plan.nodes) {
1611
+ const record = byId.get(node.id);
1612
+ if (record === undefined)
1613
+ return `no persisted session matches "${node.id}"`;
1614
+ const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id);
1615
+ if (dir === undefined) {
1616
+ return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`;
1617
+ }
1618
+ dirs.set(node.id, dir);
1619
+ }
1620
+ let removed = 0;
1621
+ for (const node of plan.nodes) {
1622
+ const dir = dirs.get(node.id);
1623
+ try {
1624
+ // Remove every canonical generation artifact this build knows; other
1625
+ // sibling files are never ours to delete, and the directory itself is
1626
+ // only removed once empty. An unreadable directory counts as a
1627
+ // failure (not a silent success) so the outcome line stays honest.
1628
+ const entries = await readdir(dir, { withFileTypes: true });
1629
+ for (const entry of entries) {
1630
+ if (entry.isFile() && isSessionArtifactName(entry.name)) {
1631
+ await rm(join(dir, entry.name), { force: true });
1632
+ }
1633
+ }
1634
+ await rm(dir, { force: true, recursive: false }).catch(() => { });
1635
+ removed += 1;
1636
+ }
1637
+ catch (error) {
1638
+ return `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`;
1639
+ }
1640
+ }
1641
+ return `deleted ${removed} session${removed === 1 ? '' : 's'}`;
1642
+ };
1643
+ const loadSessionTranscript = async (id, signal) => {
1644
+ if (sessionQuery === undefined)
1645
+ throw new Error('session query is unavailable in this profile');
1646
+ const snapshot = await sessionQuery.readSession(id, signal);
1647
+ return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id);
1648
+ };
1649
+ /**
1650
+ * Read one session's usage blocks for the /usage panel: the mounted
1651
+ * projection's session totals plus the meter's own per-turn fold over the
1652
+ * durable log (which the panel merges by model). A deployment without the
1653
+ * projection registry renders the totals as explicitly unavailable rather
1654
+ * than as zeros. The read is synchronous — the registry materializes a cell
1655
+ * on first touch — so it is handed to the panel behind a resolved promise,
1656
+ * which keeps the fold out of the keystroke that opens the panel.
1657
+ * @param current - the session to read, or undefined before the first one.
1658
+ * @returns the resolved panel data.
1659
+ */
1660
+ const loadUsage = (current) => {
1661
+ if (current === undefined)
1662
+ return Promise.resolve({ turns: [] });
1663
+ const values = ctx.get('sessionProjections')?.snapshot(current, ['tokenUsage']).values;
1664
+ return Promise.resolve({
1665
+ totals: values?.tokenUsage,
1666
+ turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
1667
+ });
1668
+ };
1669
+ const switchModeAction = async (id) => {
1670
+ if (id === '')
1671
+ throw new Error('usage: /mode <preset>');
1672
+ const currentAgent = agent;
1673
+ if (currentAgent === undefined) {
1674
+ const choice = pendingModeWork.then(async () => {
1675
+ const preset = await selectPreset(presets, undefined, id);
1676
+ // A resume may have won while this roster read was in flight; never
1677
+ // leak the old pending choice into a later /new session.
1678
+ if (agent === undefined) {
1679
+ pendingMode = preset.id;
1680
+ renderCurrent();
1681
+ }
1682
+ return preset.id;
1683
+ });
1684
+ pendingModeWork = choice.then(() => { }, () => { });
1685
+ return choice;
1686
+ }
1687
+ // Serialize the recomposition with session activations: a /mode that
1688
+ // interleaves a switch must not rebind the shared command/skill
1689
+ // registries while the switch is composing the next agent.
1690
+ const currentActive = active;
1691
+ const atEpoch = epoch;
1692
+ let selected;
1693
+ await compose(async () => {
1694
+ const preset = await selectPreset(presets, currentAgent, id);
1695
+ // A switch/quit landed while the recomposition ran: applying here
1696
+ // would write the old choice into the new session's state and rebind
1697
+ // the registries back to a disposed agent. The preset-selection log
1698
+ // entry rode the old agent's session; only the local application is
1699
+ // dropped.
1700
+ if (epoch !== atEpoch || agent !== currentAgent || active !== currentActive) {
1701
+ throw new Error('session changed while switching mode — nothing applied; retry in the active session');
1702
+ }
1703
+ if (active === undefined)
1704
+ throw new Error('active Agent has no session state');
1705
+ active.mode = preset.id;
1706
+ commands.setAgent(currentAgent);
1707
+ skills.setAgent(currentAgent);
1708
+ selected = preset.id;
1709
+ renderCurrent();
1710
+ });
1711
+ return selected;
1712
+ };
1713
+ const activate = (nextTarget) => {
1714
+ if (quitting)
1715
+ return Promise.resolve();
1716
+ // Serialized with every other composition (bare-launch creation, queued
1717
+ // switches): at most one agent is composed at a time.
1718
+ return compose(async () => {
1719
+ const previous = active;
1720
+ const next = await prepare(nextTarget);
1721
+ // Quit landed while the next session was being composed: dispose the
1722
+ // half-ready agent and leave the current session untouched.
1723
+ if (quitting) {
1724
+ await next.handle.dispose().catch(() => { });
1725
+ return;
1726
+ }
1727
+ active = next;
1728
+ agent = next.agent;
1729
+ session = next.session;
1730
+ store = next.store;
1731
+ mentions = next.mentions;
1732
+ commands.setAgent(agent);
1733
+ skills.setAgent(agent);
1734
+ try {
1735
+ // Reseed the feed BEFORE the first frame of the new session so no
1736
+ // stale row from the previous one flashes; a rolled-back handoff
1737
+ // re-seeds the previous session's catalog the same way.
1738
+ subagents.reset();
1739
+ for (const event of next.catalogSeed)
1740
+ subagents.apply(event.data.childId, event);
1741
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H');
1742
+ renderCurrent();
1743
+ // Only a successful handoff may clear the transient per-session
1744
+ // surfaces: a rolled-back switch keeps the previous session's
1745
+ // subagent feed plus the user's pre-session /mode and permission
1746
+ // picks (the bare-launch promise: explicit choices survive until
1747
+ // composition takes them). The in-flight cycle intent belonged to
1748
+ // the previous session's presses; the new session's committed fold
1749
+ // decides from here.
1750
+ pendingMode = undefined;
1751
+ pendingPermission = undefined;
1752
+ pendingPlan = false;
1753
+ planIntent = undefined;
1754
+ }
1755
+ catch (error) {
1756
+ active = previous;
1757
+ agent = previous?.agent;
1758
+ session = previous?.session;
1759
+ store = previous === undefined ? createTranscriptStore() : previous.store;
1760
+ mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions;
1761
+ if (agent !== undefined)
1762
+ commands.setAgent(agent);
1763
+ if (agent !== undefined)
1764
+ skills.setAgent(agent);
1765
+ subagents.reset();
1766
+ if (previous !== undefined) {
1767
+ for (const event of previous.catalogSeed)
1768
+ subagents.apply(event.data.childId, event);
1769
+ }
1770
+ // The failed handoff disposed the incoming session; the restored
1771
+ // store's committed plan fold is the truth, so any cycle intent
1772
+ // collected against the switch churn retires too.
1773
+ planIntent = undefined;
1774
+ await next.handle.dispose();
1775
+ if (!quitting)
1776
+ renderCurrent();
1777
+ throw error;
1778
+ }
1779
+ // From here the new session is live: in-flight prepares/commands for
1780
+ // the previous agent are stale and must be aborted and ignored.
1781
+ abortPendingControllers();
1782
+ epoch += 1;
1783
+ // No previous session (a bare launch switched straight into a resume):
1784
+ // nothing to flush or dispose, so just confirm the activation.
1785
+ if (previous === undefined) {
1786
+ // The key-change remount above swaps the App in this same synchronous
1787
+ // continuation; the new App registers its bridge.notify in a passive
1788
+ // effect AFTER it, so an immediate notice reaches the UNMOUNTED
1789
+ // instance and React drops it silently. Defer past the commit.
1790
+ setTimeout(() => {
1791
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`);
1792
+ }, 0);
1793
+ return;
1794
+ }
1795
+ let cleanupWarning;
1796
+ try {
1797
+ await sessions.flush(previous.session);
1798
+ }
1799
+ catch (error) {
1800
+ cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`;
1801
+ }
1802
+ try {
1803
+ await previous.handle.dispose();
1804
+ }
1805
+ catch (error) {
1806
+ cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`;
1807
+ }
1808
+ bridge.notify(cleanupWarning === undefined
1809
+ ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1810
+ : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`, cleanupWarning === undefined ? 'info' : 'warning');
1811
+ });
1812
+ };
1813
+ const switchQueue = new SessionSwitchQueue(async (request) => { if (!quitting)
1814
+ await activate(request.target); }, error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'));
1815
+ const requestSwitch = (request) => {
1816
+ if (session === undefined) {
1817
+ // No session yet (a bare launch using /resume before any input): activate
1818
+ // the target directly — there is no running turn to wait on and nothing
1819
+ // to flush.
1820
+ void activate(request.target).catch((error) => {
1821
+ bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1822
+ });
1823
+ return;
1824
+ }
1825
+ if (request.target.sessionId === session.id) {
1826
+ bridge.notify('that session is already active', 'warning');
1827
+ return;
1828
+ }
1829
+ const outcome = switchQueue.request(agent, request);
1830
+ if (outcome === 'queued') {
1831
+ bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`);
1832
+ }
1833
+ };
1834
+ const resolveResumeId = async (wanted) => {
1835
+ if (wanted === '')
1836
+ throw new Error('usage: /resume <id|prefix>');
1837
+ if (sessionQuery === undefined)
1838
+ throw new Error('session query is unavailable in this profile');
1839
+ const records = await sessionQuery.listSessions();
1840
+ const exact = records.filter(record => record.header.id === wanted);
1841
+ const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted));
1842
+ if (matches.length === 0)
1843
+ throw new Error(`no session matches "${wanted}"`);
1844
+ if (matches.length > 1)
1845
+ throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`);
1846
+ const matched = matches[0];
1847
+ // Same lineage gate as the CLI --resume path and the picker.
1848
+ if (isSubagentSession(matched.header)) {
1849
+ throw new Error('subagent conversations are read-only; resume a root session');
1850
+ }
1851
+ if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
1852
+ throw new Error('that session is already live in another owner');
1853
+ }
1854
+ return matched.header.id;
1855
+ };
1856
+ const requestResume = (wanted) => {
1857
+ void resolveResumeId(wanted).then(id => {
1858
+ requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) });
1859
+ }, (error) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'));
1860
+ };
1861
+ const createSession = (mode) => {
1862
+ // /new before any input is the first-session creation itself, not a switch.
1863
+ if (session === undefined) {
1864
+ ensureSession(mode);
1865
+ return;
1866
+ }
1867
+ const nextCwd = session.header.cwd ?? cwd;
1868
+ const id = `session-${randomUUID()}`;
1869
+ requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) });
1870
+ };
1871
+ const reviewChanges = (selection) => {
1872
+ // Works from a bare launch too: with no session yet the read-only
1873
+ // choice goes to pendingPermission (materialized when the first
1874
+ // session composes) and the review prompt queues behind that
1875
+ // creation exactly like a typed first submission. The identity guard
1876
+ // below still aborts a load that outlives a mid-flight switch —
1877
+ // including one landing on an undefined agent.
1878
+ const currentAgent = agent;
1879
+ // The diff loads from the CALLING session's cwd; capture that
1880
+ // workspace and this turn's identity so a switch mid-load can neither
1881
+ // flip the new session read-only nor send the old workspace's review
1882
+ // into it. The controller rides pendingControllers, so a switch/quit
1883
+ // kills the git subprocess itself instead of only ignoring its result.
1884
+ const atEpoch = epoch;
1885
+ const reviewCwd = session?.header.cwd ?? cwd;
1886
+ const controller = new AbortController();
1887
+ pendingControllers.add(controller);
1888
+ const finish = () => {
1889
+ pendingControllers.delete(controller);
1890
+ };
1891
+ // Branch reviews diff from the precomputed merge base (what would
1892
+ // actually land), commit reviews the commit's own patch, everything
1893
+ // else reviews the uncommitted working tree.
1894
+ const load = selection.kind === 'commit'
1895
+ ? loadCommitDiff(reviewCwd, selection.sha, controller.signal)
1896
+ : selection.kind === 'base-branch'
1897
+ ? mergeBaseWith(reviewCwd, selection.branch, controller.signal)
1898
+ .then(base => loadGitDiff(reviewCwd, base ?? selection.branch, controller.signal))
1899
+ : loadGitDiff(reviewCwd, '', controller.signal);
1900
+ const note = selection.kind === 'custom' ? selection.instructions : undefined;
1901
+ void load.then(({ title, files }) => {
1902
+ finish();
1903
+ if (controller.signal.aborted || epoch !== atEpoch || agent !== currentAgent)
1904
+ return;
1905
+ try {
1906
+ setPermissionAction('read-only');
1907
+ }
1908
+ catch (error) {
1909
+ bridge.notify(t('notice.reviewUnavailable', { message: error instanceof Error ? error.message : String(error) }), 'error');
1910
+ return;
1911
+ }
1912
+ send(buildReviewPrompt(files.flatMap(file => file.lines).join('\n'), title, note));
1913
+ bridge.notify(t('notice.reviewStarted'));
1914
+ }, (error) => {
1915
+ finish();
1916
+ if (controller.signal.aborted || epoch !== atEpoch)
1917
+ return;
1918
+ bridge.notify(t('notice.reviewFailed', { message: error instanceof Error ? error.message : String(error) }), 'error');
1919
+ });
1920
+ };
1921
+ const forkSession = (argument) => {
1922
+ if (session === undefined || active === undefined) {
1923
+ bridge.notify('no session yet - submit a message to start', 'warning');
1924
+ return;
1925
+ }
1926
+ try {
1927
+ const text = argument.trim();
1928
+ const atSeq = text === '' ? undefined : Number(text);
1929
+ if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
1930
+ throw new Error('usage: /fork [event-seq]');
1931
+ }
1932
+ const seed = selectForkSeed(session.snapshotEvents(), atSeq);
1933
+ const id = `session-${randomUUID()}`;
1934
+ requestSwitch({
1935
+ target: {
1936
+ sessionId: id,
1937
+ resume: false,
1938
+ mode: active.mode,
1939
+ cwd: session.header.cwd ?? cwd,
1940
+ seed: seed.events,
1941
+ parentSession: session.id,
1942
+ seedLength: seed.events.length,
1943
+ },
1944
+ label: id.slice(-12),
1945
+ });
1946
+ }
1947
+ catch (error) {
1948
+ bridge.notify(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
1949
+ }
1950
+ };
1951
+ const switchSession = (row) => {
1952
+ if (!row.resumable) {
1953
+ bridge.notify('subagent conversations are read-only', 'warning');
1954
+ return;
1955
+ }
1956
+ requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) });
1957
+ };
1958
+ // /search reads the SAME in-process engine the model's session_search
1959
+ // tools use (the bundle's skip-tolerant subclass). The row may be disabled
1960
+ // by a deployment; /search then degrades to a notice instead of a panel.
1961
+ const searchSessions = sessionQuery === undefined
1962
+ ? undefined
1963
+ : async (query, signal) => {
1964
+ const page = await sessionQuery.searchSessions({ query, limit: 30 }, signal === undefined ? undefined : { signal });
1965
+ const rows = page.items.map(hit => searchHitToRow(hit));
1966
+ // Best-effort title enrichment (the same snapshots /resume merges):
1967
+ // a failure keeps the short-id labels instead of failing the search.
1968
+ try {
1969
+ const observations = await sessionQuery.readTitleSnapshots(rows.map(row => row.id), signal);
1970
+ const titles = new Map();
1971
+ for (const observation of observations) {
1972
+ if (observation.status !== 'fulfilled')
1973
+ continue;
1974
+ const title = observation.value?.title?.title;
1975
+ if (title !== undefined && title.trim() !== '')
1976
+ titles.set(observation.sessionId, title);
1977
+ }
1978
+ return rows.map(row => titles.has(row.id) ? { ...row, label: titles.get(row.id) } : row);
1979
+ }
1980
+ catch {
1981
+ return rows;
1982
+ }
1983
+ };
1984
+ const cancelSessionSwitch = () => {
1985
+ return switchQueue.cancel();
1986
+ };
1987
+ const appElement = () => {
1988
+ // A bare launch mounts with pending/default model, mode, and permission
1989
+ // facts until the first input composes the real session. These choices stay
1990
+ // process-local and create no durable state before that composition.
1991
+ const sessionCwd = session?.header.cwd ?? cwd;
1992
+ const currentView = store.getView();
1993
+ const defaults = currentDefaults();
1994
+ const model = currentView.model !== ''
1995
+ ? currentView.model
1996
+ : pendingSelection !== undefined
1997
+ ? `${pendingSelection.provider}/${pendingSelection.model}`
1998
+ : `${defaults.provider}/${defaults.model}`;
1999
+ const effort = resolveEffectiveSelection(active?.selection.picked ?? pendingSelection, session?.requestHeader()?.config, defaults).reasoningEffort;
2000
+ const permission = permissionPresets === undefined
2001
+ ? currentView.permission
2002
+ : effectivePermission(permissionPresets, session, pendingPermission);
2003
+ return createElement(App, {
2004
+ key: session?.id ?? 'pending',
2005
+ sessionKey: session?.id ?? '',
2006
+ store,
2007
+ approval,
2008
+ questions,
2009
+ subagents,
2010
+ commands,
2011
+ skills,
2012
+ model,
2013
+ effort,
2014
+ cwd: basename(sessionCwd),
2015
+ workspaceRoot: sessionCwd,
2016
+ branch: gitBranch(sessionCwd),
2017
+ sessionId: session === undefined ? '' : session.id.slice(-8),
2018
+ resumed: active?.resumed ?? false,
2019
+ mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
2020
+ permission,
2021
+ /** Pre-session plan choice for the status badge until a session composes. */
2022
+ pendingPlan: session === undefined && pendingPlan,
2023
+ dispatch,
2024
+ steer,
2025
+ interrupt,
2026
+ quit,
2027
+ loadModels: () => dscodeMigrateOpenRouter(ctx.get('settings')).then(() => loadModelDirectory(ctx)),
2028
+ dscodeEnsureProviderRoute: (provider) => dscodeEnsureProviderRoute(ctx.get('settings'), provider),
2029
+ dscodeManagementKeyStatus: () => dscodeManagementKeyStatus(ctx),
2030
+ dscodeSaveManagementKey: (key) => dscodeSaveManagementKey(ctx, key),
2031
+ dscodeLoadOpenRouterAccount: () => dscodeLoadOpenRouterAccountFor(ctx),
2032
+ loadModelProviders: () => loadProviderSettings(ctx),
2033
+ subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
2034
+ saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
2035
+ saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
2036
+ discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
2037
+ unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
2038
+ removeModelProvider: target => removeProviderSettings(ctx, target),
2039
+ loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
2040
+ subscribeProviderAuthorizations: listener => subscribeProviderAuthorizations(ctx, listener),
2041
+ beginProviderAuthorization: (row, method, interaction, signal) => (beginProviderAuthorization(ctx, row, method, interaction, signal)),
2042
+ cancelProviderAuthorization: row => cancelProviderAuthorization(ctx, row.key),
2043
+ logoutProviderAuthorization: row => logoutProviderAuthorization(ctx, row),
2044
+ openAuthorizationUrl,
2045
+ copyTextValue: copyText,
2046
+ loadMentions: (query, signal) => mentions.candidates(query, signal),
2047
+ inspectImages: paths => inspectImagePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
2048
+ prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
2049
+ inspectFiles: paths => inspectFilePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
2050
+ prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get('attachments'), signal),
2051
+ cycleMode,
2052
+ setPermission: setPermissionAction,
2053
+ selectModel,
2054
+ dscodeCompactionPreview: dscodeCompactionPreviewFor,
2055
+ subagentModel: subagentModelLabel(),
2056
+ setSubagentModel,
2057
+ clearSubagentModel,
2058
+ deleteSession,
2059
+ exportTranscript,
2060
+ renameTitle,
2061
+ copyLastResponse,
2062
+ loadGitDiff: (argument) => loadGitDiff(session?.header.cwd ?? cwd, argument),
2063
+ listReviewBranches: (signal) => listReviewBranches(session?.header.cwd ?? cwd, signal),
2064
+ listReviewCommits: (signal) => listReviewCommits(session?.header.cwd ?? cwd, signal),
2065
+ reviewChanges,
2066
+ loadPresets: () => presets.list(),
2067
+ switchMode: switchModeAction,
2068
+ loadPermissions: () => permissionPresets === undefined
2069
+ ? Promise.reject(new Error('permission presets are not mounted in this composition'))
2070
+ : Promise.resolve(listPermissionRows(permissionPresets)),
2071
+ createSession,
2072
+ forkSession,
2073
+ loadSessions,
2074
+ loadSessionTranscript,
2075
+ loadUsage: () => loadUsage(session),
2076
+ loadSubagents: () => {
2077
+ const current = session;
2078
+ if (current === undefined || sessionQuery === undefined)
2079
+ return Promise.resolve([]);
2080
+ return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
2081
+ .then(rows => rows.filter(row => row.parent === current.id && row.subagent));
2082
+ },
2083
+ switchSession,
2084
+ searchSessions,
2085
+ cancelSessionSwitch,
2086
+ loadPlugins: () => listPluginRows(ctx),
2087
+ // The launcher owns every update decision; the TUI only drives its
2088
+ // read-only probe and streamed apply as child processes.
2089
+ probeUpdate: () => probeLauncherUpdate(),
2090
+ applyUpdate: (onLine, plan) => applyLauncherUpdate(onLine, undefined, plan),
2091
+ loadJobs: () => listJobs(ctx, active?.agent),
2092
+ statusline: statuslineItems,
2093
+ saveStatusline,
2094
+ applyEditorKeys,
2095
+ saveTheme,
2096
+ saveLanguage,
2097
+ animations: animationsEnabled,
2098
+ saveAnimations,
2099
+ history: inputHistory,
2100
+ recordHistory,
2101
+ updateQueued,
2102
+ onBridgeReady: (instance) => { bridge.notify = instance.notify; },
2103
+ });
2104
+ };
2105
+ const renderCurrent = () => {
2106
+ mountRef.current?.rerender(appElement());
2107
+ };
2108
+ mountRef.current = io.mount(appElement());
2109
+ // Startup prompt/images use the same durable delivery path as composer
2110
+ // submissions. Image bytes are committed before the user/message event, and
2111
+ // input typed during that preparation queues behind the initial request so
2112
+ // the agent always receives the startup prompt first.
2113
+ if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
2114
+ if ((startup.images?.length ?? 0) > 0) {
2115
+ bridge.notify(`processing ${startup.images.length} startup image${startup.images.length === 1 ? '' : 's'}…`);
2116
+ }
2117
+ void inputGate.run(async (deliver) => {
2118
+ const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'));
2119
+ if (images.length > 0)
2120
+ bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`);
2121
+ deliver({ text: startup.prompt ?? '', mode: 'followup', images });
2122
+ }).catch((error) => {
2123
+ bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error');
2124
+ });
2125
+ }
2126
+ async function copyLastResponse() {
2127
+ const text = latestAssistantText(store.getView());
2128
+ if (text === undefined)
2129
+ return 'nothing to copy yet';
2130
+ await copyText(text);
2131
+ return 'copied latest response';
2132
+ }
2133
+ // A corrupt statusline config must not vanish silently: surface it once
2134
+ // the notice channel is live, after the first frame settles.
2135
+ if (statuslineWarning !== undefined) {
2136
+ setTimeout(() => {
2137
+ bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning');
2138
+ }, 50);
2139
+ }
2140
+ // Same one-shot surface for a corrupt theme file (dark fallback stays live).
2141
+ if (languageWarning !== undefined) {
2142
+ setTimeout(() => {
2143
+ bridge.notify(t('notice.languageConfigUnreadable', { message: languageWarning }), 'warning');
2144
+ }, 0);
2145
+ }
2146
+ if (themeWarning !== undefined) {
2147
+ setTimeout(() => {
2148
+ bridge.notify('theme config unreadable, using dark: ' + themeWarning, 'warning');
2149
+ }, 50);
2150
+ }
2151
+ // And for a corrupt animations file (on-by-default fallback stays live).
2152
+ if (animationsWarning !== undefined) {
2153
+ setTimeout(() => {
2154
+ bridge.notify('animations config unreadable, animations stay on: ' + animationsWarning, 'warning');
2155
+ }, 50);
2156
+ }
2157
+ // One-shot VS Code Ctrl+R hint: resolveEditorKeysStartupHint checks the
2158
+ // marker file and the live keybindings config; surfacing waits for the
2159
+ // notice channel like the other startup warnings. A failed probe stays
2160
+ // silent — the hint is cosmetic and /vscode-keys remains discoverable.
2161
+ void resolveEditorKeysStartupHint(editorKeysEnv).then(hint => {
2162
+ if (hint === undefined)
2163
+ return;
2164
+ setTimeout(() => {
2165
+ bridge.notify(hint);
2166
+ }, 50);
2167
+ }, () => { });
2168
+ }
2169
+ /**
2170
+ * Mount the interactive terminal driver.
2171
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
2172
+ * @param config - validated startup config resolved from the tuiStartup provider.
2173
+ */
2174
+ export function apply(ctx, config) {
2175
+ // The CLI validated --theme at parse time; the loose config schema falls
2176
+ // back to dark for anything unexpected.
2177
+ const theme = config.startup.theme === undefined ? undefined : parseThemeName(config.startup.theme);
2178
+ const input = {
2179
+ ...(theme === undefined ? {} : { theme }),
2180
+ ...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
2181
+ ...(config.startup.images === undefined ? {} : { images: config.startup.images }),
2182
+ };
2183
+ const startup = config.startup.kind === 'resume' && config.startup.sessionId !== undefined
2184
+ ? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
2185
+ : config.startup.kind === 'latest'
2186
+ ? { kind: 'latest', ...input }
2187
+ : config.startup.kind === 'named' && config.startup.sessionId !== undefined
2188
+ ? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
2189
+ : { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input };
2190
+ // Read through the global service store, not the property proxy: appExit is
2191
+ // an optional host value, never an injected dependency.
2192
+ const exit = ctx.get('appExit');
2193
+ if (exit === undefined) {
2194
+ throw new Error('tui-runner: the launcher must provide ctx.appExit before the tree mounts');
2195
+ }
2196
+ const io = { mount: internals.mount, exit };
2197
+ void run(ctx, startup, io).catch((error) => { fail(io, error); });
2198
+ }