dsh-code 0.7.0 → 0.9.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 +30 -7
- package/README.md +30 -7
- package/lib/index.mjs +3791 -853
- package/lib/types/app.d.ts +90 -1
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +65 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +97 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +14 -1
- package/lib/types/session-directory.d.ts +61 -2
- package/lib/types/store.d.ts +13 -2
- package/lib/types/subagents.d.ts +60 -0
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1200 -219
- package/src/approval.ts +161 -126
- package/src/history.ts +20 -5
- package/src/index.ts +577 -167
- package/src/kernel-panels.ts +354 -37
- package/src/models.ts +26 -0
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +236 -218
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +670 -11
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +77 -34
- package/src/session-directory.ts +171 -10
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/subagents.ts +165 -0
- package/src/version.ts +16 -0
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { randomUUID } from 'node:crypto'
|
|
13
13
|
import { readFileSync } from 'node:fs'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
|
-
import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
15
|
+
import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
16
|
import { basename, dirname, join } from 'node:path'
|
|
17
17
|
import { createElement } from 'react'
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -33,23 +33,45 @@ import { App, type NoticeTone } from './app.ts'
|
|
|
33
33
|
import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
|
|
34
34
|
import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
|
|
35
35
|
import { internals, type TuiMount } from './internals.ts'
|
|
36
|
-
import { buildModelSelection, loadModelDirectory, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
36
|
+
import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
|
|
37
|
+
import {
|
|
38
|
+
loadProviderSettings,
|
|
39
|
+
removeProviderSettings,
|
|
40
|
+
saveProviderCredential,
|
|
41
|
+
subscribeProviderSettings,
|
|
42
|
+
unsetProviderCredential,
|
|
43
|
+
} from './provider-settings.ts'
|
|
37
44
|
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
38
45
|
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
39
46
|
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
47
|
+
import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
|
|
40
48
|
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
-
import {
|
|
49
|
+
import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
42
50
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
43
51
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
44
52
|
import { buildExportMarkdown } from './render/export.ts'
|
|
45
53
|
import type { TuiStartup } from './startup.ts'
|
|
46
54
|
import { SessionSwitchQueue } from './session-switch.ts'
|
|
47
|
-
import { agentPresetsFrom, resolvePreset,
|
|
55
|
+
import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
|
|
56
|
+
import {
|
|
57
|
+
applyPendingPermission,
|
|
58
|
+
cyclePermission as cyclePermissionPreset,
|
|
59
|
+
effectivePermission,
|
|
60
|
+
listPermissionRows,
|
|
61
|
+
permissionPresetsFrom,
|
|
62
|
+
selectPermission,
|
|
63
|
+
} from './permissions.ts'
|
|
48
64
|
import { listPluginRows } from './plugin-inventory.ts'
|
|
49
65
|
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
50
66
|
import {
|
|
67
|
+
collectDeletionSubtree,
|
|
68
|
+
isSubagentSession,
|
|
69
|
+
matchSessionId,
|
|
51
70
|
mergeSessionTitles,
|
|
71
|
+
newestRootForCwd,
|
|
52
72
|
projectSessionRows,
|
|
73
|
+
SESSION_ARTIFACT_NAMES,
|
|
74
|
+
sessionArtifactDirectory,
|
|
53
75
|
type SessionDirectoryOptions,
|
|
54
76
|
type SessionQueryService,
|
|
55
77
|
type SessionRow,
|
|
@@ -112,6 +134,64 @@ interface Target {
|
|
|
112
134
|
cwd?: string
|
|
113
135
|
}
|
|
114
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
139
|
+
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
140
|
+
* user text: path separators must never leak into the default export filename
|
|
141
|
+
* (which would escape the session cwd).
|
|
142
|
+
* @param id - the session id.
|
|
143
|
+
* @returns at most the last 8 filename-safe characters.
|
|
144
|
+
*/
|
|
145
|
+
export function exportSessionIdSuffix(id: string): string {
|
|
146
|
+
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One ordered step of the terminal quit cleanup. */
|
|
150
|
+
export interface QuitCleanupStep {
|
|
151
|
+
/** Step label used in diagnostics and tests. */
|
|
152
|
+
readonly name: string
|
|
153
|
+
/** The step's async work; a rejection is contained by the sequence. */
|
|
154
|
+
readonly run: () => Promise<void>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
159
|
+
* contained (reported through `onError`) so a failed flush or dispose never
|
|
160
|
+
* skips the remaining cleanup; the exit request is always reached exactly
|
|
161
|
+
* once.
|
|
162
|
+
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
163
|
+
* session, await the final in-flight composition, await durable recall).
|
|
164
|
+
* @param exit - the terminal exit request (code 0).
|
|
165
|
+
* @param onError - optional failure sink; called once per failing step and
|
|
166
|
+
* itself contained, so a throwing sink cannot abort the sequence.
|
|
167
|
+
* @returns the names of the steps that started, in order (for tests).
|
|
168
|
+
*/
|
|
169
|
+
export async function runQuitSequence(
|
|
170
|
+
steps: readonly QuitCleanupStep[],
|
|
171
|
+
exit: (code: number) => void,
|
|
172
|
+
onError?: (name: string, error: unknown) => void,
|
|
173
|
+
): Promise<readonly string[]> {
|
|
174
|
+
const started: string[] = []
|
|
175
|
+
for (const step of steps) {
|
|
176
|
+
started.push(step.name)
|
|
177
|
+
try {
|
|
178
|
+
await step.run()
|
|
179
|
+
} catch (error) {
|
|
180
|
+
try {
|
|
181
|
+
onError?.(step.name, error)
|
|
182
|
+
} catch {
|
|
183
|
+
// The failure sink must never abort the cleanup sequence.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
exit(0)
|
|
189
|
+
} catch {
|
|
190
|
+
// The exit request itself must not become an unhandled rejection.
|
|
191
|
+
}
|
|
192
|
+
return started
|
|
193
|
+
}
|
|
194
|
+
|
|
115
195
|
/**
|
|
116
196
|
* Resolve the invocation's target session against the persisted headers.
|
|
117
197
|
* @param startup - the parsed startup flags.
|
|
@@ -120,29 +200,37 @@ interface Target {
|
|
|
120
200
|
* @returns the target identity.
|
|
121
201
|
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
122
202
|
*/
|
|
123
|
-
async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
203
|
+
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
124
204
|
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
125
|
-
if (startup.kind === 'named')
|
|
205
|
+
if (startup.kind === 'named') {
|
|
206
|
+
// The id must not exist yet: reject before any Agent composition when the
|
|
207
|
+
// backend can tell us (a live collision is still caught by the session
|
|
208
|
+
// store at create time).
|
|
209
|
+
if (persistence !== undefined) {
|
|
210
|
+
const headers: readonly SessionHeader[] = await persistence.list()
|
|
211
|
+
if (headers.some(header => header.id === startup.sessionId)) {
|
|
212
|
+
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
216
|
+
}
|
|
126
217
|
if (persistence === undefined) {
|
|
127
218
|
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
128
219
|
}
|
|
129
220
|
const headers: readonly SessionHeader[] = await persistence.list()
|
|
130
221
|
if (startup.kind === 'resume') {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.sort((left, right) => right.createdAt - left.createdAt)
|
|
144
|
-
if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
145
|
-
return { sessionId: local[0]!.id, resume: true }
|
|
222
|
+
const matched = matchSessionId(headers, startup.sessionId)
|
|
223
|
+
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
224
|
+
// be a back door into appending root turns to a child's durable log.
|
|
225
|
+
if (isSubagentSession(matched)) {
|
|
226
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
227
|
+
}
|
|
228
|
+
return { sessionId: matched.id, resume: true }
|
|
229
|
+
}
|
|
230
|
+
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
231
|
+
const newest = newestRootForCwd(headers, cwd)
|
|
232
|
+
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
233
|
+
return { sessionId: newest.id, resume: true }
|
|
146
234
|
}
|
|
147
235
|
|
|
148
236
|
/**
|
|
@@ -190,9 +278,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
190
278
|
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
191
279
|
|
|
192
280
|
const cwd = process.cwd()
|
|
193
|
-
|
|
281
|
+
// Live deployment default (web selectModel parity): read on every use, not
|
|
282
|
+
// snapshotted at launch, so a /model pick this process saves becomes the
|
|
283
|
+
// default for sessions composed afterwards without a restart.
|
|
284
|
+
const currentDefaults = (): ModelSelection => defaultModel.currentSelection()
|
|
194
285
|
const presets = agentPresetsFrom(ctx)
|
|
195
286
|
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
287
|
+
const permissionPresets = permissionPresetsFrom(ctx)
|
|
196
288
|
|
|
197
289
|
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
198
290
|
// nothing is persisted, until the user's first real input. Explicit flags
|
|
@@ -219,7 +311,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
219
311
|
const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
|
|
220
312
|
? {}
|
|
221
313
|
: { picked: pendingSelection }
|
|
222
|
-
let mode = next.mode
|
|
314
|
+
let mode = next.resume ? next.mode : next.mode ?? pendingMode
|
|
223
315
|
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
224
316
|
const setup = async (agentCtx: Context): Promise<void> => {
|
|
225
317
|
const sessionPreset = next.resume
|
|
@@ -229,26 +321,38 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
229
321
|
mode = mounted.id
|
|
230
322
|
const selection: ModelSelectionRef = {
|
|
231
323
|
get current(): ModelSelection | undefined {
|
|
232
|
-
return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config,
|
|
324
|
+
return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
|
|
233
325
|
},
|
|
234
326
|
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
235
327
|
assembled: undefined,
|
|
236
328
|
}
|
|
237
329
|
installModelSelection(agentCtx, selection)
|
|
238
330
|
}
|
|
331
|
+
// AgentOptions seed the loop's fallback route; effort rides the selection
|
|
332
|
+
// ref (installModelSelection), so only the provider/model pair is seeded.
|
|
333
|
+
const seedOptions = pendingSelection === undefined
|
|
334
|
+
? { provider: currentDefaults().provider, model: currentDefaults().model }
|
|
335
|
+
: { provider: pendingSelection.provider, model: pendingSelection.model }
|
|
239
336
|
const handle = next.resume
|
|
240
337
|
? await agents.resume({
|
|
241
338
|
resumeSessionId: SessionId(next.sessionId),
|
|
242
|
-
agentOptions:
|
|
339
|
+
agentOptions: seedOptions,
|
|
340
|
+
// Quit aborts an in-flight composition so the exit wait never hangs
|
|
341
|
+
// on a prepare that cannot settle; upstream rolls the creation back.
|
|
342
|
+
signal: quitAbort.signal,
|
|
243
343
|
setup,
|
|
244
344
|
})
|
|
245
345
|
: await agents.create({
|
|
246
346
|
sessionId: SessionId(next.sessionId),
|
|
247
347
|
meta: { cwd: nextCwd, agentPreset: mode },
|
|
248
|
-
agentOptions:
|
|
348
|
+
agentOptions: seedOptions,
|
|
349
|
+
signal: quitAbort.signal,
|
|
249
350
|
setup,
|
|
250
351
|
})
|
|
251
352
|
const session = handle.agent.session
|
|
353
|
+
if (!next.resume && permissionPresets !== undefined) {
|
|
354
|
+
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
355
|
+
}
|
|
252
356
|
const sessionCwd = session.header.cwd ?? nextCwd
|
|
253
357
|
return {
|
|
254
358
|
handle,
|
|
@@ -266,12 +370,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
266
370
|
let agent: Agent | undefined
|
|
267
371
|
let session: Session | undefined
|
|
268
372
|
let store: TranscriptStore = createTranscriptStore()
|
|
373
|
+
// Live subagent activity (child sessions of the current root): one bounded
|
|
374
|
+
// row per child, folded from the same event bus the transcript feeds on.
|
|
375
|
+
const subagents: SubagentFeedView & { apply(sessionId: string, event: SessionEvent): void; reset(): void } = createSubagentFeed()
|
|
269
376
|
// File-only mentions from the start: `@` completion works on a bare launch
|
|
270
377
|
// (no session yet); the prepare/activate paths replace this with the full
|
|
271
378
|
// agent-scoped instance that also resolves session references.
|
|
272
379
|
let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
|
|
273
380
|
/** Explicit model pick made before any session exists (a bare launch). */
|
|
274
381
|
let pendingSelection: ModelSelection | undefined
|
|
382
|
+
/** Agent preset selected before the first session exists. */
|
|
383
|
+
let pendingMode: string | undefined
|
|
384
|
+
/** Ordered pre-session preset resolutions; first composition awaits them. */
|
|
385
|
+
let pendingModeWork: Promise<void> = Promise.resolve()
|
|
386
|
+
/** Permission preset selected before the first session exists. */
|
|
387
|
+
let pendingPermission: string | undefined
|
|
388
|
+
/**
|
|
389
|
+
* Monotonic session epoch: bumped on every successful activation, on every
|
|
390
|
+
* first-session creation, and on quit. Async callbacks (mention prepares,
|
|
391
|
+
* command executions) capture it at call time and drop their result when it
|
|
392
|
+
* changed, so a stale callback can never deliver to an agent that is no
|
|
393
|
+
* longer on screen.
|
|
394
|
+
*/
|
|
395
|
+
let epoch = 0
|
|
396
|
+
/** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
|
|
397
|
+
const quitAbort = new AbortController()
|
|
398
|
+
/** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
|
|
399
|
+
const pendingControllers = new Set<AbortController>()
|
|
400
|
+
const abortPendingControllers = (): void => {
|
|
401
|
+
for (const controller of [...pendingControllers]) {
|
|
402
|
+
pendingControllers.delete(controller)
|
|
403
|
+
controller.abort()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** The in-flight session-composition turn (create/resume/activate), if any. */
|
|
407
|
+
let composing: Promise<void> | undefined
|
|
408
|
+
/**
|
|
409
|
+
* Run one session composition exclusively: concurrent compositions wait
|
|
410
|
+
* their turn, so a bare-launch first-session creation and a /resume
|
|
411
|
+
* activation can never compose agents in parallel (the loser would leak its
|
|
412
|
+
* agent or mis-deliver). Errors propagate to the caller; the shared slot
|
|
413
|
+
* always continues.
|
|
414
|
+
*/
|
|
415
|
+
const compose = (work: () => Promise<void>): Promise<void> => {
|
|
416
|
+
const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
|
|
417
|
+
composing = turn.catch(() => {})
|
|
418
|
+
return turn
|
|
419
|
+
}
|
|
275
420
|
|
|
276
421
|
if (!lazy) {
|
|
277
422
|
const target = await resolveTarget(startup, persistence, cwd)
|
|
@@ -288,7 +433,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
288
433
|
// before the first render. The handler reads the current session/store, so
|
|
289
434
|
// the deferred first session of a bare launch is covered by the same feed.
|
|
290
435
|
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
291
|
-
if (session
|
|
436
|
+
if (session === undefined) return
|
|
437
|
+
if (subject.id === session.id) {
|
|
438
|
+
store.apply(event)
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
// Child sessions (subagent conversations this root spawned) fold into
|
|
442
|
+
// the bounded live-activity feed, never the transcript: the root stays
|
|
443
|
+
// the only durable transcript truth while a running subagent remains
|
|
444
|
+
// visible. Lineage comes from the child header, same field the session
|
|
445
|
+
// directory uses to tag `↳` rows.
|
|
446
|
+
if (subject.header.parentSession === session.id) subagents.apply(subject.id, event)
|
|
292
447
|
})
|
|
293
448
|
|
|
294
449
|
const commands: CommandsView = watchCommands(ctx)
|
|
@@ -306,6 +461,28 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
306
461
|
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
307
462
|
)
|
|
308
463
|
|
|
464
|
+
// Subagent model routing. The kernel seeds child agents from the parent's
|
|
465
|
+
// CREATE-TIME AgentOptions (resolveChildAgentOptions), which a mid-session
|
|
466
|
+
// /model switch never touches — delegated work would keep running on the
|
|
467
|
+
// launch-time route. This plugin-level listener mirrors installModelSelection
|
|
468
|
+
// for subagent-origin requests (scope filtering delivers the agent subject
|
|
469
|
+
// inside the payload): the explicit /subagent override wins, else the root's
|
|
470
|
+
// effective selection (explicit pick > session header > deployment default).
|
|
471
|
+
// Effort rides the selection exactly like the kernel listener applies it.
|
|
472
|
+
let subagentOverride: ModelSelection | undefined
|
|
473
|
+
ctx.on('agent/request', (payload, next) => {
|
|
474
|
+
const subject = payload.agent
|
|
475
|
+
const header = subject.session.header
|
|
476
|
+
if (header.parentSession === undefined && header.origin !== 'subagent') return next()
|
|
477
|
+
const picked = subagentOverride
|
|
478
|
+
?? resolveEffectiveSelection(
|
|
479
|
+
active?.selection.picked ?? pendingSelection,
|
|
480
|
+
subject.session.requestHeader()?.config,
|
|
481
|
+
currentDefaults(),
|
|
482
|
+
)
|
|
483
|
+
return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
|
|
484
|
+
})
|
|
485
|
+
|
|
309
486
|
// ask_user_question provider: the single UI provider on the shared service,
|
|
310
487
|
// one request on screen at a time. Plan reviews (exit_plan_mode) arrive
|
|
311
488
|
// through this same pipe.
|
|
@@ -378,18 +555,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
378
555
|
} catch {
|
|
379
556
|
inputHistory = []
|
|
380
557
|
}
|
|
558
|
+
/** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
|
|
559
|
+
let historyWriteChain: Promise<void> = Promise.resolve()
|
|
381
560
|
const recordHistory = (text: string): void => {
|
|
382
561
|
if (text === '') return
|
|
383
562
|
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
void mkdir(dirname(historyPath), { recursive: true })
|
|
392
|
-
.then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
|
|
563
|
+
// Write the whole current list, serialized per submission: the file is
|
|
564
|
+
// never read back on the submit path, so rapid same-process submissions
|
|
565
|
+
// cannot lose entries to a read-modify-write race.
|
|
566
|
+
historyWriteChain = historyWriteChain
|
|
567
|
+
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
568
|
+
.then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
|
|
393
569
|
.catch((writeError: unknown) => {
|
|
394
570
|
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
395
571
|
})
|
|
@@ -415,37 +591,43 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
415
591
|
if (quitting) return
|
|
416
592
|
quitting = true
|
|
417
593
|
switchQueue.cancel()
|
|
594
|
+
// Stale prepares/commands die with the session they were for. Aborting
|
|
595
|
+
// the composition signal lets a never-settling prepare reject, so the
|
|
596
|
+
// exit wait below cannot hang (upstream rolls the creation back).
|
|
597
|
+
abortPendingControllers()
|
|
598
|
+
quitAbort.abort()
|
|
599
|
+
epoch += 1
|
|
418
600
|
off()
|
|
419
601
|
mountRef.current?.unmount()
|
|
420
|
-
// A bare launch that exits before the first input has no session: exit
|
|
421
|
-
// cleanly without flushing or disposing anything.
|
|
422
602
|
const currentSession = session
|
|
423
603
|
const currentActive = active
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
return
|
|
604
|
+
const report = (name: string, error: unknown): void => {
|
|
605
|
+
internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
427
606
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
607
|
+
// One ordered cleanup: settle the visible session (if any — a bare launch
|
|
608
|
+
// that never composed one resolves immediately), then wait for the final
|
|
609
|
+
// in-flight composition (its work swallows errors and the quitting guard
|
|
610
|
+
// disposes any half-prepared agent), then flush the durable recall, then
|
|
611
|
+
// request exit. `composing` and `historyWriteChain` are read at step run
|
|
612
|
+
// time, so a turn that was still being queued when quit ran is included.
|
|
613
|
+
// A failing step must never skip the remaining cleanup.
|
|
614
|
+
const steps: QuitCleanupStep[] = [
|
|
615
|
+
...(currentSession === undefined || currentActive === undefined
|
|
616
|
+
? []
|
|
617
|
+
: [
|
|
618
|
+
{ name: 'flush', run: async () => { await sessions.flush(currentSession) } },
|
|
619
|
+
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
620
|
+
]),
|
|
621
|
+
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
622
|
+
{ name: 'history', run: () => historyWriteChain },
|
|
623
|
+
]
|
|
624
|
+
void runQuitSequence(steps, io.exit, report)
|
|
439
625
|
}
|
|
440
626
|
|
|
441
627
|
/** Run one slash line through the command registry (closed namespace). */
|
|
442
628
|
const runSlash = (line: string): void => {
|
|
443
629
|
const currentAgent = agent
|
|
444
630
|
if (currentAgent === undefined) return
|
|
445
|
-
if (line.startsWith('/mode ')) {
|
|
446
|
-
void switchModeAction(line.slice(6).trim())
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
631
|
if (line.startsWith('/resume ')) {
|
|
450
632
|
requestResume(line.slice(8).trim())
|
|
451
633
|
return
|
|
@@ -456,7 +638,16 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
456
638
|
return
|
|
457
639
|
}
|
|
458
640
|
const controller = new AbortController()
|
|
641
|
+
const atEpoch = epoch
|
|
642
|
+
pendingControllers.add(controller)
|
|
643
|
+
const finish = (): void => {
|
|
644
|
+
pendingControllers.delete(controller)
|
|
645
|
+
}
|
|
459
646
|
void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
|
|
647
|
+
finish()
|
|
648
|
+
// A switch/quit landed while the command ran: its fall-through must not
|
|
649
|
+
// reach an agent that is no longer on screen.
|
|
650
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
460
651
|
if (execution === undefined) {
|
|
461
652
|
// No command owns this line: send it verbatim so a user-invocable
|
|
462
653
|
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
@@ -471,6 +662,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
471
662
|
}
|
|
472
663
|
}
|
|
473
664
|
}, (error: unknown) => {
|
|
665
|
+
finish()
|
|
666
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
474
667
|
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
475
668
|
})
|
|
476
669
|
}
|
|
@@ -493,7 +686,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
493
686
|
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
494
687
|
return
|
|
495
688
|
}
|
|
689
|
+
const atEpoch = epoch
|
|
496
690
|
const deliver = (readable: string, context?: UserMessage): void => {
|
|
691
|
+
// A switch/quit landed while the snapshot was being prepared: never
|
|
692
|
+
// deliver to an agent that is no longer on screen.
|
|
693
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
497
694
|
// Session snapshots ride the inbox as model-facing context ahead of
|
|
498
695
|
// the readable message (upstream README wiring: inject before the
|
|
499
696
|
// followup/steer that wakes the driver).
|
|
@@ -519,10 +716,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
519
716
|
return
|
|
520
717
|
}
|
|
521
718
|
const controller = new AbortController()
|
|
719
|
+
pendingControllers.add(controller)
|
|
522
720
|
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
721
|
+
pendingControllers.delete(controller)
|
|
523
722
|
deliver(prepared.text, prepared.additionalContext)
|
|
524
723
|
}, (error: unknown) => {
|
|
525
|
-
|
|
724
|
+
pendingControllers.delete(controller)
|
|
725
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
526
726
|
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
527
727
|
})
|
|
528
728
|
}
|
|
@@ -533,47 +733,84 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
533
733
|
// failure reports and clears the queue, leaving the transient state ready
|
|
534
734
|
// for the next attempt.
|
|
535
735
|
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
|
|
536
|
-
|
|
736
|
+
// A creation is queued/running: further submissions must not mint more
|
|
737
|
+
// fresh sessions (their lines queue into pendingInputs instead).
|
|
738
|
+
let creating = false
|
|
537
739
|
const ensureSession = (mode?: string): void => {
|
|
538
|
-
if (creating
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
740
|
+
if (creating) return
|
|
741
|
+
creating = true
|
|
742
|
+
void compose(async () => {
|
|
743
|
+
try {
|
|
744
|
+
// A direct `/mode <preset>` resolves asynchronously. Preserve submit
|
|
745
|
+
// order so the first composition cannot race ahead with the old mode.
|
|
746
|
+
await pendingModeWork
|
|
747
|
+
// Another composition (e.g. a /resume activated while this creation
|
|
748
|
+
// waited its turn) may have published a session already: deliver the
|
|
749
|
+
// queued lines there instead of minting a competing fresh session
|
|
750
|
+
// (which would orphan the live one without a dispose).
|
|
751
|
+
if (session !== undefined) {
|
|
752
|
+
const queued = pendingInputs.splice(0)
|
|
753
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
const next = await prepare({
|
|
757
|
+
sessionId: `session-${randomUUID()}`,
|
|
758
|
+
resume: false,
|
|
759
|
+
...(mode === undefined ? {} : { mode }),
|
|
760
|
+
})
|
|
761
|
+
if (quitting) {
|
|
762
|
+
void next.handle.dispose().catch(() => {})
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
active = next
|
|
766
|
+
agent = next.agent
|
|
767
|
+
session = next.session
|
|
768
|
+
store = next.store
|
|
769
|
+
mentions = next.mentions
|
|
770
|
+
subagents.reset()
|
|
771
|
+
pendingMode = undefined
|
|
772
|
+
pendingPermission = undefined
|
|
773
|
+
commands.setAgent(agent)
|
|
774
|
+
skills.setAgent(agent)
|
|
775
|
+
// The App mounts with a placeholder key until the first input; the
|
|
776
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
777
|
+
// static header stays visible above the new one (same source-backed
|
|
778
|
+
// clear the session-switch path performs).
|
|
779
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
780
|
+
renderCurrent()
|
|
781
|
+
abortPendingControllers()
|
|
782
|
+
epoch += 1
|
|
783
|
+
const queued = pendingInputs.splice(0)
|
|
784
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
785
|
+
} finally {
|
|
786
|
+
creating = false
|
|
548
787
|
}
|
|
549
|
-
|
|
550
|
-
agent = next.agent
|
|
551
|
-
session = next.session
|
|
552
|
-
store = next.store
|
|
553
|
-
mentions = next.mentions
|
|
554
|
-
commands.setAgent(agent)
|
|
555
|
-
skills.setAgent(agent)
|
|
556
|
-
// The App mounts with a placeholder key until the first input; the
|
|
557
|
-
// key-change remount below must start from a clean screen or the ghost
|
|
558
|
-
// static header stays visible above the new one (same source-backed
|
|
559
|
-
// clear the session-switch path performs).
|
|
560
|
-
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
561
|
-
renderCurrent()
|
|
562
|
-
const queued = pendingInputs.splice(0)
|
|
563
|
-
for (const item of queued) deliverLine(item.text, item.mode)
|
|
564
|
-
})().catch((error: unknown) => {
|
|
788
|
+
}).catch((error: unknown) => {
|
|
565
789
|
pendingInputs.length = 0
|
|
566
790
|
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
567
|
-
}).finally(() => {
|
|
568
|
-
creating = undefined
|
|
569
791
|
})
|
|
570
|
-
creating = attempt
|
|
571
792
|
}
|
|
572
793
|
|
|
573
794
|
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
574
795
|
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
575
796
|
const line = text.trim()
|
|
576
797
|
if (line === '') return
|
|
798
|
+
if (line.startsWith('/mode ')) {
|
|
799
|
+
void switchModeAction(line.slice(6).trim()).then(
|
|
800
|
+
selected => bridge.notify(`mode → ${selected}`),
|
|
801
|
+
error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
802
|
+
)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
if (line.startsWith('/permission ')) {
|
|
806
|
+
try {
|
|
807
|
+
const selected = setPermissionAction(line.slice(12).trim())
|
|
808
|
+
bridge.notify(`permission → ${selected}`)
|
|
809
|
+
} catch (error: unknown) {
|
|
810
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
811
|
+
}
|
|
812
|
+
return
|
|
813
|
+
}
|
|
577
814
|
if (session === undefined) {
|
|
578
815
|
pendingInputs.push({ text: line, mode })
|
|
579
816
|
ensureSession()
|
|
@@ -609,29 +846,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
609
846
|
}
|
|
610
847
|
}
|
|
611
848
|
|
|
849
|
+
/** Select one permission preset before the first session or on the active one. */
|
|
850
|
+
const setPermissionAction = (id: string): string => {
|
|
851
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
852
|
+
throw new Error('permission presets are not mounted in this composition')
|
|
853
|
+
}
|
|
854
|
+
if (id === '') throw new Error('usage: /permission <preset>')
|
|
855
|
+
const selected = selectPermission(permissionPresets, session, id)
|
|
856
|
+
if (session === undefined) {
|
|
857
|
+
pendingPermission = selected
|
|
858
|
+
renderCurrent()
|
|
859
|
+
}
|
|
860
|
+
return selected
|
|
861
|
+
}
|
|
862
|
+
|
|
612
863
|
/**
|
|
613
|
-
* Cycle to the next permission preset (Shift+Tab
|
|
614
|
-
*
|
|
615
|
-
*
|
|
864
|
+
* Cycle to the next permission preset (Shift+Tab). Before the first session,
|
|
865
|
+
* the choice remains process-local and is materialized when Harness creates
|
|
866
|
+
* that session; afterwards the canonical service writes durable events.
|
|
616
867
|
*/
|
|
617
868
|
const cyclePermission = (): string => {
|
|
618
|
-
if (
|
|
619
|
-
const service = ctx.get('permissionPresets') as
|
|
620
|
-
| {
|
|
621
|
-
names: readonly string[]
|
|
622
|
-
current(events: readonly SessionEvent[]): string
|
|
623
|
-
set(target: Session, preset: string): void
|
|
624
|
-
}
|
|
625
|
-
| undefined
|
|
626
|
-
if (service === undefined || service.names.length === 0) {
|
|
869
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
627
870
|
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
628
871
|
return ''
|
|
629
872
|
}
|
|
630
|
-
const at = service.names.indexOf(service.current(session.events))
|
|
631
|
-
const next = service.names[(at + 1) % service.names.length] ?? ''
|
|
632
|
-
if (next === '') return ''
|
|
633
873
|
try {
|
|
634
|
-
|
|
874
|
+
const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
|
|
875
|
+
if (session === undefined && next !== '') {
|
|
876
|
+
pendingPermission = next
|
|
877
|
+
renderCurrent()
|
|
878
|
+
}
|
|
635
879
|
return next
|
|
636
880
|
} catch (error: unknown) {
|
|
637
881
|
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
@@ -654,9 +898,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
654
898
|
} else {
|
|
655
899
|
active.selection.picked = selection
|
|
656
900
|
}
|
|
901
|
+
// Global default (web selectModel parity): every pick is persisted as the
|
|
902
|
+
// deployment default through the same agentDefaultModel service the web
|
|
903
|
+
// host writes, so the choice survives restarts and other surfaces read
|
|
904
|
+
// it. Save failures degrade to a notice — the in-session switch already
|
|
905
|
+
// took effect and must not roll back (the web contract).
|
|
906
|
+
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
907
|
+
bridge.notify(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
908
|
+
})
|
|
909
|
+
// Advisory immediate validation (web selectModel parity): run the same
|
|
910
|
+
// local resolveCallConfig check the request pipeline would, so a stale
|
|
911
|
+
// directory — an effort the adapter withdrew since /model loaded —
|
|
912
|
+
// surfaces as a pick-time notice instead of failing the next assembled
|
|
913
|
+
// step. Best-effort: an llm service without the resolver keeps the
|
|
914
|
+
// existing request-boundary rejection. Called as a method (`this`-bound)
|
|
915
|
+
// like resolveModelInfo in models.ts.
|
|
916
|
+
const llm = ctx.get('llm')
|
|
917
|
+
const resolveCallConfig = (llm as {
|
|
918
|
+
resolveCallConfig?: (this: unknown, config: { provider: string; model: string; reasoningEffort?: string }) => Promise<unknown>
|
|
919
|
+
} | undefined)?.resolveCallConfig
|
|
920
|
+
if (llm !== undefined && typeof resolveCallConfig === 'function') {
|
|
921
|
+
void Promise.resolve(resolveCallConfig.call(llm, {
|
|
922
|
+
provider: selection.provider,
|
|
923
|
+
model: selection.model,
|
|
924
|
+
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
925
|
+
})).catch((error: unknown) => {
|
|
926
|
+
bridge.notify(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
|
|
927
|
+
})
|
|
928
|
+
}
|
|
657
929
|
return `${row.provider}/${row.model}`
|
|
658
930
|
}
|
|
659
931
|
|
|
932
|
+
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
933
|
+
const subagentModelLabel = (): string => subagentOverride === undefined ? '' : modelSelectionLabel(subagentOverride)
|
|
934
|
+
|
|
935
|
+
/** Apply one /subagent model pick; returns the override label. */
|
|
936
|
+
const setSubagentModel = (row: ModelRow, effortId?: string): string => {
|
|
937
|
+
subagentOverride = buildModelSelection(row, effortId)
|
|
938
|
+
renderCurrent()
|
|
939
|
+
return modelSelectionLabel(subagentOverride)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Drop the /subagent override: delegated agents follow the current model again. */
|
|
943
|
+
const clearSubagentModel = (): void => {
|
|
944
|
+
subagentOverride = undefined
|
|
945
|
+
renderCurrent()
|
|
946
|
+
}
|
|
947
|
+
|
|
660
948
|
/**
|
|
661
949
|
* Export the folded transcript to a markdown file (/export). The default
|
|
662
950
|
* target sits beside the session's cwd so the file lands in the user's
|
|
@@ -669,7 +957,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
669
957
|
}
|
|
670
958
|
const wanted = argument.trim()
|
|
671
959
|
const sessionCwd = session.header.cwd ?? cwd
|
|
672
|
-
|
|
960
|
+
// The default name derives from the session id, which `--session` lets the
|
|
961
|
+
// user spell freely: reduce it to filename-safe characters first so the
|
|
962
|
+
// default target can never escape the session cwd.
|
|
963
|
+
const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
|
|
673
964
|
const target = wanted === ''
|
|
674
965
|
? join(sessionCwd, defaultName)
|
|
675
966
|
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
@@ -705,7 +996,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
705
996
|
|
|
706
997
|
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
707
998
|
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
708
|
-
const
|
|
999
|
+
const records = await sessionQuery.listSessions(signal)
|
|
1000
|
+
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
1001
|
+
// JSONL artifact's mtime via locate()+stat — the upstream api-proxy's own
|
|
1002
|
+
// cold-probe pattern. O(1) per session; backends without a location (or
|
|
1003
|
+
// vanished files) fall back to createdAt inside the projection.
|
|
1004
|
+
const updated = new Map<string, number>()
|
|
1005
|
+
for (const record of records) {
|
|
1006
|
+
const location = persistence?.locate(record.header)
|
|
1007
|
+
if (location === undefined) continue
|
|
1008
|
+
try {
|
|
1009
|
+
updated.set(record.header.id, (await stat(location.path)).mtimeMs)
|
|
1010
|
+
} catch {
|
|
1011
|
+
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const projected = projectSessionRows(records, options, updated)
|
|
709
1015
|
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
710
1016
|
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
711
1017
|
const page = projected.slice(0, 32)
|
|
@@ -714,6 +1020,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
714
1020
|
return mergeSessionTitles(projected, observations)
|
|
715
1021
|
}
|
|
716
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Delete one session subtree (/delete, codex semantics: subagent threads go
|
|
1025
|
+
* with their root). The kernel persistence seam has NO deletion API by
|
|
1026
|
+
* design — logs accumulate "until removed externally" — so this is the
|
|
1027
|
+
* controlled external removal: guards (live/current refusal, subtree
|
|
1028
|
+
* collection, and the JSONL layout check `encodeSegment(id)/session.jsonl`)
|
|
1029
|
+
* run before any filesystem touch, and only the backend-located artifacts
|
|
1030
|
+
* are removed. Backends without a locatable artifact (SQLite) are refused.
|
|
1031
|
+
* @param id - the root session id to delete.
|
|
1032
|
+
* @returns the outcome line for the panel/notice.
|
|
1033
|
+
*/
|
|
1034
|
+
const deleteSession = async (id: string): Promise<string> => {
|
|
1035
|
+
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
1036
|
+
if (session !== undefined && session.id === id) return 'cannot delete the session you are using — switch or /new first'
|
|
1037
|
+
const records = await sessionQuery.listSessions()
|
|
1038
|
+
const target = records.find(record => record.header.id === id)
|
|
1039
|
+
if (target === undefined) return `no persisted session matches "${id}"`
|
|
1040
|
+
if (target.live) return 'cannot delete a live session — it is open in this or another process'
|
|
1041
|
+
const doomed = collectDeletionSubtree(records, id)
|
|
1042
|
+
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
1043
|
+
let removed = 0
|
|
1044
|
+
for (const candidate of doomed) {
|
|
1045
|
+
const record = byId.get(candidate)
|
|
1046
|
+
if (record === undefined || record.live) continue
|
|
1047
|
+
const location = persistence?.locate(record.header)
|
|
1048
|
+
if (location === undefined) {
|
|
1049
|
+
return `session backend exposes no deletable artifact for ${candidate.slice(-12)} (deletion is unsupported on this backend)`
|
|
1050
|
+
}
|
|
1051
|
+
const dir = sessionArtifactDirectory(location.path, candidate)
|
|
1052
|
+
if (dir === undefined) {
|
|
1053
|
+
return `refusing to delete: unexpected artifact layout at ${location.path}`
|
|
1054
|
+
}
|
|
1055
|
+
try {
|
|
1056
|
+
for (const name of SESSION_ARTIFACT_NAMES) {
|
|
1057
|
+
await rm(join(dir, name), { force: true })
|
|
1058
|
+
}
|
|
1059
|
+
// Remove the now-empty session directory; a non-empty one stays (an
|
|
1060
|
+
// unexpected sibling file is never ours to delete).
|
|
1061
|
+
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1062
|
+
removed += 1
|
|
1063
|
+
} catch (error: unknown) {
|
|
1064
|
+
return `delete failed for ${candidate.slice(-12)}: ${error instanceof Error ? error.message : String(error)}`
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1068
|
+
}
|
|
1069
|
+
|
|
717
1070
|
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
718
1071
|
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
719
1072
|
const snapshot = await sessionQuery.readSession(id, signal)
|
|
@@ -723,12 +1076,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
723
1076
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
724
1077
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
725
1078
|
const currentAgent = agent
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1079
|
+
if (currentAgent === undefined) {
|
|
1080
|
+
const choice = pendingModeWork.then(async () => {
|
|
1081
|
+
const preset = await selectPreset(presets, undefined, id)
|
|
1082
|
+
// A resume may have won while this roster read was in flight; never
|
|
1083
|
+
// leak the old pending choice into a later /new session.
|
|
1084
|
+
if (agent === undefined) {
|
|
1085
|
+
pendingMode = preset.id
|
|
1086
|
+
renderCurrent()
|
|
1087
|
+
}
|
|
1088
|
+
return preset.id
|
|
1089
|
+
})
|
|
1090
|
+
pendingModeWork = choice.then(() => {}, () => {})
|
|
1091
|
+
return choice
|
|
729
1092
|
}
|
|
730
|
-
|
|
731
|
-
|
|
1093
|
+
|
|
1094
|
+
const preset = await selectPreset(presets, currentAgent, id)
|
|
1095
|
+
if (active === undefined) throw new Error('active Agent has no session state')
|
|
1096
|
+
active.mode = preset.id
|
|
732
1097
|
commands.setAgent(currentAgent)
|
|
733
1098
|
skills.setAgent(currentAgent)
|
|
734
1099
|
renderCurrent()
|
|
@@ -737,52 +1102,70 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
737
1102
|
|
|
738
1103
|
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
739
1104
|
|
|
740
|
-
const activate =
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1105
|
+
const activate = (nextTarget: Target): Promise<void> => {
|
|
1106
|
+
if (quitting) return Promise.resolve()
|
|
1107
|
+
// Serialized with every other composition (bare-launch creation, queued
|
|
1108
|
+
// switches): at most one agent is composed at a time.
|
|
1109
|
+
return compose(async () => {
|
|
1110
|
+
const previous = active
|
|
1111
|
+
const next = await prepare(nextTarget)
|
|
1112
|
+
// Quit landed while the next session was being composed: dispose the
|
|
1113
|
+
// half-ready agent and leave the current session untouched.
|
|
1114
|
+
if (quitting) {
|
|
1115
|
+
await next.handle.dispose().catch(() => {})
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
active = next
|
|
1119
|
+
agent = next.agent
|
|
1120
|
+
session = next.session
|
|
1121
|
+
store = next.store
|
|
1122
|
+
mentions = next.mentions
|
|
1123
|
+
subagents.reset()
|
|
1124
|
+
pendingMode = undefined
|
|
1125
|
+
pendingPermission = undefined
|
|
1126
|
+
commands.setAgent(agent)
|
|
1127
|
+
skills.setAgent(agent)
|
|
1128
|
+
try {
|
|
1129
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
1130
|
+
renderCurrent()
|
|
1131
|
+
} catch (error: unknown) {
|
|
1132
|
+
active = previous
|
|
1133
|
+
agent = previous?.agent
|
|
1134
|
+
session = previous?.session
|
|
1135
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
1136
|
+
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
1137
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
1138
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
1139
|
+
await next.handle.dispose()
|
|
1140
|
+
if (!quitting) renderCurrent()
|
|
1141
|
+
throw error
|
|
1142
|
+
}
|
|
1143
|
+
// From here the new session is live: in-flight prepares/commands for
|
|
1144
|
+
// the previous agent are stale and must be aborted and ignored.
|
|
1145
|
+
abortPendingControllers()
|
|
1146
|
+
epoch += 1
|
|
1147
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
1148
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
1149
|
+
if (previous === undefined) {
|
|
1150
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
let cleanupWarning: string | undefined
|
|
1154
|
+
try {
|
|
1155
|
+
await sessions.flush(previous.session)
|
|
1156
|
+
} catch (error: unknown) {
|
|
1157
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1158
|
+
}
|
|
1159
|
+
try {
|
|
1160
|
+
await previous.handle.dispose()
|
|
1161
|
+
} catch (error: unknown) {
|
|
1162
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1163
|
+
}
|
|
1164
|
+
bridge.notify(cleanupWarning === undefined
|
|
1165
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
1166
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
1167
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1168
|
+
})
|
|
786
1169
|
}
|
|
787
1170
|
|
|
788
1171
|
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
@@ -818,13 +1201,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
818
1201
|
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
819
1202
|
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
820
1203
|
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
821
|
-
|
|
822
|
-
|
|
1204
|
+
const matched = matches[0]!
|
|
1205
|
+
// Same lineage gate as the CLI --resume path and the picker.
|
|
1206
|
+
if (isSubagentSession(matched.header)) {
|
|
1207
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
823
1208
|
}
|
|
824
|
-
if (session !== undefined && agents.get(SessionId(
|
|
1209
|
+
if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
|
|
825
1210
|
throw new Error('that session is already live in another owner')
|
|
826
1211
|
}
|
|
827
|
-
return
|
|
1212
|
+
return matched.header.id
|
|
828
1213
|
}
|
|
829
1214
|
|
|
830
1215
|
const requestResume = (wanted: string): void => {
|
|
@@ -857,13 +1242,14 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
857
1242
|
}
|
|
858
1243
|
|
|
859
1244
|
const appElement = (): ReturnType<typeof createElement> => {
|
|
860
|
-
// A bare launch mounts with
|
|
861
|
-
// composes
|
|
862
|
-
//
|
|
863
|
-
// mode/sessionId, so the bar renders only the identity it actually has.
|
|
1245
|
+
// A bare launch mounts with pending/default model, mode, and permission
|
|
1246
|
+
// facts until the first input composes the real session. These choices stay
|
|
1247
|
+
// process-local and create no durable state before that composition.
|
|
864
1248
|
const sessionCwd = session?.header.cwd ?? cwd
|
|
865
|
-
const
|
|
866
|
-
|
|
1249
|
+
const currentView = store.getView()
|
|
1250
|
+
const defaults = currentDefaults()
|
|
1251
|
+
const model = currentView.model !== ''
|
|
1252
|
+
? currentView.model
|
|
867
1253
|
: pendingSelection !== undefined
|
|
868
1254
|
? `${pendingSelection.provider}/${pendingSelection.model}`
|
|
869
1255
|
: `${defaults.provider}/${defaults.model}`
|
|
@@ -872,11 +1258,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
872
1258
|
session?.requestHeader()?.config,
|
|
873
1259
|
defaults,
|
|
874
1260
|
).reasoningEffort
|
|
1261
|
+
const permission = permissionPresets === undefined
|
|
1262
|
+
? currentView.permission
|
|
1263
|
+
: effectivePermission(permissionPresets, session, pendingPermission)
|
|
875
1264
|
return createElement(App, {
|
|
876
1265
|
key: session?.id ?? 'pending',
|
|
877
1266
|
store,
|
|
878
1267
|
approval,
|
|
879
1268
|
questions,
|
|
1269
|
+
subagents,
|
|
880
1270
|
commands,
|
|
881
1271
|
skills,
|
|
882
1272
|
model,
|
|
@@ -886,22 +1276,42 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
886
1276
|
branch: gitBranch(sessionCwd),
|
|
887
1277
|
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
888
1278
|
resumed: active?.resumed ?? false,
|
|
889
|
-
mode: active?.mode ??
|
|
1279
|
+
mode: active?.mode ?? pendingMode ?? presets.defaultId,
|
|
1280
|
+
permission,
|
|
890
1281
|
dispatch,
|
|
891
1282
|
steer,
|
|
892
1283
|
interrupt,
|
|
893
1284
|
quit,
|
|
894
1285
|
loadModels: () => loadModelDirectory(ctx),
|
|
1286
|
+
loadModelProviders: () => loadProviderSettings(ctx),
|
|
1287
|
+
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
1288
|
+
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1289
|
+
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
1290
|
+
removeModelProvider: target => removeProviderSettings(ctx, target),
|
|
895
1291
|
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
896
1292
|
cyclePermission,
|
|
1293
|
+
setPermission: setPermissionAction,
|
|
897
1294
|
selectModel,
|
|
1295
|
+
subagentModel: subagentModelLabel(),
|
|
1296
|
+
setSubagentModel,
|
|
1297
|
+
clearSubagentModel,
|
|
1298
|
+
deleteSession,
|
|
898
1299
|
exportTranscript,
|
|
899
1300
|
renameTitle,
|
|
900
1301
|
loadPresets: () => presets.list(),
|
|
901
1302
|
switchMode: switchModeAction,
|
|
1303
|
+
loadPermissions: () => permissionPresets === undefined
|
|
1304
|
+
? Promise.reject(new Error('permission presets are not mounted in this composition'))
|
|
1305
|
+
: Promise.resolve(listPermissionRows(permissionPresets)),
|
|
902
1306
|
createSession,
|
|
903
1307
|
loadSessions,
|
|
904
1308
|
loadSessionTranscript,
|
|
1309
|
+
loadSubagents: () => {
|
|
1310
|
+
const current = session
|
|
1311
|
+
if (current === undefined || sessionQuery === undefined) return Promise.resolve([])
|
|
1312
|
+
return loadSessions({ sessions: 'all', cwd: 'all', sort: 'newest', currentCwd: current.header.cwd ?? cwd, query: '' })
|
|
1313
|
+
.then(rows => rows.filter(row => row.parent === current.id))
|
|
1314
|
+
},
|
|
905
1315
|
switchSession,
|
|
906
1316
|
cancelSessionSwitch,
|
|
907
1317
|
loadPlugins: () => listPluginRows(ctx),
|