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/README.en.md +19 -3
- package/README.md +19 -3
- package/lib/index.mjs +1175 -227
- package/lib/types/app.d.ts +13 -0
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/kernel-panels.d.ts +58 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/render/animations.d.ts +14 -2
- package/lib/types/render/projection.d.ts +2 -0
- package/lib/types/render/tool-preview.d.ts +10 -0
- package/lib/types/session-directory.d.ts +46 -2
- package/lib/types/subagents.d.ts +60 -0
- package/package.json +25 -1
- package/src/app.ts +400 -103
- package/src/approval.ts +161 -135
- package/src/index.ts +175 -8
- package/src/kernel-panels.ts +310 -30
- package/src/models.ts +26 -0
- package/src/render/animations.ts +49 -5
- package/src/render/lines.ts +236 -233
- package/src/render/projection.ts +5 -1
- package/src/render/tool-preview.ts +77 -50
- package/src/session-directory.ts +128 -6
- package/src/subagents.ts +165 -0
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
|
|
37
|
-
pending: PendingApproval | undefined
|
|
38
|
-
/** Presentational: an answer was submitted, the ask has not settled yet. */
|
|
39
|
-
answered: boolean
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @param
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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),
|