dsh-code 0.8.0 → 0.9.1

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/src/approval.ts CHANGED
@@ -1,135 +1,161 @@
1
- /**
2
- * The terminal approval answerer: one `approval/request` waterfall listener
3
- * that renders the pending question as a y/n bar and resolves the decision
4
- * back into the waterfall. Mirrors the web host's composer takeover — the
5
- * service (audit pair, policy gate, fail-closed defaults) all live in
6
- * dsh-base; this module only answers for agents this TUI owns.
7
- *
8
- * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
- * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
- * `'unavailable'` to the fail-closed waterfall default.
11
- *
12
- * @module @deepseek-ai/dsh-code/approval
13
- */
14
-
15
- import type { Context } from '@deepseek-ai/cordis'
16
- import type { Agent } from '@deepseek-ai/dsh-agent'
17
- import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
-
19
- /** The answer values a client answerer may resolve with. */
20
- export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
-
22
- /** One pending approval question, derived from the request for rendering. */
23
- export interface PendingApproval {
24
- /** The asker's human-readable explanation, or a generic fallback. */
25
- headline: string
26
- /** The tool the question is about. */
27
- toolName: string
28
- /** Command-line preview resolved from the paired streaming tool call. */
29
- command: string
30
- /** Resolve the ask; calling twice is inert (one-shot latch). */
31
- answer(outcome: ApprovalAnswer): void
32
- }
33
-
34
- /** The pending-question snapshot the renderer subscribes to. */
35
- export interface ApprovalSnapshot {
36
- /** The pending question, or undefined when none is being asked. */
37
- pending: PendingApproval | undefined
38
- /** Presentational: an answer was submitted, the ask has not settled yet. */
39
- answered: boolean
40
- }
41
-
42
- /** Store the pending question lands in; the renderer reads, the answerer writes. */
43
- export interface ApprovalStore {
44
- /** Subscribe to pending-state changes; returns the unsubscribe function. */
45
- subscribe(listener: () => void): () => void
46
- /** Read the current snapshot (identity-stable between changes). */
47
- getSnapshot(): ApprovalSnapshot
48
- }
49
-
50
- /**
51
- * Create the approval store and mount the answerer listener on the context.
52
- * The listener claims only requests for `owns`-owned agents and defers every
53
- * other request back into the waterfall (`next()`), so sibling answerers stay
54
- * usable. An aborted ask never reaches the human. Plugin teardown removes the
55
- * listener; the service then fails its own question closed.
56
- * @param ctx - plugin context whose event bus carries `approval/request`.
57
- * @param owns - agents this terminal answers for.
58
- * @param preview - resolves a tool-call preview for a pending request (the
59
- * request contract carries no arguments; the UI self-serves from the
60
- * transcript projection via `callId`).
61
- * @returns the store the renderer subscribes to.
62
- */
63
- export function mountApprovalAnswerer(
64
- ctx: Context,
65
- owns: (agent: Agent) => boolean,
66
- preview: (request: ApprovalRequest) => string,
67
- ): ApprovalStore {
68
- let snapshot: ApprovalSnapshot = { pending: undefined, answered: false }
69
- const listeners = new Set<() => void>()
70
- const set = (next: ApprovalSnapshot): void => {
71
- snapshot = next
72
- for (const listener of listeners) listener()
73
- }
74
-
75
- ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
76
- if (!owns(request.agent)) return next()
77
- // An already-aborted ask never reaches the human (mirrors the host bridge).
78
- if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
79
-
80
- let resolved = false
81
- let settle!: (outcome: ApprovalOutcome) => void
82
- const signal = request.signal
83
- const onAbort = (): void => withdraw()
84
- // Detach on every settle so an answered ask never retains a listener on
85
- // the tool call's signal (long turns ask many times; each ask must let go).
86
- const detachAbort = (): void => {
87
- if (signal !== undefined) signal.removeEventListener('abort', onAbort)
88
- }
89
- const withdraw = (): void => {
90
- if (resolved) return
91
- resolved = true
92
- detachAbort()
93
- set({ pending: undefined, answered: false })
94
- // The service's signal race would conclude 'cancelled' anyway; settle
95
- // the same way so this listener never dangles a pending promise.
96
- settle('cancelled')
97
- }
98
- if (signal !== undefined) {
99
- signal.addEventListener('abort', onAbort, { once: true })
100
- }
101
- const pending: PendingApproval = {
102
- headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
103
- toolName: request.toolName,
104
- command: preview(request),
105
- answer: (outcome: ApprovalAnswer): void => {
106
- // One-shot latch: a second keypress after submission is inert.
107
- if (resolved) return
108
- resolved = true
109
- detachAbort()
110
- set({ pending, answered: true })
111
- settle(outcome)
112
- },
113
- }
114
- set({ pending, answered: false })
115
-
116
- return new Promise<ApprovalOutcome>((resolve) => {
117
- settle = resolve
118
- }).then((outcome) => {
119
- if (outcome !== 'cancelled') set({ pending: undefined, answered: false })
120
- return outcome
121
- })
122
- })
123
-
124
- return {
125
- subscribe(listener: () => void): () => void {
126
- listeners.add(listener)
127
- return () => {
128
- listeners.delete(listener)
129
- }
130
- },
131
- getSnapshot(): ApprovalSnapshot {
132
- return snapshot
133
- },
134
- }
135
- }
1
+ /**
2
+ * The terminal approval answerer: one `approval/request` waterfall listener
3
+ * that renders the pending question as a y/n bar and resolves the decision
4
+ * back into the waterfall. Mirrors the web host's composer takeover — the
5
+ * service (audit pair, policy gate, fail-closed defaults) all live in
6
+ * dsh-base; this module only answers for agents this TUI owns.
7
+ *
8
+ * Vocabulary note: a client answerer may only ever resolve `'allowed-once'`
9
+ * or `'rejected'`; `'cancelled'` belongs to the request signal and
10
+ * `'unavailable'` to the fail-closed waterfall default.
11
+ *
12
+ * @module @deepseek-ai/dsh-code/approval
13
+ */
14
+
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type { Agent } from '@deepseek-ai/dsh-agent'
17
+ import type { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
18
+
19
+ /** The answer values a client answerer may resolve with. */
20
+ export type ApprovalAnswer = 'allowed-once' | 'rejected'
21
+
22
+ /** One pending approval question, derived from the request for rendering. */
23
+ export interface PendingApproval {
24
+ /** The asker's human-readable explanation, or a generic fallback. */
25
+ headline: string
26
+ /** The tool the question is about. */
27
+ toolName: string
28
+ /** Command-line preview resolved from the paired streaming tool call. */
29
+ command: string
30
+ /** Resolve the ask; calling twice is inert (one-shot latch). */
31
+ answer(outcome: ApprovalAnswer): void
32
+ }
33
+
34
+ /** The pending-question snapshot the renderer subscribes to. */
35
+ export interface ApprovalSnapshot {
36
+ /** The question on screen (queue head), or undefined when none is asked. */
37
+ pending: PendingApproval | undefined
38
+ /** Presentational: an answer was submitted, the ask has not settled yet. */
39
+ answered: boolean
40
+ /** Further asks waiting behind the on-screen one (FIFO, Codex-style). */
41
+ queued: number
42
+ }
43
+
44
+ /** Store the pending question lands in; the renderer reads, the answerer writes. */
45
+ export interface ApprovalStore {
46
+ /** Subscribe to pending-state changes; returns the unsubscribe function. */
47
+ subscribe(listener: () => void): () => void
48
+ /** Read the current snapshot (identity-stable between changes). */
49
+ getSnapshot(): ApprovalSnapshot
50
+ }
51
+
52
+ /**
53
+ * Create the approval store and mount the answerer listener on the context.
54
+ * The listener claims only requests for `owns`-owned agents and defers every
55
+ * other request back into the waterfall (`next()`), so sibling answerers stay
56
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
57
+ * listener; the service then fails its own question closed.
58
+ * @param ctx - plugin context whose event bus carries `approval/request`.
59
+ * @param owns - agents this terminal answers for.
60
+ * @param preview - resolves a tool-call preview for a pending request (the
61
+ * request contract carries no arguments; the UI self-serves from the
62
+ * transcript projection via `callId`).
63
+ * @returns the store the renderer subscribes to.
64
+ */
65
+ export function mountApprovalAnswerer(
66
+ ctx: Context,
67
+ owns: (agent: Agent) => boolean,
68
+ preview: (request: ApprovalRequest) => string,
69
+ ): ApprovalStore {
70
+ /** One live ask: its pending view plus the one-shot settle plumbing. */
71
+ interface Slot {
72
+ readonly pending: PendingApproval
73
+ answered: boolean
74
+ }
75
+ const queue: Slot[] = []
76
+ let snapshot: ApprovalSnapshot = { pending: undefined, answered: false, queued: 0 }
77
+ const listeners = new Set<() => void>()
78
+ const publish = (): void => {
79
+ const head = queue[0]
80
+ snapshot = {
81
+ pending: head === undefined ? undefined : head.pending,
82
+ answered: head !== undefined && head.answered,
83
+ queued: Math.max(0, queue.length - 1),
84
+ }
85
+ for (const listener of listeners) listener()
86
+ }
87
+ const removeSlot = (slot: Slot): void => {
88
+ const at = queue.indexOf(slot)
89
+ if (at !== -1) queue.splice(at, 1)
90
+ }
91
+
92
+ ctx.on('approval/request', (request: ApprovalRequest, next: () => Promise<ApprovalOutcome>) => {
93
+ if (!owns(request.agent)) return next()
94
+ // An already-aborted ask never reaches the human (mirrors the host bridge).
95
+ if (request.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
96
+
97
+ let resolved = false
98
+ let settle!: (outcome: ApprovalOutcome) => void
99
+ const signal = request.signal
100
+ const onAbort = (): void => withdraw()
101
+ // Detach on every settle so an answered ask never retains a listener on
102
+ // the tool call's signal (long turns ask many times; each ask must let go).
103
+ const detachAbort = (): void => {
104
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
105
+ }
106
+ const withdraw = (): void => {
107
+ if (resolved) return
108
+ resolved = true
109
+ detachAbort()
110
+ removeSlot(slot)
111
+ publish()
112
+ // The service's signal race would conclude 'cancelled' anyway; settle
113
+ // the same way so this listener never dangles a pending promise.
114
+ settle('cancelled')
115
+ }
116
+ if (signal !== undefined) {
117
+ signal.addEventListener('abort', onAbort, { once: true })
118
+ }
119
+ const slot: Slot = {
120
+ answered: false,
121
+ pending: {
122
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
123
+ toolName: request.toolName,
124
+ command: preview(request),
125
+ answer: (outcome: ApprovalAnswer): void => {
126
+ // One-shot latch: a second keypress after submission is inert.
127
+ if (resolved) return
128
+ resolved = true
129
+ detachAbort()
130
+ slot.answered = true
131
+ publish()
132
+ settle(outcome)
133
+ },
134
+ },
135
+ }
136
+ queue.push(slot)
137
+ publish()
138
+
139
+ return new Promise<ApprovalOutcome>((resolve) => {
140
+ settle = resolve
141
+ }).then((outcome) => {
142
+ if (outcome !== 'cancelled') {
143
+ removeSlot(slot)
144
+ publish()
145
+ }
146
+ return outcome
147
+ })
148
+ })
149
+
150
+ return {
151
+ subscribe(listener: () => void): () => void {
152
+ listeners.add(listener)
153
+ return () => {
154
+ listeners.delete(listener)
155
+ }
156
+ },
157
+ getSnapshot(): ApprovalSnapshot {
158
+ return snapshot
159
+ },
160
+ }
161
+ }
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,7 +33,7 @@ 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
37
  import {
38
38
  loadProviderSettings,
39
39
  removeProviderSettings,
@@ -44,6 +44,7 @@ import {
44
44
  import { createMentions, type MentionsApi } from './mentions.ts'
45
45
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
46
46
  import { createTranscriptStore, type TranscriptStore } from './store.ts'
47
+ import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
47
48
  import { parseStatuslineItems } from './render/status.ts'
48
49
  import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
49
50
  import { watchSkills, type SkillsView } from './skills.ts'
@@ -63,11 +64,14 @@ import {
63
64
  import { listPluginRows } from './plugin-inventory.ts'
64
65
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
65
66
  import {
67
+ collectDeletionSubtree,
66
68
  isSubagentSession,
67
69
  matchSessionId,
68
70
  mergeSessionTitles,
69
71
  newestRootForCwd,
70
72
  projectSessionRows,
73
+ SESSION_ARTIFACT_NAMES,
74
+ sessionArtifactDirectory,
71
75
  type SessionDirectoryOptions,
72
76
  type SessionQueryService,
73
77
  type SessionRow,
@@ -274,7 +278,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
274
278
  if (agents === undefined || defaultModel === undefined || sessions === undefined) return
275
279
 
276
280
  const cwd = process.cwd()
277
- const defaults = defaultModel.currentSelection()
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()
278
285
  const presets = agentPresetsFrom(ctx)
279
286
  if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
280
287
  const permissionPresets = permissionPresetsFrom(ctx)
@@ -314,17 +321,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
314
321
  mode = mounted.id
315
322
  const selection: ModelSelectionRef = {
316
323
  get current(): ModelSelection | undefined {
317
- return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, defaults)
324
+ return resolveEffectiveSelection(selectionState.picked, agentCtx.agent?.session.requestHeader()?.config, currentDefaults())
318
325
  },
319
326
  set current(value: ModelSelection | undefined) { selectionState.picked = value },
320
327
  assembled: undefined,
321
328
  }
322
329
  installModelSelection(agentCtx, selection)
323
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 }
324
336
  const handle = next.resume
325
337
  ? await agents.resume({
326
338
  resumeSessionId: SessionId(next.sessionId),
327
- agentOptions: { provider: defaults.provider, model: defaults.model },
339
+ agentOptions: seedOptions,
328
340
  // Quit aborts an in-flight composition so the exit wait never hangs
329
341
  // on a prepare that cannot settle; upstream rolls the creation back.
330
342
  signal: quitAbort.signal,
@@ -333,7 +345,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
333
345
  : await agents.create({
334
346
  sessionId: SessionId(next.sessionId),
335
347
  meta: { cwd: nextCwd, agentPreset: mode },
336
- agentOptions: { provider: defaults.provider, model: defaults.model },
348
+ agentOptions: seedOptions,
337
349
  signal: quitAbort.signal,
338
350
  setup,
339
351
  })
@@ -358,6 +370,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
358
370
  let agent: Agent | undefined
359
371
  let session: Session | undefined
360
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()
361
376
  // File-only mentions from the start: `@` completion works on a bare launch
362
377
  // (no session yet); the prepare/activate paths replace this with the full
363
378
  // agent-scoped instance that also resolves session references.
@@ -418,7 +433,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
418
433
  // before the first render. The handler reads the current session/store, so
419
434
  // the deferred first session of a bare launch is covered by the same feed.
420
435
  const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
421
- if (session !== undefined && subject.id === session.id) store.apply(event)
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)
422
447
  })
423
448
 
424
449
  const commands: CommandsView = watchCommands(ctx)
@@ -436,6 +461,28 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
436
461
  request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
437
462
  )
438
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
+
439
486
  // ask_user_question provider: the single UI provider on the shared service,
440
487
  // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
441
488
  // through this same pipe.
@@ -720,6 +767,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
720
767
  session = next.session
721
768
  store = next.store
722
769
  mentions = next.mentions
770
+ subagents.reset()
723
771
  pendingMode = undefined
724
772
  pendingPermission = undefined
725
773
  commands.setAgent(agent)
@@ -850,9 +898,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
850
898
  } else {
851
899
  active.selection.picked = selection
852
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
+ }
853
929
  return `${row.provider}/${row.model}`
854
930
  }
855
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
+
856
948
  /**
857
949
  * Export the folded transcript to a markdown file (/export). The default
858
950
  * target sits beside the session's cwd so the file lands in the user's
@@ -904,7 +996,22 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
904
996
 
905
997
  const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
906
998
  if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
907
- const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
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)
908
1015
  // Titles are the expensive fold. Fetch only the first bounded picker page;
909
1016
  // navigation/filter changes trigger a fresh, cancellable observation.
910
1017
  const page = projected.slice(0, 32)
@@ -913,6 +1020,53 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
913
1020
  return mergeSessionTitles(projected, observations)
914
1021
  }
915
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
+
916
1070
  const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
917
1071
  if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
918
1072
  const snapshot = await sessionQuery.readSession(id, signal)
@@ -966,6 +1120,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
966
1120
  session = next.session
967
1121
  store = next.store
968
1122
  mentions = next.mentions
1123
+ subagents.reset()
969
1124
  pendingMode = undefined
970
1125
  pendingPermission = undefined
971
1126
  commands.setAgent(agent)
@@ -1092,6 +1247,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1092
1247
  // process-local and create no durable state before that composition.
1093
1248
  const sessionCwd = session?.header.cwd ?? cwd
1094
1249
  const currentView = store.getView()
1250
+ const defaults = currentDefaults()
1095
1251
  const model = currentView.model !== ''
1096
1252
  ? currentView.model
1097
1253
  : pendingSelection !== undefined
@@ -1110,6 +1266,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1110
1266
  store,
1111
1267
  approval,
1112
1268
  questions,
1269
+ subagents,
1113
1270
  commands,
1114
1271
  skills,
1115
1272
  model,
@@ -1135,6 +1292,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1135
1292
  cyclePermission,
1136
1293
  setPermission: setPermissionAction,
1137
1294
  selectModel,
1295
+ subagentModel: subagentModelLabel(),
1296
+ setSubagentModel,
1297
+ clearSubagentModel,
1298
+ deleteSession,
1138
1299
  exportTranscript,
1139
1300
  renameTitle,
1140
1301
  loadPresets: () => presets.list(),
@@ -1145,6 +1306,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1145
1306
  createSession,
1146
1307
  loadSessions,
1147
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
+ },
1148
1315
  switchSession,
1149
1316
  cancelSessionSwitch,
1150
1317
  loadPlugins: () => listPluginRows(ctx),