dsh-code 0.7.0 → 0.8.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 +20 -6
- package/README.md +20 -6
- package/lib/index.mjs +2685 -622
- package/lib/types/app.d.ts +77 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +7 -0
- 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 +95 -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 +4 -1
- package/lib/types/session-directory.d.ts +15 -0
- package/lib/types/store.d.ts +13 -2
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +847 -150
- package/src/approval.ts +11 -2
- package/src/history.ts +20 -5
- package/src/index.ts +402 -159
- package/src/kernel-panels.ts +45 -8
- 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 +21 -6
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +665 -10
- 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 +18 -2
- package/src/session-directory.ts +44 -5
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/version.ts +16 -0
package/src/index.ts
CHANGED
|
@@ -34,21 +34,39 @@ 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
36
|
import { buildModelSelection, loadModelDirectory, 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'
|
|
40
47
|
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
-
import {
|
|
48
|
+
import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
|
|
42
49
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
43
50
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
44
51
|
import { buildExportMarkdown } from './render/export.ts'
|
|
45
52
|
import type { TuiStartup } from './startup.ts'
|
|
46
53
|
import { SessionSwitchQueue } from './session-switch.ts'
|
|
47
|
-
import { agentPresetsFrom, resolvePreset,
|
|
54
|
+
import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
|
|
55
|
+
import {
|
|
56
|
+
applyPendingPermission,
|
|
57
|
+
cyclePermission as cyclePermissionPreset,
|
|
58
|
+
effectivePermission,
|
|
59
|
+
listPermissionRows,
|
|
60
|
+
permissionPresetsFrom,
|
|
61
|
+
selectPermission,
|
|
62
|
+
} from './permissions.ts'
|
|
48
63
|
import { listPluginRows } from './plugin-inventory.ts'
|
|
49
64
|
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
50
65
|
import {
|
|
66
|
+
isSubagentSession,
|
|
67
|
+
matchSessionId,
|
|
51
68
|
mergeSessionTitles,
|
|
69
|
+
newestRootForCwd,
|
|
52
70
|
projectSessionRows,
|
|
53
71
|
type SessionDirectoryOptions,
|
|
54
72
|
type SessionQueryService,
|
|
@@ -112,6 +130,64 @@ interface Target {
|
|
|
112
130
|
cwd?: string
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
135
|
+
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
136
|
+
* user text: path separators must never leak into the default export filename
|
|
137
|
+
* (which would escape the session cwd).
|
|
138
|
+
* @param id - the session id.
|
|
139
|
+
* @returns at most the last 8 filename-safe characters.
|
|
140
|
+
*/
|
|
141
|
+
export function exportSessionIdSuffix(id: string): string {
|
|
142
|
+
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One ordered step of the terminal quit cleanup. */
|
|
146
|
+
export interface QuitCleanupStep {
|
|
147
|
+
/** Step label used in diagnostics and tests. */
|
|
148
|
+
readonly name: string
|
|
149
|
+
/** The step's async work; a rejection is contained by the sequence. */
|
|
150
|
+
readonly run: () => Promise<void>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Run the ordered quit cleanup, then request exit. Every step rejection is
|
|
155
|
+
* contained (reported through `onError`) so a failed flush or dispose never
|
|
156
|
+
* skips the remaining cleanup; the exit request is always reached exactly
|
|
157
|
+
* once.
|
|
158
|
+
* @param steps - the cleanup steps in dependency order (settle the visible
|
|
159
|
+
* session, await the final in-flight composition, await durable recall).
|
|
160
|
+
* @param exit - the terminal exit request (code 0).
|
|
161
|
+
* @param onError - optional failure sink; called once per failing step and
|
|
162
|
+
* itself contained, so a throwing sink cannot abort the sequence.
|
|
163
|
+
* @returns the names of the steps that started, in order (for tests).
|
|
164
|
+
*/
|
|
165
|
+
export async function runQuitSequence(
|
|
166
|
+
steps: readonly QuitCleanupStep[],
|
|
167
|
+
exit: (code: number) => void,
|
|
168
|
+
onError?: (name: string, error: unknown) => void,
|
|
169
|
+
): Promise<readonly string[]> {
|
|
170
|
+
const started: string[] = []
|
|
171
|
+
for (const step of steps) {
|
|
172
|
+
started.push(step.name)
|
|
173
|
+
try {
|
|
174
|
+
await step.run()
|
|
175
|
+
} catch (error) {
|
|
176
|
+
try {
|
|
177
|
+
onError?.(step.name, error)
|
|
178
|
+
} catch {
|
|
179
|
+
// The failure sink must never abort the cleanup sequence.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
exit(0)
|
|
185
|
+
} catch {
|
|
186
|
+
// The exit request itself must not become an unhandled rejection.
|
|
187
|
+
}
|
|
188
|
+
return started
|
|
189
|
+
}
|
|
190
|
+
|
|
115
191
|
/**
|
|
116
192
|
* Resolve the invocation's target session against the persisted headers.
|
|
117
193
|
* @param startup - the parsed startup flags.
|
|
@@ -120,29 +196,37 @@ interface Target {
|
|
|
120
196
|
* @returns the target identity.
|
|
121
197
|
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
122
198
|
*/
|
|
123
|
-
async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
199
|
+
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
124
200
|
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
125
|
-
if (startup.kind === 'named')
|
|
201
|
+
if (startup.kind === 'named') {
|
|
202
|
+
// The id must not exist yet: reject before any Agent composition when the
|
|
203
|
+
// backend can tell us (a live collision is still caught by the session
|
|
204
|
+
// store at create time).
|
|
205
|
+
if (persistence !== undefined) {
|
|
206
|
+
const headers: readonly SessionHeader[] = await persistence.list()
|
|
207
|
+
if (headers.some(header => header.id === startup.sessionId)) {
|
|
208
|
+
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
212
|
+
}
|
|
126
213
|
if (persistence === undefined) {
|
|
127
214
|
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
128
215
|
}
|
|
129
216
|
const headers: readonly SessionHeader[] = await persistence.list()
|
|
130
217
|
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 }
|
|
218
|
+
const matched = matchSessionId(headers, startup.sessionId)
|
|
219
|
+
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
220
|
+
// be a back door into appending root turns to a child's durable log.
|
|
221
|
+
if (isSubagentSession(matched)) {
|
|
222
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
223
|
+
}
|
|
224
|
+
return { sessionId: matched.id, resume: true }
|
|
225
|
+
}
|
|
226
|
+
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
227
|
+
const newest = newestRootForCwd(headers, cwd)
|
|
228
|
+
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
229
|
+
return { sessionId: newest.id, resume: true }
|
|
146
230
|
}
|
|
147
231
|
|
|
148
232
|
/**
|
|
@@ -193,6 +277,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
193
277
|
const defaults = defaultModel.currentSelection()
|
|
194
278
|
const presets = agentPresetsFrom(ctx)
|
|
195
279
|
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
280
|
+
const permissionPresets = permissionPresetsFrom(ctx)
|
|
196
281
|
|
|
197
282
|
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
198
283
|
// nothing is persisted, until the user's first real input. Explicit flags
|
|
@@ -219,7 +304,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
219
304
|
const selectionState: { picked?: ModelSelection } = pendingSelection === undefined
|
|
220
305
|
? {}
|
|
221
306
|
: { picked: pendingSelection }
|
|
222
|
-
let mode = next.mode
|
|
307
|
+
let mode = next.resume ? next.mode : next.mode ?? pendingMode
|
|
223
308
|
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
224
309
|
const setup = async (agentCtx: Context): Promise<void> => {
|
|
225
310
|
const sessionPreset = next.resume
|
|
@@ -240,15 +325,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
240
325
|
? await agents.resume({
|
|
241
326
|
resumeSessionId: SessionId(next.sessionId),
|
|
242
327
|
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
328
|
+
// Quit aborts an in-flight composition so the exit wait never hangs
|
|
329
|
+
// on a prepare that cannot settle; upstream rolls the creation back.
|
|
330
|
+
signal: quitAbort.signal,
|
|
243
331
|
setup,
|
|
244
332
|
})
|
|
245
333
|
: await agents.create({
|
|
246
334
|
sessionId: SessionId(next.sessionId),
|
|
247
335
|
meta: { cwd: nextCwd, agentPreset: mode },
|
|
248
336
|
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
337
|
+
signal: quitAbort.signal,
|
|
249
338
|
setup,
|
|
250
339
|
})
|
|
251
340
|
const session = handle.agent.session
|
|
341
|
+
if (!next.resume && permissionPresets !== undefined) {
|
|
342
|
+
applyPendingPermission(permissionPresets, session, pendingPermission)
|
|
343
|
+
}
|
|
252
344
|
const sessionCwd = session.header.cwd ?? nextCwd
|
|
253
345
|
return {
|
|
254
346
|
handle,
|
|
@@ -272,6 +364,44 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
272
364
|
let mentions: MentionsApi = createMentions(ctx, undefined, cwd)
|
|
273
365
|
/** Explicit model pick made before any session exists (a bare launch). */
|
|
274
366
|
let pendingSelection: ModelSelection | undefined
|
|
367
|
+
/** Agent preset selected before the first session exists. */
|
|
368
|
+
let pendingMode: string | undefined
|
|
369
|
+
/** Ordered pre-session preset resolutions; first composition awaits them. */
|
|
370
|
+
let pendingModeWork: Promise<void> = Promise.resolve()
|
|
371
|
+
/** Permission preset selected before the first session exists. */
|
|
372
|
+
let pendingPermission: string | undefined
|
|
373
|
+
/**
|
|
374
|
+
* Monotonic session epoch: bumped on every successful activation, on every
|
|
375
|
+
* first-session creation, and on quit. Async callbacks (mention prepares,
|
|
376
|
+
* command executions) capture it at call time and drop their result when it
|
|
377
|
+
* changed, so a stale callback can never deliver to an agent that is no
|
|
378
|
+
* longer on screen.
|
|
379
|
+
*/
|
|
380
|
+
let epoch = 0
|
|
381
|
+
/** Aborted on quit: an in-flight agent composition (create/resume) races this signal. */
|
|
382
|
+
const quitAbort = new AbortController()
|
|
383
|
+
/** In-flight mention-prepare / command-execute controllers, aborted on any session transition. */
|
|
384
|
+
const pendingControllers = new Set<AbortController>()
|
|
385
|
+
const abortPendingControllers = (): void => {
|
|
386
|
+
for (const controller of [...pendingControllers]) {
|
|
387
|
+
pendingControllers.delete(controller)
|
|
388
|
+
controller.abort()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** The in-flight session-composition turn (create/resume/activate), if any. */
|
|
392
|
+
let composing: Promise<void> | undefined
|
|
393
|
+
/**
|
|
394
|
+
* Run one session composition exclusively: concurrent compositions wait
|
|
395
|
+
* their turn, so a bare-launch first-session creation and a /resume
|
|
396
|
+
* activation can never compose agents in parallel (the loser would leak its
|
|
397
|
+
* agent or mis-deliver). Errors propagate to the caller; the shared slot
|
|
398
|
+
* always continues.
|
|
399
|
+
*/
|
|
400
|
+
const compose = (work: () => Promise<void>): Promise<void> => {
|
|
401
|
+
const turn = (composing ?? Promise.resolve()).catch(() => {}).then(work)
|
|
402
|
+
composing = turn.catch(() => {})
|
|
403
|
+
return turn
|
|
404
|
+
}
|
|
275
405
|
|
|
276
406
|
if (!lazy) {
|
|
277
407
|
const target = await resolveTarget(startup, persistence, cwd)
|
|
@@ -378,18 +508,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
378
508
|
} catch {
|
|
379
509
|
inputHistory = []
|
|
380
510
|
}
|
|
511
|
+
/** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
|
|
512
|
+
let historyWriteChain: Promise<void> = Promise.resolve()
|
|
381
513
|
const recordHistory = (text: string): void => {
|
|
382
514
|
if (text === '') return
|
|
383
515
|
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'))
|
|
516
|
+
// Write the whole current list, serialized per submission: the file is
|
|
517
|
+
// never read back on the submit path, so rapid same-process submissions
|
|
518
|
+
// cannot lose entries to a read-modify-write race.
|
|
519
|
+
historyWriteChain = historyWriteChain
|
|
520
|
+
.then(() => mkdir(dirname(historyPath), { recursive: true }))
|
|
521
|
+
.then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
|
|
393
522
|
.catch((writeError: unknown) => {
|
|
394
523
|
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
395
524
|
})
|
|
@@ -415,37 +544,43 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
415
544
|
if (quitting) return
|
|
416
545
|
quitting = true
|
|
417
546
|
switchQueue.cancel()
|
|
547
|
+
// Stale prepares/commands die with the session they were for. Aborting
|
|
548
|
+
// the composition signal lets a never-settling prepare reject, so the
|
|
549
|
+
// exit wait below cannot hang (upstream rolls the creation back).
|
|
550
|
+
abortPendingControllers()
|
|
551
|
+
quitAbort.abort()
|
|
552
|
+
epoch += 1
|
|
418
553
|
off()
|
|
419
554
|
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
555
|
const currentSession = session
|
|
423
556
|
const currentActive = active
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
return
|
|
557
|
+
const report = (name: string, error: unknown): void => {
|
|
558
|
+
internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
427
559
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
560
|
+
// One ordered cleanup: settle the visible session (if any — a bare launch
|
|
561
|
+
// that never composed one resolves immediately), then wait for the final
|
|
562
|
+
// in-flight composition (its work swallows errors and the quitting guard
|
|
563
|
+
// disposes any half-prepared agent), then flush the durable recall, then
|
|
564
|
+
// request exit. `composing` and `historyWriteChain` are read at step run
|
|
565
|
+
// time, so a turn that was still being queued when quit ran is included.
|
|
566
|
+
// A failing step must never skip the remaining cleanup.
|
|
567
|
+
const steps: QuitCleanupStep[] = [
|
|
568
|
+
...(currentSession === undefined || currentActive === undefined
|
|
569
|
+
? []
|
|
570
|
+
: [
|
|
571
|
+
{ name: 'flush', run: async () => { await sessions.flush(currentSession) } },
|
|
572
|
+
{ name: 'dispose', run: () => currentActive.handle.dispose() },
|
|
573
|
+
]),
|
|
574
|
+
{ name: 'composing', run: () => composing ?? Promise.resolve() },
|
|
575
|
+
{ name: 'history', run: () => historyWriteChain },
|
|
576
|
+
]
|
|
577
|
+
void runQuitSequence(steps, io.exit, report)
|
|
439
578
|
}
|
|
440
579
|
|
|
441
580
|
/** Run one slash line through the command registry (closed namespace). */
|
|
442
581
|
const runSlash = (line: string): void => {
|
|
443
582
|
const currentAgent = agent
|
|
444
583
|
if (currentAgent === undefined) return
|
|
445
|
-
if (line.startsWith('/mode ')) {
|
|
446
|
-
void switchModeAction(line.slice(6).trim())
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
584
|
if (line.startsWith('/resume ')) {
|
|
450
585
|
requestResume(line.slice(8).trim())
|
|
451
586
|
return
|
|
@@ -456,7 +591,16 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
456
591
|
return
|
|
457
592
|
}
|
|
458
593
|
const controller = new AbortController()
|
|
594
|
+
const atEpoch = epoch
|
|
595
|
+
pendingControllers.add(controller)
|
|
596
|
+
const finish = (): void => {
|
|
597
|
+
pendingControllers.delete(controller)
|
|
598
|
+
}
|
|
459
599
|
void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
|
|
600
|
+
finish()
|
|
601
|
+
// A switch/quit landed while the command ran: its fall-through must not
|
|
602
|
+
// reach an agent that is no longer on screen.
|
|
603
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
460
604
|
if (execution === undefined) {
|
|
461
605
|
// No command owns this line: send it verbatim so a user-invocable
|
|
462
606
|
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
@@ -471,6 +615,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
471
615
|
}
|
|
472
616
|
}
|
|
473
617
|
}, (error: unknown) => {
|
|
618
|
+
finish()
|
|
619
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
474
620
|
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
475
621
|
})
|
|
476
622
|
}
|
|
@@ -493,7 +639,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
493
639
|
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
494
640
|
return
|
|
495
641
|
}
|
|
642
|
+
const atEpoch = epoch
|
|
496
643
|
const deliver = (readable: string, context?: UserMessage): void => {
|
|
644
|
+
// A switch/quit landed while the snapshot was being prepared: never
|
|
645
|
+
// deliver to an agent that is no longer on screen.
|
|
646
|
+
if (epoch !== atEpoch || agent !== currentAgent) return
|
|
497
647
|
// Session snapshots ride the inbox as model-facing context ahead of
|
|
498
648
|
// the readable message (upstream README wiring: inject before the
|
|
499
649
|
// followup/steer that wakes the driver).
|
|
@@ -519,10 +669,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
519
669
|
return
|
|
520
670
|
}
|
|
521
671
|
const controller = new AbortController()
|
|
672
|
+
pendingControllers.add(controller)
|
|
522
673
|
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
674
|
+
pendingControllers.delete(controller)
|
|
523
675
|
deliver(prepared.text, prepared.additionalContext)
|
|
524
676
|
}, (error: unknown) => {
|
|
525
|
-
|
|
677
|
+
pendingControllers.delete(controller)
|
|
678
|
+
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
526
679
|
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
527
680
|
})
|
|
528
681
|
}
|
|
@@ -533,47 +686,83 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
533
686
|
// failure reports and clears the queue, leaving the transient state ready
|
|
534
687
|
// for the next attempt.
|
|
535
688
|
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
|
|
536
|
-
|
|
689
|
+
// A creation is queued/running: further submissions must not mint more
|
|
690
|
+
// fresh sessions (their lines queue into pendingInputs instead).
|
|
691
|
+
let creating = false
|
|
537
692
|
const ensureSession = (mode?: string): void => {
|
|
538
|
-
if (creating
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
693
|
+
if (creating) return
|
|
694
|
+
creating = true
|
|
695
|
+
void compose(async () => {
|
|
696
|
+
try {
|
|
697
|
+
// A direct `/mode <preset>` resolves asynchronously. Preserve submit
|
|
698
|
+
// order so the first composition cannot race ahead with the old mode.
|
|
699
|
+
await pendingModeWork
|
|
700
|
+
// Another composition (e.g. a /resume activated while this creation
|
|
701
|
+
// waited its turn) may have published a session already: deliver the
|
|
702
|
+
// queued lines there instead of minting a competing fresh session
|
|
703
|
+
// (which would orphan the live one without a dispose).
|
|
704
|
+
if (session !== undefined) {
|
|
705
|
+
const queued = pendingInputs.splice(0)
|
|
706
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
const next = await prepare({
|
|
710
|
+
sessionId: `session-${randomUUID()}`,
|
|
711
|
+
resume: false,
|
|
712
|
+
...(mode === undefined ? {} : { mode }),
|
|
713
|
+
})
|
|
714
|
+
if (quitting) {
|
|
715
|
+
void next.handle.dispose().catch(() => {})
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
active = next
|
|
719
|
+
agent = next.agent
|
|
720
|
+
session = next.session
|
|
721
|
+
store = next.store
|
|
722
|
+
mentions = next.mentions
|
|
723
|
+
pendingMode = undefined
|
|
724
|
+
pendingPermission = undefined
|
|
725
|
+
commands.setAgent(agent)
|
|
726
|
+
skills.setAgent(agent)
|
|
727
|
+
// The App mounts with a placeholder key until the first input; the
|
|
728
|
+
// key-change remount below must start from a clean screen or the ghost
|
|
729
|
+
// static header stays visible above the new one (same source-backed
|
|
730
|
+
// clear the session-switch path performs).
|
|
731
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
732
|
+
renderCurrent()
|
|
733
|
+
abortPendingControllers()
|
|
734
|
+
epoch += 1
|
|
735
|
+
const queued = pendingInputs.splice(0)
|
|
736
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
737
|
+
} finally {
|
|
738
|
+
creating = false
|
|
548
739
|
}
|
|
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) => {
|
|
740
|
+
}).catch((error: unknown) => {
|
|
565
741
|
pendingInputs.length = 0
|
|
566
742
|
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
567
|
-
}).finally(() => {
|
|
568
|
-
creating = undefined
|
|
569
743
|
})
|
|
570
|
-
creating = attempt
|
|
571
744
|
}
|
|
572
745
|
|
|
573
746
|
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
574
747
|
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
575
748
|
const line = text.trim()
|
|
576
749
|
if (line === '') return
|
|
750
|
+
if (line.startsWith('/mode ')) {
|
|
751
|
+
void switchModeAction(line.slice(6).trim()).then(
|
|
752
|
+
selected => bridge.notify(`mode → ${selected}`),
|
|
753
|
+
error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
754
|
+
)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
if (line.startsWith('/permission ')) {
|
|
758
|
+
try {
|
|
759
|
+
const selected = setPermissionAction(line.slice(12).trim())
|
|
760
|
+
bridge.notify(`permission → ${selected}`)
|
|
761
|
+
} catch (error: unknown) {
|
|
762
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
763
|
+
}
|
|
764
|
+
return
|
|
765
|
+
}
|
|
577
766
|
if (session === undefined) {
|
|
578
767
|
pendingInputs.push({ text: line, mode })
|
|
579
768
|
ensureSession()
|
|
@@ -609,29 +798,36 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
609
798
|
}
|
|
610
799
|
}
|
|
611
800
|
|
|
801
|
+
/** Select one permission preset before the first session or on the active one. */
|
|
802
|
+
const setPermissionAction = (id: string): string => {
|
|
803
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
804
|
+
throw new Error('permission presets are not mounted in this composition')
|
|
805
|
+
}
|
|
806
|
+
if (id === '') throw new Error('usage: /permission <preset>')
|
|
807
|
+
const selected = selectPermission(permissionPresets, session, id)
|
|
808
|
+
if (session === undefined) {
|
|
809
|
+
pendingPermission = selected
|
|
810
|
+
renderCurrent()
|
|
811
|
+
}
|
|
812
|
+
return selected
|
|
813
|
+
}
|
|
814
|
+
|
|
612
815
|
/**
|
|
613
|
-
* Cycle to the next permission preset (Shift+Tab
|
|
614
|
-
*
|
|
615
|
-
*
|
|
816
|
+
* Cycle to the next permission preset (Shift+Tab). Before the first session,
|
|
817
|
+
* the choice remains process-local and is materialized when Harness creates
|
|
818
|
+
* that session; afterwards the canonical service writes durable events.
|
|
616
819
|
*/
|
|
617
820
|
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) {
|
|
821
|
+
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
627
822
|
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
628
823
|
return ''
|
|
629
824
|
}
|
|
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
825
|
try {
|
|
634
|
-
|
|
826
|
+
const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
|
|
827
|
+
if (session === undefined && next !== '') {
|
|
828
|
+
pendingPermission = next
|
|
829
|
+
renderCurrent()
|
|
830
|
+
}
|
|
635
831
|
return next
|
|
636
832
|
} catch (error: unknown) {
|
|
637
833
|
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
@@ -669,7 +865,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
669
865
|
}
|
|
670
866
|
const wanted = argument.trim()
|
|
671
867
|
const sessionCwd = session.header.cwd ?? cwd
|
|
672
|
-
|
|
868
|
+
// The default name derives from the session id, which `--session` lets the
|
|
869
|
+
// user spell freely: reduce it to filename-safe characters first so the
|
|
870
|
+
// default target can never escape the session cwd.
|
|
871
|
+
const defaultName = `dsh-session-${exportSessionIdSuffix(session.id)}.md`
|
|
673
872
|
const target = wanted === ''
|
|
674
873
|
? join(sessionCwd, defaultName)
|
|
675
874
|
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
@@ -723,12 +922,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
723
922
|
const switchModeAction = async (id: string): Promise<string> => {
|
|
724
923
|
if (id === '') throw new Error('usage: /mode <preset>')
|
|
725
924
|
const currentAgent = agent
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
925
|
+
if (currentAgent === undefined) {
|
|
926
|
+
const choice = pendingModeWork.then(async () => {
|
|
927
|
+
const preset = await selectPreset(presets, undefined, id)
|
|
928
|
+
// A resume may have won while this roster read was in flight; never
|
|
929
|
+
// leak the old pending choice into a later /new session.
|
|
930
|
+
if (agent === undefined) {
|
|
931
|
+
pendingMode = preset.id
|
|
932
|
+
renderCurrent()
|
|
933
|
+
}
|
|
934
|
+
return preset.id
|
|
935
|
+
})
|
|
936
|
+
pendingModeWork = choice.then(() => {}, () => {})
|
|
937
|
+
return choice
|
|
729
938
|
}
|
|
730
|
-
|
|
731
|
-
|
|
939
|
+
|
|
940
|
+
const preset = await selectPreset(presets, currentAgent, id)
|
|
941
|
+
if (active === undefined) throw new Error('active Agent has no session state')
|
|
942
|
+
active.mode = preset.id
|
|
732
943
|
commands.setAgent(currentAgent)
|
|
733
944
|
skills.setAgent(currentAgent)
|
|
734
945
|
renderCurrent()
|
|
@@ -737,52 +948,69 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
737
948
|
|
|
738
949
|
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
739
950
|
|
|
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
|
-
|
|
951
|
+
const activate = (nextTarget: Target): Promise<void> => {
|
|
952
|
+
if (quitting) return Promise.resolve()
|
|
953
|
+
// Serialized with every other composition (bare-launch creation, queued
|
|
954
|
+
// switches): at most one agent is composed at a time.
|
|
955
|
+
return compose(async () => {
|
|
956
|
+
const previous = active
|
|
957
|
+
const next = await prepare(nextTarget)
|
|
958
|
+
// Quit landed while the next session was being composed: dispose the
|
|
959
|
+
// half-ready agent and leave the current session untouched.
|
|
960
|
+
if (quitting) {
|
|
961
|
+
await next.handle.dispose().catch(() => {})
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
active = next
|
|
965
|
+
agent = next.agent
|
|
966
|
+
session = next.session
|
|
967
|
+
store = next.store
|
|
968
|
+
mentions = next.mentions
|
|
969
|
+
pendingMode = undefined
|
|
970
|
+
pendingPermission = undefined
|
|
971
|
+
commands.setAgent(agent)
|
|
972
|
+
skills.setAgent(agent)
|
|
973
|
+
try {
|
|
974
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
975
|
+
renderCurrent()
|
|
976
|
+
} catch (error: unknown) {
|
|
977
|
+
active = previous
|
|
978
|
+
agent = previous?.agent
|
|
979
|
+
session = previous?.session
|
|
980
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
981
|
+
mentions = previous === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
|
|
982
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
983
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
984
|
+
await next.handle.dispose()
|
|
985
|
+
if (!quitting) renderCurrent()
|
|
986
|
+
throw error
|
|
987
|
+
}
|
|
988
|
+
// From here the new session is live: in-flight prepares/commands for
|
|
989
|
+
// the previous agent are stale and must be aborted and ignored.
|
|
990
|
+
abortPendingControllers()
|
|
991
|
+
epoch += 1
|
|
992
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
993
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
994
|
+
if (previous === undefined) {
|
|
995
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
let cleanupWarning: string | undefined
|
|
999
|
+
try {
|
|
1000
|
+
await sessions.flush(previous.session)
|
|
1001
|
+
} catch (error: unknown) {
|
|
1002
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await previous.handle.dispose()
|
|
1006
|
+
} catch (error: unknown) {
|
|
1007
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1008
|
+
}
|
|
1009
|
+
bridge.notify(cleanupWarning === undefined
|
|
1010
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
1011
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
1012
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1013
|
+
})
|
|
786
1014
|
}
|
|
787
1015
|
|
|
788
1016
|
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
@@ -818,13 +1046,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
818
1046
|
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
819
1047
|
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
820
1048
|
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
821
|
-
|
|
822
|
-
|
|
1049
|
+
const matched = matches[0]!
|
|
1050
|
+
// Same lineage gate as the CLI --resume path and the picker.
|
|
1051
|
+
if (isSubagentSession(matched.header)) {
|
|
1052
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
823
1053
|
}
|
|
824
|
-
if (session !== undefined && agents.get(SessionId(
|
|
1054
|
+
if (session !== undefined && agents.get(SessionId(matched.header.id)) !== undefined && matched.header.id !== session.id) {
|
|
825
1055
|
throw new Error('that session is already live in another owner')
|
|
826
1056
|
}
|
|
827
|
-
return
|
|
1057
|
+
return matched.header.id
|
|
828
1058
|
}
|
|
829
1059
|
|
|
830
1060
|
const requestResume = (wanted: string): void => {
|
|
@@ -857,13 +1087,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
857
1087
|
}
|
|
858
1088
|
|
|
859
1089
|
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.
|
|
1090
|
+
// A bare launch mounts with pending/default model, mode, and permission
|
|
1091
|
+
// facts until the first input composes the real session. These choices stay
|
|
1092
|
+
// process-local and create no durable state before that composition.
|
|
864
1093
|
const sessionCwd = session?.header.cwd ?? cwd
|
|
865
|
-
const
|
|
866
|
-
|
|
1094
|
+
const currentView = store.getView()
|
|
1095
|
+
const model = currentView.model !== ''
|
|
1096
|
+
? currentView.model
|
|
867
1097
|
: pendingSelection !== undefined
|
|
868
1098
|
? `${pendingSelection.provider}/${pendingSelection.model}`
|
|
869
1099
|
: `${defaults.provider}/${defaults.model}`
|
|
@@ -872,6 +1102,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
872
1102
|
session?.requestHeader()?.config,
|
|
873
1103
|
defaults,
|
|
874
1104
|
).reasoningEffort
|
|
1105
|
+
const permission = permissionPresets === undefined
|
|
1106
|
+
? currentView.permission
|
|
1107
|
+
: effectivePermission(permissionPresets, session, pendingPermission)
|
|
875
1108
|
return createElement(App, {
|
|
876
1109
|
key: session?.id ?? 'pending',
|
|
877
1110
|
store,
|
|
@@ -886,19 +1119,29 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
886
1119
|
branch: gitBranch(sessionCwd),
|
|
887
1120
|
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
888
1121
|
resumed: active?.resumed ?? false,
|
|
889
|
-
mode: active?.mode ??
|
|
1122
|
+
mode: active?.mode ?? pendingMode ?? presets.defaultId,
|
|
1123
|
+
permission,
|
|
890
1124
|
dispatch,
|
|
891
1125
|
steer,
|
|
892
1126
|
interrupt,
|
|
893
1127
|
quit,
|
|
894
1128
|
loadModels: () => loadModelDirectory(ctx),
|
|
1129
|
+
loadModelProviders: () => loadProviderSettings(ctx),
|
|
1130
|
+
subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
|
|
1131
|
+
saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
|
|
1132
|
+
unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
|
|
1133
|
+
removeModelProvider: target => removeProviderSettings(ctx, target),
|
|
895
1134
|
loadMentions: (query: string, signal?: AbortSignal) => mentions.candidates(query, signal),
|
|
896
1135
|
cyclePermission,
|
|
1136
|
+
setPermission: setPermissionAction,
|
|
897
1137
|
selectModel,
|
|
898
1138
|
exportTranscript,
|
|
899
1139
|
renameTitle,
|
|
900
1140
|
loadPresets: () => presets.list(),
|
|
901
1141
|
switchMode: switchModeAction,
|
|
1142
|
+
loadPermissions: () => permissionPresets === undefined
|
|
1143
|
+
? Promise.reject(new Error('permission presets are not mounted in this composition'))
|
|
1144
|
+
: Promise.resolve(listPermissionRows(permissionPresets)),
|
|
902
1145
|
createSession,
|
|
903
1146
|
loadSessions,
|
|
904
1147
|
loadSessionTranscript,
|