dsh-code 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -8
- package/README.md +10 -8
- package/lib/index.mjs +6980 -5942
- package/lib/session-query.mjs +18 -1
- package/lib/types/app.d.ts +38 -65
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +20 -8
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +16 -37
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +1 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +16 -0
- package/lib/types/render/projection-events.d.ts +33 -0
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/render/transcript-viewport.d.ts +32 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +57 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/session-query.d.ts +11 -0
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/package.json +3 -2
- package/src/app.ts +802 -4162
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +113 -633
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +20 -8
- package/src/locales/zh.ts +20 -8
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +25 -6
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +84 -65
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +6 -6
- package/src/provider-settings.ts +38 -0
- package/src/render/inspector.ts +35 -0
- package/src/render/lines.ts +45 -18
- package/src/render/projection-events.ts +109 -0
- package/src/render/projection.ts +11 -2
- package/src/render/text.ts +8 -0
- package/src/render/tool-preview.ts +35 -2
- package/src/render/transcript-viewport.ts +61 -0
- package/src/render/usage.ts +1 -2
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +245 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{session-directory.ts → session/session-directory.ts} +1 -1
- package/src/{store.ts → session/store.ts} +1 -1
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/session-query.ts +22 -0
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
- /package/lib/types/{history.d.ts → session/history.d.ts} +0 -0
- /package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +0 -0
- /package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +0 -0
- /package/src/{fork.ts → session/fork.ts} +0 -0
- /package/src/{history.ts → session/history.ts} +0 -0
- /package/src/{session-switch.ts → session/session-switch.ts} +0 -0
package/src/index.ts
CHANGED
|
@@ -10,29 +10,49 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
13
|
import { homedir } from 'node:os'
|
|
15
|
-
import {
|
|
16
|
-
import { basename,
|
|
14
|
+
import { writeFile as writeFileAsync } from 'node:fs/promises'
|
|
15
|
+
import { basename, join } from 'node:path'
|
|
17
16
|
import { createElement } from 'react'
|
|
18
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
18
|
import z from '@deepseek-ai/schemastery'
|
|
20
19
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, AgentHandle,
|
|
20
|
+
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
21
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
22
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
24
|
-
import { createUserMessage,
|
|
23
|
+
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
25
24
|
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
|
-
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type
|
|
25
|
+
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
26
|
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
28
|
-
import { SessionAlreadyOwnedError, type SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
29
27
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
30
28
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
31
29
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
32
30
|
// and the cmdline Context merge for the appExit host value.
|
|
33
31
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
34
32
|
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
35
|
-
import { App
|
|
33
|
+
import { App } from './app.ts'
|
|
34
|
+
import type { NoticeTone, QueueMutation } from './ui/ui-contract.ts'
|
|
35
|
+
import { planCycleDecision } from './runner/mode-cycle.ts'
|
|
36
|
+
export { planCycleDecision, type ModeCycleDecision } from './runner/mode-cycle.ts'
|
|
37
|
+
import { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
|
|
38
|
+
export { runQuitSequence, type QuitCleanupStep } from './runner/quit.ts'
|
|
39
|
+
import { exportSessionIdSuffix, resolveTarget, type Target } from './runner/session-target.ts'
|
|
40
|
+
export { exportSessionIdSuffix, resolveTarget } from './runner/session-target.ts'
|
|
41
|
+
import {
|
|
42
|
+
applyQueueMutation,
|
|
43
|
+
cancelPreservingQueue,
|
|
44
|
+
StartupInputGate,
|
|
45
|
+
submissionBelongsToSession,
|
|
46
|
+
} from './runner/submissions.ts'
|
|
47
|
+
export {
|
|
48
|
+
applyQueueMutation,
|
|
49
|
+
cancelPreservingQueue,
|
|
50
|
+
queueEditContent,
|
|
51
|
+
StartupInputGate,
|
|
52
|
+
submissionBelongsToSession,
|
|
53
|
+
type QueuedSubmission,
|
|
54
|
+
type QueueMutationOutcome,
|
|
55
|
+
} from './runner/submissions.ts'
|
|
36
56
|
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
37
57
|
import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
|
|
38
58
|
import { internals, type TuiMount } from './internals.ts'
|
|
@@ -44,6 +64,7 @@ import {
|
|
|
44
64
|
removeProviderSettings,
|
|
45
65
|
saveProviderCredential,
|
|
46
66
|
saveProviderConfiguration,
|
|
67
|
+
enableProviderSubscription,
|
|
47
68
|
subscribeProviderSettings,
|
|
48
69
|
unsetProviderCredential,
|
|
49
70
|
} from './provider-settings.ts'
|
|
@@ -52,10 +73,10 @@ import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
|
52
73
|
// Type-only import merges the settings Events declarations ('settings/updated',
|
|
53
74
|
// 'settings/document-updated') into this program's Cordis bus typing.
|
|
54
75
|
import type {} from '@deepseek-ai/dsh-settings'
|
|
55
|
-
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
56
|
-
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
76
|
+
import { createTranscriptStore, type TranscriptStore } from './session/store.ts'
|
|
77
|
+
import { createSubagentFeed, subagentCatalogSeed, type SubagentFeedView } from './session/subagents.ts'
|
|
78
|
+
export { subagentCatalogSeed } from './session/subagents.ts'
|
|
57
79
|
import { parseStatuslineItems } from './render/status.ts'
|
|
58
|
-
import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
59
80
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
60
81
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
61
82
|
import { buildExportMarkdown } from './render/export.ts'
|
|
@@ -70,7 +91,8 @@ import {
|
|
|
70
91
|
openAuthorizationUrl,
|
|
71
92
|
subscribeProviderAuthorizations,
|
|
72
93
|
} from './authorization.ts'
|
|
73
|
-
import { selectForkSeed } from './fork.ts'
|
|
94
|
+
import { selectForkSeed } from './session/fork.ts'
|
|
95
|
+
import { gitBranch } from './git-workflow.ts'
|
|
74
96
|
import {
|
|
75
97
|
buildReviewPrompt,
|
|
76
98
|
listReviewBranches,
|
|
@@ -81,7 +103,7 @@ import {
|
|
|
81
103
|
type ReviewSelection,
|
|
82
104
|
} from './git-workflow.ts'
|
|
83
105
|
import type { TuiStartup } from './startup.ts'
|
|
84
|
-
import { SessionSwitchQueue } from './session-switch.ts'
|
|
106
|
+
import { SessionSwitchQueue } from './session/session-switch.ts'
|
|
85
107
|
import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
|
|
86
108
|
import {
|
|
87
109
|
applyPendingPermission,
|
|
@@ -96,25 +118,19 @@ import { parseAnimationsPref } from './render/animations.ts'
|
|
|
96
118
|
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
97
119
|
import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
|
|
98
120
|
import {
|
|
99
|
-
acquireSessionDeletionLeases,
|
|
100
121
|
isSubagentSession,
|
|
101
|
-
matchSessionId,
|
|
102
|
-
mergeSessionTitles,
|
|
103
|
-
newestRootForCwd,
|
|
104
|
-
sessionRowMatchesQuery,
|
|
105
|
-
isSessionArtifactName,
|
|
106
|
-
jsonlSessionRoot,
|
|
107
|
-
planSessionDeletion,
|
|
108
|
-
projectSessionRows,
|
|
109
|
-
releaseSessionDeletionLeases,
|
|
110
|
-
sessionArtifactDirectory,
|
|
111
|
-
sessionDirectoryFor,
|
|
112
|
-
type SessionDirectoryOptions,
|
|
113
122
|
type SessionQueryService,
|
|
114
123
|
type SessionRow,
|
|
115
|
-
} from './session-directory.ts'
|
|
116
|
-
import type { JobRow
|
|
117
|
-
import {
|
|
124
|
+
} from './session/session-directory.ts'
|
|
125
|
+
import type { JobRow } from './panels/kernel-panels.ts'
|
|
126
|
+
import { searchHitToRow, type SearchRow } from './runner/search-rows.ts'
|
|
127
|
+
export { searchHitToRow } from './runner/search-rows.ts'
|
|
128
|
+
import { createUserSettingsPersistence } from './settings-file.ts'
|
|
129
|
+
import { preferencePath, readPreference, savePreference } from './runner/preferences.ts'
|
|
130
|
+
import { createInputHistory } from './runner/input-history.ts'
|
|
131
|
+
import { createSessionIo } from './runner/session-io.ts'
|
|
132
|
+
import { EXPECTED_HARNESS_VERSION, probeRunningHarness, requireHarnessVersion } from './runner/harness-gate.ts'
|
|
133
|
+
import { resolveStartupConfig } from './runner/startup-config.ts'
|
|
118
134
|
import { turnUsages, type UsageView } from './render/usage.ts'
|
|
119
135
|
// Type-only import: merges the projection registry into the Context type so
|
|
120
136
|
// `ctx.get('sessionProjections')` is typed (the service itself is mounted by
|
|
@@ -184,373 +200,16 @@ function listJobs(ctx: Context, caller: Agent | undefined): readonly JobRow[] {
|
|
|
184
200
|
}
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
/**
|
|
188
|
-
* Read one user-level settings file as a plain object. The callers all treat a
|
|
189
|
-
* missing file as "unset" and a corrupt one as "warn and fall back", so this
|
|
190
|
-
* helper owns the one distinction they share: readable JSON that is not an
|
|
191
|
-
* object is corruption, not an absent preference, and must not surface as a
|
|
192
|
-
* cryptic property access on `null`.
|
|
193
|
-
* @param path - absolute path of the settings file.
|
|
194
|
-
* @returns the parsed object; the caller narrows each field itself.
|
|
195
|
-
*/
|
|
196
|
-
function readSettingsObject(path: string): Record<string, unknown> {
|
|
197
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
198
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
199
|
-
throw new Error(`${basename(path)} must contain a JSON object`)
|
|
200
|
-
}
|
|
201
|
-
return parsed as Record<string, unknown>
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Resolve the working directory's git branch for the status line.
|
|
206
|
-
* @param cwd - the session's working directory.
|
|
207
|
-
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
208
|
-
*/
|
|
209
|
-
function gitBranch(cwd: string): string {
|
|
210
|
-
try {
|
|
211
|
-
const ref = readFileSync(join(cwd, '.git', 'HEAD'), 'utf8').trim().match(/^ref: refs\/heads\/(.+)$/)
|
|
212
|
-
return ref?.[1] ?? ''
|
|
213
|
-
} catch {
|
|
214
|
-
// Only the single HEAD read is attempted, so the sole reachable failure is
|
|
215
|
-
// a missing repository (or unreadable HEAD file): the branch group drops out.
|
|
216
|
-
return ''
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
221
|
-
interface Target {
|
|
222
|
-
sessionId: string
|
|
223
|
-
resume: boolean
|
|
224
|
-
mode?: string
|
|
225
|
-
cwd?: string
|
|
226
|
-
seed?: readonly SessionEvent[]
|
|
227
|
-
parentSession?: SessionId
|
|
228
|
-
/** Marks the session as a subagent conversation in the durable header. */
|
|
229
|
-
origin?: 'subagent'
|
|
230
|
-
seedLength?: number
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
235
|
-
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
236
|
-
* user text: path separators must never leak into the default export filename
|
|
237
|
-
* (which would escape the session cwd).
|
|
238
|
-
* @param id - the session id.
|
|
239
|
-
* @returns at most the last 8 filename-safe characters.
|
|
240
|
-
*/
|
|
241
|
-
export function exportSessionIdSuffix(id: string): string {
|
|
242
|
-
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** One ordered step of the terminal quit cleanup. */
|
|
246
|
-
export interface QuitCleanupStep {
|
|
247
|
-
/** Step label used in diagnostics and tests. */
|
|
248
|
-
readonly name: string
|
|
249
|
-
/** The step's async work; a rejection is contained by the sequence. */
|
|
250
|
-
readonly run: () => Promise<void>
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
255
|
-
* contained (reported through `onError`) so a failed flush or dispose never
|
|
256
|
-
* skips the remaining cleanup; the exit request is always reached exactly
|
|
257
|
-
* once.
|
|
258
|
-
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
259
|
-
* session, await the final in-flight composition, await durable recall).
|
|
260
|
-
* @param exit - the terminal exit request (code 0).
|
|
261
|
-
* @param onError - optional failure sink; called once per failing step and
|
|
262
|
-
* itself contained, so a throwing sink cannot abort the sequence.
|
|
263
|
-
* @returns the names of the steps that started, in order (for tests).
|
|
264
|
-
*/
|
|
265
|
-
export async function runQuitSequence(
|
|
266
|
-
steps: readonly QuitCleanupStep[],
|
|
267
|
-
exit: (code: number) => void,
|
|
268
|
-
onError?: (name: string, error: unknown) => void,
|
|
269
|
-
): Promise<readonly string[]> {
|
|
270
|
-
const started: string[] = []
|
|
271
|
-
for (const step of steps) {
|
|
272
|
-
started.push(step.name)
|
|
273
|
-
try {
|
|
274
|
-
await step.run()
|
|
275
|
-
} catch (error) {
|
|
276
|
-
try {
|
|
277
|
-
onError?.(step.name, error)
|
|
278
|
-
} catch {
|
|
279
|
-
// The failure sink must never abort the cleanup sequence.
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
try {
|
|
284
|
-
exit(0)
|
|
285
|
-
} catch {
|
|
286
|
-
// The exit request itself must not become an unhandled rejection.
|
|
287
|
-
}
|
|
288
|
-
return started
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** One composer submission waiting behind the startup delivery. */
|
|
292
|
-
export interface QueuedSubmission {
|
|
293
|
-
readonly text: string
|
|
294
|
-
/** `steer` inserts into the running turn; `followup` waits for the next one. */
|
|
295
|
-
readonly mode: 'followup' | 'steer'
|
|
296
|
-
readonly images: readonly ContentBlock[]
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** What one requested queue mutation did; the runner maps it to one notice. */
|
|
300
|
-
export type QueueMutationOutcome =
|
|
301
|
-
| 'removed'
|
|
302
|
-
| 'edited'
|
|
303
|
-
| 'steered'
|
|
304
|
-
| 'unavailable'
|
|
305
|
-
| 'empty'
|
|
306
|
-
| 'steerUnavailable'
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Replace one queued message's text while keeping its attachments. A queue
|
|
310
|
-
* edit rewrites what the user typed, not what they attached: image and file
|
|
311
|
-
* blocks ride through in delivery order (text first, then attachments, the
|
|
312
|
-
* shape {@link deliverLine} submits). Dropping them here would silently strip
|
|
313
|
-
* an attachment the user already confirmed, so this is the edit's single
|
|
314
|
-
* definition and the panel's read-only marker only mirrors it.
|
|
315
|
-
*/
|
|
316
|
-
export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
|
|
317
|
-
const attachments = content.filter(block => block.type !== 'text')
|
|
318
|
-
return [{ type: 'text', text }, ...attachments]
|
|
319
|
-
}
|
|
320
203
|
|
|
321
|
-
/**
|
|
322
|
-
* Apply one terminal queue mutation to the live inbox. The decision and the
|
|
323
|
-
* inbox change are pure over the supplied handles so every branch is testable
|
|
324
|
-
* without an agent; steering itself is injected because it wakes the driver
|
|
325
|
-
* rather than mutating the inbox. The durable inbox splices remain the UI's
|
|
326
|
-
* single source of truth — this helper never reports a state the inbox did not
|
|
327
|
-
* actually reach.
|
|
328
|
-
* @param inbox - the live agent inbox (pending lists plus its mutators).
|
|
329
|
-
* @param status - the agent's lifecycle status; steering needs `running`.
|
|
330
|
-
* @param messageId - identity of the queued message to mutate.
|
|
331
|
-
* @param action - the requested mutation.
|
|
332
|
-
* @param steer - submits the removed message as next-step steering.
|
|
333
|
-
* @returns the outcome the caller reports.
|
|
334
|
-
*/
|
|
335
|
-
export function applyQueueMutation(
|
|
336
|
-
inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
|
|
337
|
-
status: AgentStatus,
|
|
338
|
-
messageId: string,
|
|
339
|
-
action: QueueMutation,
|
|
340
|
-
steer: (message: UserMessage) => void,
|
|
341
|
-
): QueueMutationOutcome {
|
|
342
|
-
const id = MessageId(messageId)
|
|
343
|
-
const message = inbox.nextTurn.find(candidate => candidate.id === id)
|
|
344
|
-
if (message === undefined) return 'unavailable'
|
|
345
|
-
switch (action.kind) {
|
|
346
|
-
case 'remove':
|
|
347
|
-
return inbox.remove(id) ? 'removed' : 'unavailable'
|
|
348
|
-
case 'edit':
|
|
349
|
-
if (action.text.trim() === '') return 'empty'
|
|
350
|
-
inbox.replace(id, createUserMessage({
|
|
351
|
-
content: queueEditContent(message.content, action.text),
|
|
352
|
-
source: message.source,
|
|
353
|
-
}))
|
|
354
|
-
return 'edited'
|
|
355
|
-
case 'steer':
|
|
356
|
-
if (status !== 'running') return 'steerUnavailable'
|
|
357
|
-
// Steer promotes the message out of next-turn, so a failing submit must
|
|
358
|
-
// put it back: the row the user was looking at never just disappears.
|
|
359
|
-
if (!inbox.remove(id)) return 'unavailable'
|
|
360
|
-
try {
|
|
361
|
-
steer(message)
|
|
362
|
-
} catch (error: unknown) {
|
|
363
|
-
inbox.append('next-turn', message)
|
|
364
|
-
throw error
|
|
365
|
-
}
|
|
366
|
-
return 'steered'
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
204
|
|
|
370
|
-
/**
|
|
371
|
-
* Cancel the active turn while keeping the next-turn queue, then wake the
|
|
372
|
-
* driver again so the preserved messages actually run. `cancel` clears
|
|
373
|
-
* pending work by default and never wakes the driver on its own, so the queue
|
|
374
|
-
* is captured first and re-submitted afterwards: a waking submission latches
|
|
375
|
-
* the wake while the aborted activity converges to idle, which is what turns
|
|
376
|
-
* "preserved" into "sent next" instead of "parked forever". Next-step
|
|
377
|
-
* steering is deliberately dropped — it belonged to the cancelled turn.
|
|
378
|
-
* @param agent - the live agent handle.
|
|
379
|
-
* @returns how many queued messages were preserved across the abort.
|
|
380
|
-
*/
|
|
381
|
-
export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
|
|
382
|
-
const queued = [...agent.inbox.nextTurn]
|
|
383
|
-
agent.cancel({ kind: 'user' })
|
|
384
|
-
for (const message of queued) agent.followup(message)
|
|
385
|
-
return queued.length
|
|
386
|
-
}
|
|
387
205
|
|
|
388
|
-
/**
|
|
389
|
-
* Whether a tagged submission still belongs to the active session. Attachment
|
|
390
|
-
* prepares resolve on the microtask timeline, while a queued session switch
|
|
391
|
-
* remounts the app asynchronously — the composing instance's unmount cleanup
|
|
392
|
-
* runs too late to abort, so the delivery itself carries the composing
|
|
393
|
-
* session's full id and the runner drops it here when the world moved on.
|
|
394
|
-
* An untagged (synchronous) or pending-session ('') submission always passes.
|
|
395
|
-
*/
|
|
396
|
-
export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
|
|
397
|
-
return origin === undefined || origin === '' || origin === activeSessionId
|
|
398
|
-
}
|
|
399
206
|
|
|
400
|
-
/**
|
|
401
|
-
* Root-log catalog facts a resumed session must replay into the subagent
|
|
402
|
-
* feed: constructor seeds never fire on the live bus, so without this the
|
|
403
|
-
* children of a resumed session vanish behind a restart. The empty-child
|
|
404
|
-
* placeholder row (childId '') is a placeholder, not a child, and stays out.
|
|
405
|
-
*/
|
|
406
|
-
export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
|
|
407
|
-
return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
|
|
408
|
-
event.type === 'subagent/catalog' && event.data.childId !== '')
|
|
409
|
-
}
|
|
410
207
|
|
|
411
|
-
/**
|
|
412
|
-
* Map one cross-session full-text hit onto the /search panel's row (pure).
|
|
413
|
-
* Labels fall back to the short id form — the engine's hit carries the
|
|
414
|
-
* strongest matching event, not the title observation.
|
|
415
|
-
*/
|
|
416
|
-
export function searchHitToRow(hit: {
|
|
417
|
-
header: SessionHeader
|
|
418
|
-
bestMatch: { snippet: string; time: number }
|
|
419
|
-
}): SearchRow {
|
|
420
|
-
const subagent = hit.header.origin === 'subagent'
|
|
421
|
-
const cwd = hit.header.cwd ?? ''
|
|
422
|
-
// Session cwds may arrive in either separator style regardless of the
|
|
423
|
-
// observing host (a workspace synced from Windows), so split on both.
|
|
424
|
-
const workspace = cwd.split(/[\\/]/u).filter(part => part !== '').at(-1) ?? ''
|
|
425
|
-
const preset = hit.header.agentPreset ?? ''
|
|
426
|
-
const flat = hit.bestMatch.snippet.replace(/\s+/gu, ' ').trim()
|
|
427
|
-
return {
|
|
428
|
-
id: hit.header.id,
|
|
429
|
-
label: hit.header.id.slice(-12),
|
|
430
|
-
detail: [workspace, preset].filter(part => part !== '').join(' · '),
|
|
431
|
-
snippet: flat.length > 158 ? `${flat.slice(0, 157)}…` : flat,
|
|
432
|
-
updatedAt: hit.bestMatch.time,
|
|
433
|
-
subagent,
|
|
434
|
-
resumable: !subagent,
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
208
|
|
|
438
|
-
/** One Shift+Tab station decision for the mode cycle. */
|
|
439
|
-
export type ModeCycleDecision =
|
|
440
|
-
| { readonly kind: 'permission'; readonly preset: string }
|
|
441
|
-
| { readonly kind: 'plan-on' }
|
|
442
|
-
| { readonly kind: 'plan-off'; readonly preset: string }
|
|
443
209
|
|
|
444
|
-
/**
|
|
445
|
-
* Decide the next Shift+Tab station. The cycle keeps the preset table's
|
|
446
|
-
* own order (most restrictive first) and inserts ONE plan station between
|
|
447
|
-
* the most restrictive preset and the wrap target: with the shipped three
|
|
448
|
-
* presets the user sees workspace-write → danger-full-access → read-only
|
|
449
|
-
* → plan → workspace-write. Plan IS the most restrictive preset plus the
|
|
450
|
-
* plan prompt layer — entering it switches nothing (the cycle is already
|
|
451
|
-
* parked on read-only), and leaving it lands on the next preset after the
|
|
452
|
-
* most restrictive one. Without the /plan command the cycle is exactly the
|
|
453
|
-
* preset table.
|
|
454
|
-
*
|
|
455
|
-
* `planIntent` covers the committed fold's commit lag: upstream queues a
|
|
456
|
-
* plan switch during an open turn (and the command pipeline is async even
|
|
457
|
-
* idle), so the durable plan/mode event lands AFTER the press that chose
|
|
458
|
-
* it. While an intent from an earlier press is in flight it — not the
|
|
459
|
-
* stale committed fold — decides the station, so repeated presses advance
|
|
460
|
-
* the cycle instead of re-issuing the same plan transition (the stuck
|
|
461
|
-
* plan-on/plan-off toggle). Undefined falls back to the committed fold.
|
|
462
|
-
*/
|
|
463
|
-
export function planCycleDecision(input: {
|
|
464
|
-
readonly names: readonly string[]
|
|
465
|
-
readonly current: string
|
|
466
|
-
readonly inPlan: boolean
|
|
467
|
-
readonly planAvailable: boolean
|
|
468
|
-
readonly planIntent?: boolean
|
|
469
|
-
}): ModeCycleDecision | undefined {
|
|
470
|
-
const names = input.names
|
|
471
|
-
if (names.length === 0) return undefined
|
|
472
|
-
const first = names[0]
|
|
473
|
-
if ((input.planIntent ?? input.inPlan) === true) return { kind: 'plan-off', preset: names[1] ?? first }
|
|
474
|
-
const at = names.indexOf(input.current)
|
|
475
|
-
if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
|
|
476
|
-
return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
|
|
477
|
-
}
|
|
478
210
|
|
|
479
|
-
/**
|
|
480
|
-
* Order-preserving gate for composer input while the startup prompt/images
|
|
481
|
-
* are still preparing. Anything submitted before the startup delivery settles
|
|
482
|
-
* queues and flushes afterwards in submit order, so the initial request can
|
|
483
|
-
* never be overtaken by typing that raced a slow image preparation. The flush
|
|
484
|
-
* also runs when the startup delivery fails: user input is never stranded.
|
|
485
|
-
*/
|
|
486
|
-
export class StartupInputGate {
|
|
487
|
-
private readonly queued: QueuedSubmission[] = []
|
|
488
|
-
private pending = false
|
|
489
|
-
constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
|
|
490
|
-
|
|
491
|
-
/** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
|
|
492
|
-
submit(submission: QueuedSubmission): void {
|
|
493
|
-
if (this.pending) this.queued.push(submission)
|
|
494
|
-
else this.deliver(submission)
|
|
495
|
-
}
|
|
496
211
|
|
|
497
|
-
/**
|
|
498
|
-
* Run the startup delivery — the callback receives the direct-delivery sink
|
|
499
|
-
* for the startup prompt itself — then flush everything that queued behind
|
|
500
|
-
* it, in order, even when the callback rejects.
|
|
501
|
-
*/
|
|
502
|
-
async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
|
|
503
|
-
this.pending = true
|
|
504
|
-
try {
|
|
505
|
-
await startup(submission => this.deliver(submission))
|
|
506
|
-
} finally {
|
|
507
|
-
this.pending = false
|
|
508
|
-
const queued = this.queued.splice(0)
|
|
509
|
-
for (const submission of queued) this.deliver(submission)
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
212
|
|
|
514
|
-
/**
|
|
515
|
-
* Resolve the invocation's target session against the persisted headers.
|
|
516
|
-
* @param startup - the parsed startup flags.
|
|
517
|
-
* @param persistence - the persistence service; required for resume/latest.
|
|
518
|
-
* @param cwd - the working directory `--continue` filters by.
|
|
519
|
-
* @returns the target identity.
|
|
520
|
-
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
521
|
-
*/
|
|
522
|
-
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
523
|
-
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
524
|
-
if (startup.kind === 'named') {
|
|
525
|
-
// The id must not exist yet: reject before any Agent composition when the
|
|
526
|
-
// backend can tell us (a live collision is still caught by the session
|
|
527
|
-
// store at create time).
|
|
528
|
-
if (persistence !== undefined) {
|
|
529
|
-
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
530
|
-
if (headers.some(header => header.id === startup.sessionId)) {
|
|
531
|
-
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
535
|
-
}
|
|
536
|
-
if (persistence === undefined) {
|
|
537
|
-
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
538
|
-
}
|
|
539
|
-
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
540
|
-
if (startup.kind === 'resume') {
|
|
541
|
-
const matched = matchSessionId(headers, startup.sessionId)
|
|
542
|
-
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
543
|
-
// be a back door into appending root turns to a child's durable log.
|
|
544
|
-
if (isSubagentSession(matched)) {
|
|
545
|
-
throw new Error('subagent conversations are read-only; resume a root session')
|
|
546
|
-
}
|
|
547
|
-
return { sessionId: matched.id, resume: true }
|
|
548
|
-
}
|
|
549
|
-
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
550
|
-
const newest = newestRootForCwd(headers, cwd)
|
|
551
|
-
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
552
|
-
return { sessionId: newest.id, resume: true }
|
|
553
|
-
}
|
|
554
213
|
|
|
555
214
|
/**
|
|
556
215
|
* Resolve a bounded command preview for one pending approval: the request
|
|
@@ -944,17 +603,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
944
603
|
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
945
604
|
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
946
605
|
// surfaced warning (the customization is user-authored, never silent).
|
|
947
|
-
const statuslinePath =
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
statuslineItems = parseStatuslineItems(readSettingsObject(statuslinePath).items)
|
|
952
|
-
} catch (error) {
|
|
953
|
-
statuslineItems = parseStatuslineItems(undefined)
|
|
954
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
955
|
-
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
956
|
-
}
|
|
957
|
-
}
|
|
606
|
+
const statuslinePath = preferencePath('statusline.json')
|
|
607
|
+
const statuslineRead = readPreference(statuslinePath, 'items', parseStatuslineItems)
|
|
608
|
+
const statuslineWarning: string | undefined = statuslineRead.warning
|
|
609
|
+
let statuslineItems: readonly string[] = statuslineRead.value ?? parseStatuslineItems(undefined)
|
|
958
610
|
// Serialized, crash-atomic writes for the user-level JSON files: the chain
|
|
959
611
|
// orders rapid consecutive saves (the LAST snapshot wins on disk), each
|
|
960
612
|
// write goes through a sibling temp file + rename, and quit waits for the
|
|
@@ -962,10 +614,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
962
614
|
const settingsPersistence = createUserSettingsPersistence()
|
|
963
615
|
const saveStatusline = (items: readonly string[]): void => {
|
|
964
616
|
statuslineItems = [...items]
|
|
965
|
-
|
|
966
|
-
.
|
|
967
|
-
|
|
968
|
-
})
|
|
617
|
+
savePreference(settingsPersistence, statuslinePath, 'items', items, message => {
|
|
618
|
+
bridge.notify(t('notice.statuslineSaveFailed', { message }), 'error')
|
|
619
|
+
})
|
|
969
620
|
}
|
|
970
621
|
|
|
971
622
|
// /vscode-keys: detect the hosting editor's user keybindings.json and pass
|
|
@@ -974,7 +625,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
974
625
|
const editorKeysEnv: EditorKeysEnv = {
|
|
975
626
|
env: process.env,
|
|
976
627
|
paths: { homedir: homedir(), appdata: process.env.APPDATA, platform: process.platform },
|
|
977
|
-
flagPath:
|
|
628
|
+
flagPath: preferencePath('editor-keys.json'),
|
|
978
629
|
}
|
|
979
630
|
const applyEditorKeys = (): Promise<string> => applyCtrlRPassthrough(editorKeysEnv)
|
|
980
631
|
|
|
@@ -983,45 +634,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
983
634
|
// degrades to dark with a surfaced warning. Precedence: CLI --theme > file >
|
|
984
635
|
// auto detection > dark (auto detection itself is a later enhancement and
|
|
985
636
|
// currently falls back to dark inside theme.ts).
|
|
986
|
-
const themePath =
|
|
637
|
+
const themePath = preferencePath('theme.json')
|
|
987
638
|
let themeWarning: string | undefined
|
|
988
639
|
if (startup.theme === undefined) {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
themeWarning = error instanceof Error ? error.message : String(error)
|
|
994
|
-
}
|
|
995
|
-
}
|
|
640
|
+
const read = readPreference(themePath, 'theme', parseThemeName)
|
|
641
|
+
themeWarning = read.warning
|
|
642
|
+
// A missing or corrupt file leaves theme.ts on its own dark default.
|
|
643
|
+
if (read.value !== undefined) setTheme(read.value)
|
|
996
644
|
} else {
|
|
997
645
|
setTheme(startup.theme)
|
|
998
646
|
}
|
|
999
647
|
const saveTheme = (name: ThemeName): void => {
|
|
1000
648
|
setTheme(name)
|
|
1001
|
-
|
|
1002
|
-
.
|
|
1003
|
-
|
|
1004
|
-
})
|
|
649
|
+
savePreference(settingsPersistence, themePath, 'theme', name, message => {
|
|
650
|
+
bridge.notify(t('notice.themeSaveFailed', { message }), 'error')
|
|
651
|
+
})
|
|
1005
652
|
}
|
|
1006
653
|
|
|
1007
654
|
// /language persistence: one user-level JSON file beside theme.json. A
|
|
1008
655
|
// missing file means English; a corrupt file degrades to English with a
|
|
1009
656
|
// surfaced warning.
|
|
1010
|
-
const languagePath =
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1016
|
-
languageWarning = error instanceof Error ? error.message : String(error)
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
657
|
+
const languagePath = preferencePath('language.json')
|
|
658
|
+
const languageRead = readPreference(languagePath, 'language', parseLanguageName)
|
|
659
|
+
const languageWarning: string | undefined = languageRead.warning
|
|
660
|
+
// A missing or corrupt file leaves i18n on its own English default.
|
|
661
|
+
if (languageRead.value !== undefined) setLanguage(languageRead.value)
|
|
1019
662
|
const saveLanguage = (name: LanguageName): void => {
|
|
1020
663
|
setLanguage(name)
|
|
1021
|
-
|
|
1022
|
-
.
|
|
1023
|
-
|
|
1024
|
-
})
|
|
664
|
+
savePreference(settingsPersistence, languagePath, 'language', name, message => {
|
|
665
|
+
bridge.notify(t('notice.languageSaveFailed', { message }), 'error')
|
|
666
|
+
})
|
|
1025
667
|
}
|
|
1026
668
|
|
|
1027
669
|
// /animation persistence: one user-level JSON file under the DSH home,
|
|
@@ -1029,68 +671,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1029
671
|
// corrupt file degrades to on with a surfaced warning. Only an explicit
|
|
1030
672
|
// `false` disables (parseAnimationsPref), so hand-edited or partial files
|
|
1031
673
|
// never silently freeze the UI.
|
|
1032
|
-
const animationsPath =
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
animationsEnabled = parseAnimationsPref(readSettingsObject(animationsPath).animations)
|
|
1039
|
-
} catch (error) {
|
|
1040
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1041
|
-
animationsWarning = error instanceof Error ? error.message : String(error)
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
674
|
+
const animationsPath = preferencePath('animations.json')
|
|
675
|
+
// A literal `null` file reads as corruption and surfaces a warning, instead
|
|
676
|
+
// of a property access on `null`.
|
|
677
|
+
const animationsRead = readPreference(animationsPath, 'animations', parseAnimationsPref)
|
|
678
|
+
const animationsWarning: string | undefined = animationsRead.warning
|
|
679
|
+
const animationsEnabled = animationsRead.value ?? true
|
|
1044
680
|
const saveAnimations = (enabled: boolean): void => {
|
|
1045
|
-
|
|
1046
|
-
.
|
|
1047
|
-
|
|
1048
|
-
})
|
|
681
|
+
savePreference(settingsPersistence, animationsPath, 'animations', enabled, message => {
|
|
682
|
+
bridge.notify(t('notice.animationsSaveFailed', { message }), 'error')
|
|
683
|
+
})
|
|
1049
684
|
}
|
|
1050
685
|
|
|
1051
686
|
// Global input recall (Codex composer-history contract): one JSONL file
|
|
1052
687
|
// under the DSH home. A missing file means an empty history; unreadable or
|
|
1053
688
|
// corrupt content degrades to the valid lines it could parse, silently —
|
|
1054
689
|
// recall is a convenience surface, never a gate.
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
try {
|
|
1059
|
-
const rawHistory = readFileSync(historyPath, 'utf8')
|
|
1060
|
-
inputHistory = parseHistoryFile(rawHistory)
|
|
1061
|
-
// Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
|
|
1062
|
-
// accumulate in an append-only file; rewrite the canonical form once
|
|
1063
|
-
// per boot. The rewrite rides the same chain, so it lands before any
|
|
1064
|
-
// submission the user types next. An entry another terminal appends
|
|
1065
|
-
// inside the read-to-rename window is dropped — a millisecond-scale
|
|
1066
|
-
// gap at boot that recall tolerates by design.
|
|
1067
|
-
if (needsCompaction(rawHistory)) {
|
|
1068
|
-
historyWriteChain = historyWriteChain
|
|
1069
|
-
.then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
|
|
1070
|
-
.catch(() => {})
|
|
1071
|
-
}
|
|
1072
|
-
} catch {
|
|
1073
|
-
inputHistory = []
|
|
1074
|
-
}
|
|
1075
|
-
/**
|
|
1076
|
-
* Serialized history writes: each submission appends one JSON line at the
|
|
1077
|
-
* end of the file, so concurrent terminals add entries after each other
|
|
1078
|
-
* instead of overwriting snapshots they read at their own boot. A
|
|
1079
|
-
* multi-line draft still occupies one physical line (JSON escapes the
|
|
1080
|
-
* newline), and a regular-length line reaches the disk as one positioned
|
|
1081
|
-
* write; an oversized paste may interleave mid-line, which the next
|
|
1082
|
-
* parse simply drops.
|
|
1083
|
-
*/
|
|
1084
|
-
const recordHistory = (text: string): void => {
|
|
1085
|
-
if (text === '') return
|
|
1086
|
-
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
1087
|
-
historyWriteChain = historyWriteChain
|
|
1088
|
-
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
1089
|
-
.then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
|
|
1090
|
-
.catch((writeError: unknown) => {
|
|
1091
|
-
bridge.notify(t('notice.historySaveFailed', { message: writeError instanceof Error ? writeError.message : String(writeError) }), 'error')
|
|
1092
|
-
})
|
|
1093
|
-
}
|
|
690
|
+
const inputHistory = createInputHistory(preferencePath('history.jsonl'), message => {
|
|
691
|
+
bridge.notify(t('notice.historySaveFailed', { message }), 'error')
|
|
692
|
+
})
|
|
1094
693
|
|
|
1095
694
|
/** Mutate one next-turn inbox item; durable inbox splices remain the UI truth. */
|
|
1096
695
|
const updateQueued = (messageId: string, action: QueueMutation): void => {
|
|
@@ -1162,7 +761,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1162
761
|
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
1163
762
|
]),
|
|
1164
763
|
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
1165
|
-
{ name: 'history', run: () =>
|
|
764
|
+
{ name: 'history', run: () => inputHistory.flush() },
|
|
1166
765
|
{ name: 'settings', run: () => settingsPersistence.flush() },
|
|
1167
766
|
]
|
|
1168
767
|
void runQuitSequence(steps, io.exit, report)
|
|
@@ -1669,138 +1268,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1669
1268
|
}
|
|
1670
1269
|
}
|
|
1671
1270
|
|
|
1672
|
-
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
1673
|
-
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1674
|
-
const records = await sessionQuery.listSessions(signal)
|
|
1675
|
-
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
1676
|
-
// newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
|
|
1677
|
-
// the persistence `locate()` query, so paths are derived from the
|
|
1678
|
-
// backend's public config root. Backends without a JSONL config (or
|
|
1679
|
-
// vanished directories) fall back to createdAt inside the projection.
|
|
1680
|
-
const root = jsonlSessionRoot(persistence)
|
|
1681
|
-
const updated = new Map<string, number>()
|
|
1682
|
-
if (root !== undefined) {
|
|
1683
|
-
await Promise.all(records.map(async record => {
|
|
1684
|
-
try {
|
|
1685
|
-
const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
|
|
1686
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
1687
|
-
const stats = await Promise.all(
|
|
1688
|
-
entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
|
|
1689
|
-
.map(entry => stat(join(dir, entry.name))),
|
|
1690
|
-
)
|
|
1691
|
-
const newest = Math.max(...stats.map(info => info.mtimeMs))
|
|
1692
|
-
if (Number.isFinite(newest)) updated.set(record.header.id, newest)
|
|
1693
|
-
} catch {
|
|
1694
|
-
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
1695
|
-
}
|
|
1696
|
-
}))
|
|
1697
|
-
}
|
|
1698
|
-
const projected = projectSessionRows(records, { ...options, query: '' }, updated)
|
|
1699
|
-
// Titles are the expensive fold. Fetch the first picker page when idle;
|
|
1700
|
-
// a non-empty query loads more so the displayed title can match.
|
|
1701
|
-
const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
|
|
1702
|
-
const page = projected.slice(0, titleBudget)
|
|
1703
|
-
if (page.length === 0) return projected
|
|
1704
|
-
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1705
|
-
const titled = mergeSessionTitles(projected, observations)
|
|
1706
|
-
return titled.filter(row => sessionRowMatchesQuery(row, options.query))
|
|
1707
|
-
}
|
|
1708
1271
|
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
* between planning and touching the filesystem:
|
|
1715
|
-
*
|
|
1716
|
-
* 1. `planSessionDeletion` collects the subtree and refuses when the root
|
|
1717
|
-
* or ANY member is live (a live child would outlive its deleted
|
|
1718
|
-
* parent), ordering the plan children-first.
|
|
1719
|
-
* 2. Every plan node must derive to a guarded artifact directory
|
|
1720
|
-
* (`encodeSegment(id)` layout beneath the backend's config root).
|
|
1721
|
-
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
1722
|
-
* deletion here — no file has been touched yet, so a backend or layout
|
|
1723
|
-
* surprise can never strand a half-deleted subtree.
|
|
1724
|
-
* 3. Acquire every node's public persistence write handle before touching
|
|
1725
|
-
* files. The JSONL backend holds its cross-process kernel lease for each
|
|
1726
|
-
* handle, so another terminal's live session refuses the whole deletion.
|
|
1727
|
-
* 4. Artifacts are removed children-first while every lease remains held:
|
|
1728
|
-
* only an I/O error mid-delete can stop it short (reported with
|
|
1729
|
-
* removed/total counts), leaving the shallowest lineage intact.
|
|
1730
|
-
*
|
|
1731
|
-
* @param id - the root session id to delete.
|
|
1732
|
-
* @returns the outcome line for the panel/notice.
|
|
1733
|
-
*/
|
|
1734
|
-
const deleteSession = async (id: string): Promise<string> => {
|
|
1735
|
-
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
1736
|
-
if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
|
|
1737
|
-
const records = await sessionQuery.listSessions()
|
|
1738
|
-
const plan = planSessionDeletion(records, id)
|
|
1739
|
-
if (!plan.ok) return plan.reason
|
|
1740
|
-
// Phase 2 completes the plan before the first rm: derive and
|
|
1741
|
-
// layout-check every node up front, so a refusal never leaves a
|
|
1742
|
-
// partially removed subtree behind.
|
|
1743
|
-
const root = jsonlSessionRoot(persistence)
|
|
1744
|
-
if (root === undefined || persistence === undefined) {
|
|
1745
|
-
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
1746
|
-
}
|
|
1747
|
-
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
1748
|
-
const dirs = new Map<string, string>()
|
|
1749
|
-
for (const node of plan.nodes) {
|
|
1750
|
-
const record = byId.get(node.id)
|
|
1751
|
-
if (record === undefined) return `no persisted session matches "${node.id}"`
|
|
1752
|
-
const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
|
|
1753
|
-
if (dir === undefined) {
|
|
1754
|
-
return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
|
|
1755
|
-
}
|
|
1756
|
-
dirs.set(node.id, dir)
|
|
1757
|
-
}
|
|
1758
|
-
let leases
|
|
1759
|
-
try {
|
|
1760
|
-
leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
|
|
1761
|
-
} catch (error: unknown) {
|
|
1762
|
-
if (error instanceof SessionAlreadyOwnedError) {
|
|
1763
|
-
return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
|
|
1764
|
-
}
|
|
1765
|
-
return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
let removed = 0
|
|
1769
|
-
let outcome: string | undefined
|
|
1770
|
-
for (const node of plan.nodes) {
|
|
1771
|
-
const dir = dirs.get(node.id)!
|
|
1772
|
-
try {
|
|
1773
|
-
// Remove every canonical generation artifact this build knows; other
|
|
1774
|
-
// sibling files are never ours to delete. The POSIX session.lock file
|
|
1775
|
-
// deliberately remains because unlinking a held flock inode would
|
|
1776
|
-
// forfeit the backend's exclusion guarantee.
|
|
1777
|
-
const entries = await readdir(dir, { withFileTypes: true })
|
|
1778
|
-
for (const entry of entries) {
|
|
1779
|
-
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
1780
|
-
await rm(join(dir, entry.name), { force: true })
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1784
|
-
removed += 1
|
|
1785
|
-
} catch (error: unknown) {
|
|
1786
|
-
outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
1787
|
-
break
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1791
|
-
try {
|
|
1792
|
-
await releaseSessionDeletionLeases(leases)
|
|
1793
|
-
} catch (error: unknown) {
|
|
1794
|
-
return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
|
|
1795
|
-
}
|
|
1796
|
-
return outcome
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
1800
|
-
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
1801
|
-
const snapshot = await sessionQuery.readSession(id, signal)
|
|
1802
|
-
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
1803
|
-
}
|
|
1272
|
+
const { loadSessions, deleteSession, loadSessionTranscript } = createSessionIo({
|
|
1273
|
+
sessionQuery,
|
|
1274
|
+
persistence,
|
|
1275
|
+
activeSessionId: () => session?.id,
|
|
1276
|
+
})
|
|
1804
1277
|
|
|
1805
1278
|
/**
|
|
1806
1279
|
* Read one session's usage blocks for the /usage panel: the mounted
|
|
@@ -1821,7 +1294,6 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1821
1294
|
turns: turnUsages(current.snapshotEvents(), deriveTurnTokenUsage),
|
|
1822
1295
|
})
|
|
1823
1296
|
}
|
|
1824
|
-
|
|
1825
1297
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
1826
1298
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
1827
1299
|
const currentAgent = agent
|
|
@@ -2190,6 +1662,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2190
1662
|
loadModelProviders: () => loadProviderSettings(ctx),
|
|
2191
1663
|
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
2192
1664
|
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1665
|
+
enableModelProviderSubscription: target => enableProviderSubscription(ctx, target),
|
|
1666
|
+
attachSubagent: sessionQuery === undefined ? undefined : {
|
|
1667
|
+
// The seed is the child's durable log; the two buses are the same
|
|
1668
|
+
// process-local channels the root uses, filtered by the child's id
|
|
1669
|
+
// (an Agent's id IS its SessionId).
|
|
1670
|
+
load: (id, signal) => sessionQuery.readSession(id, signal).then(snapshot => snapshot.events),
|
|
1671
|
+
subscribeEvents: (id, onEvent) => {
|
|
1672
|
+
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
1673
|
+
if (subject.id === id) onEvent(event)
|
|
1674
|
+
})
|
|
1675
|
+
return off
|
|
1676
|
+
},
|
|
1677
|
+
subscribeStream: (id, onFrame) => {
|
|
1678
|
+
const off = ctx.on('agent/assistant-stream', ({ agent: source, frame }) => {
|
|
1679
|
+
if (source.id === id) onFrame(frame)
|
|
1680
|
+
})
|
|
1681
|
+
return off
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
2193
1684
|
saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
|
|
2194
1685
|
discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
|
|
2195
1686
|
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
@@ -2254,8 +1745,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2254
1745
|
saveLanguage,
|
|
2255
1746
|
animations: animationsEnabled,
|
|
2256
1747
|
saveAnimations,
|
|
2257
|
-
history: inputHistory,
|
|
2258
|
-
recordHistory,
|
|
1748
|
+
history: inputHistory.entries(),
|
|
1749
|
+
recordHistory: inputHistory.record,
|
|
2259
1750
|
updateQueued,
|
|
2260
1751
|
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
2261
1752
|
})
|
|
@@ -2334,22 +1825,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2334
1825
|
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
2335
1826
|
*/
|
|
2336
1827
|
export function apply(ctx: Context, config: Config): void {
|
|
2337
|
-
// The
|
|
2338
|
-
//
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
...(config.startup.prompt === undefined ? {} : { prompt: config.startup.prompt }),
|
|
2343
|
-
...(config.startup.images === undefined ? {} : { images: config.startup.images }),
|
|
2344
|
-
}
|
|
2345
|
-
const startup: TuiStartup =
|
|
2346
|
-
config.startup.kind === 'resume' && config.startup.sessionId !== undefined
|
|
2347
|
-
? { kind: 'resume', sessionId: config.startup.sessionId, ...input }
|
|
2348
|
-
: config.startup.kind === 'latest'
|
|
2349
|
-
? { kind: 'latest', ...input }
|
|
2350
|
-
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
2351
|
-
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
2352
|
-
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode }, ...input }
|
|
1828
|
+
// The host resolves every bare @deepseek-ai/* import against its own
|
|
1829
|
+
// installed copies with no version check anywhere on that path, so refuse
|
|
1830
|
+
// to run against an identified-but-different Harness before anything loads.
|
|
1831
|
+
requireHarnessVersion(EXPECTED_HARNESS_VERSION, probeRunningHarness(process.argv[1]))
|
|
1832
|
+
const startup = resolveStartupConfig(config)
|
|
2353
1833
|
// Read through the global service store, not the property proxy: appExit is
|
|
2354
1834
|
// an optional host value, never an injected dependency.
|
|
2355
1835
|
const exit = ctx.get('appExit')
|