dsh-code 0.4.0 → 0.6.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 +217 -0
- package/README.md +216 -67
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/devtools-CdTl3MNy.mjs +3643 -0
- package/lib/index.mjs +27467 -673
- package/lib/rolldown-runtime-CMFfr-1z.mjs +26 -0
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +29 -1
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/history.d.ts +79 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +48 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/animations.d.ts +10 -1
- package/lib/types/render/inspector.d.ts +6 -0
- package/lib/types/render/projection.d.ts +21 -1
- package/lib/types/render/status.d.ts +131 -14
- package/lib/types/render/text.d.ts +9 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/package.json +117 -112
- package/src/app.ts +2543 -1969
- package/src/commands.ts +15 -1
- package/src/history.ts +136 -0
- package/src/index.ts +550 -155
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +419 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/animations.ts +14 -1
- package/src/render/export.ts +4 -0
- package/src/render/inspector.ts +23 -5
- package/src/render/lines.ts +21 -10
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +71 -8
- package/src/render/status.ts +522 -65
- package/src/render/text.ts +34 -6
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- package/src/whale-glyph.ts +23 -23
- package/README.zh.md +0 -65
- package/src/pictures/1.png +0 -0
package/src/index.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
3
3
|
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
-
* creates
|
|
5
|
-
*
|
|
6
|
-
* durable session, answers approval asks with a y/n bar,
|
|
7
|
-
*
|
|
8
|
-
* process exit.
|
|
4
|
+
* creates or resumes preset-composed Agents through the core registry, keeps
|
|
5
|
+
* one Ink owner while the active session changes, folds submitted prompts
|
|
6
|
+
* into the selected durable session, answers approval asks with a y/n bar,
|
|
7
|
+
* dispatches slash commands, and on quit flushes and requests process exit.
|
|
9
8
|
*
|
|
10
9
|
* @module @deepseek-ai/dsh-code
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import { randomUUID } from 'node:crypto'
|
|
14
13
|
import { readFileSync } from 'node:fs'
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { mkdir, writeFile as writeFileAsync } from 'node:fs/promises'
|
|
16
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
17
|
import { createElement } from 'react'
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
19
|
import z from '@deepseek-ai/schemastery'
|
|
20
20
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
21
|
-
import type { Agent, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
21
|
+
import type { Agent, AgentHandle, ModelSelection, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
|
22
22
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
23
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
|
24
24
|
import { SessionId, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
25
25
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
26
26
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
@@ -29,18 +29,30 @@ import type {} from '@deepseek-ai/dsh-session-title'
|
|
|
29
29
|
// and the cmdline Context merge for the appExit host value.
|
|
30
30
|
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
31
31
|
import type {} from '@deepseek-ai/dsh-cmdline'
|
|
32
|
-
import { App } from './app.ts'
|
|
32
|
+
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
36
|
import { loadModelDirectory, type ModelRow } from './models.ts'
|
|
37
|
-
import { createMentions, type MentionsApi } from './mentions.ts'
|
|
37
|
+
import { createMentions, type MentionCandidate, type MentionsApi } from './mentions.ts'
|
|
38
38
|
import { mountQuestionProvider, type QuestionStore } from './questions.ts'
|
|
39
|
-
import { createTranscriptStore } from './store.ts'
|
|
39
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
40
|
+
import { parseStatuslineItems } from './render/status.ts'
|
|
41
|
+
import { appendHistoryContent, HISTORY_MAX_ENTRIES, parseHistoryFile } from './history.ts'
|
|
40
42
|
import { watchSkills, type SkillsView } from './skills.ts'
|
|
41
43
|
import { toolArgumentsPreview } from './render/tool-preview.ts'
|
|
42
44
|
import { buildExportMarkdown } from './render/export.ts'
|
|
43
45
|
import type { TuiStartup } from './startup.ts'
|
|
46
|
+
import { SessionSwitchQueue } from './session-switch.ts'
|
|
47
|
+
import { agentPresetsFrom, resolvePreset, switchPreset } from './presets.ts'
|
|
48
|
+
import { listPluginRows } from './plugin-inventory.ts'
|
|
49
|
+
import {
|
|
50
|
+
mergeSessionTitles,
|
|
51
|
+
projectSessionRows,
|
|
52
|
+
type SessionDirectoryOptions,
|
|
53
|
+
type SessionQueryService,
|
|
54
|
+
type SessionRow,
|
|
55
|
+
} from './session-directory.ts'
|
|
44
56
|
|
|
45
57
|
/** Stable Cordis plugin name. */
|
|
46
58
|
export const name = 'tui-runner'
|
|
@@ -51,13 +63,14 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
|
|
|
51
63
|
/** Plugin config: the startup resolved from this app's injected provider service. */
|
|
52
64
|
export interface Config {
|
|
53
65
|
/** How this invocation obtains its session identity (validated loosely; narrowed in {@link apply}). */
|
|
54
|
-
startup: { kind: string; sessionId?: string }
|
|
66
|
+
startup: { kind: string; sessionId?: string; mode?: string }
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
export const Config: z<Config> = z.object({
|
|
58
70
|
startup: z.object({
|
|
59
71
|
kind: z.string().required(),
|
|
60
72
|
sessionId: z.string(),
|
|
73
|
+
mode: z.string(),
|
|
61
74
|
}),
|
|
62
75
|
})
|
|
63
76
|
|
|
@@ -93,6 +106,8 @@ function gitBranch(cwd: string): string {
|
|
|
93
106
|
interface Target {
|
|
94
107
|
sessionId: string
|
|
95
108
|
resume: boolean
|
|
109
|
+
mode?: string
|
|
110
|
+
cwd?: string
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
/**
|
|
@@ -104,8 +119,8 @@ interface Target {
|
|
|
104
119
|
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
105
120
|
*/
|
|
106
121
|
async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
107
|
-
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false }
|
|
108
|
-
if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false }
|
|
122
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
123
|
+
if (startup.kind === 'named') return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
109
124
|
if (persistence === undefined) {
|
|
110
125
|
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
111
126
|
}
|
|
@@ -149,7 +164,7 @@ function approvalCommandPreview(events: readonly { kind: string }[], callId: str
|
|
|
149
164
|
/** The runner's connection between the React app and the process side. */
|
|
150
165
|
interface AppBridge {
|
|
151
166
|
/** Post one local notice line (feedback the transcript does not carry). */
|
|
152
|
-
notify(text: string): void
|
|
167
|
+
notify(text: string, tone?: NoticeTone): void
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
/**
|
|
@@ -168,91 +183,123 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
168
183
|
const defaultModel = ctx.get('agentDefaultModel')
|
|
169
184
|
const sessions = ctx.get('sessions')
|
|
170
185
|
const persistence = ctx.get('sessionPersistence')
|
|
186
|
+
const sessionQuery = (ctx as unknown as { get(name: string): unknown }).get('sessionQuery') as SessionQueryService | undefined
|
|
171
187
|
// Early process shutdown can dispose the tree while settlement is pending.
|
|
172
188
|
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
|
173
189
|
|
|
174
190
|
const cwd = process.cwd()
|
|
175
|
-
const target = await resolveTarget(startup, persistence, cwd)
|
|
176
|
-
|
|
177
191
|
const defaults = defaultModel.currentSelection()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
192
|
+
const presets = agentPresetsFrom(ctx)
|
|
193
|
+
if (presets === undefined) throw new Error('agent preset service is unavailable; check the dsh-code bundle patch')
|
|
194
|
+
|
|
195
|
+
// A bare fresh launch stays transient: no Agent or session is composed, and
|
|
196
|
+
// nothing is persisted, until the user's first real input. Explicit flags
|
|
197
|
+
// (--resume/--continue/--session/--mode) keep the eager create/resume path.
|
|
198
|
+
const lazy = startup.kind === 'fresh' && startup.mode === undefined
|
|
199
|
+
|
|
200
|
+
interface ActiveSession {
|
|
201
|
+
handle: AgentHandle
|
|
202
|
+
agent: Agent
|
|
203
|
+
session: Session
|
|
204
|
+
store: ReturnType<typeof createTranscriptStore>
|
|
205
|
+
mentions: MentionsApi
|
|
206
|
+
mode: string
|
|
207
|
+
selection: { picked?: ModelSelection }
|
|
208
|
+
resumed: boolean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Prepare a complete next session before disturbing the currently visible one. */
|
|
212
|
+
const prepare = async (next: Target): Promise<ActiveSession> => {
|
|
213
|
+
const nextCwd = next.cwd ?? cwd
|
|
214
|
+
const selectionState: { picked?: ModelSelection } = {}
|
|
215
|
+
let mode = next.mode
|
|
216
|
+
if (!next.resume) mode = (await presets.resolve(mode)).id
|
|
217
|
+
const setup = async (agentCtx: Context): Promise<void> => {
|
|
218
|
+
const sessionPreset = next.resume
|
|
219
|
+
? resolvePreset(agentCtx.agent!.session)
|
|
220
|
+
: mode
|
|
221
|
+
const mounted = await presets.mount(agentCtx, sessionPreset)
|
|
222
|
+
mode = mounted.id
|
|
223
|
+
const selection: ModelSelectionRef = {
|
|
224
|
+
get current(): ModelSelection | undefined {
|
|
225
|
+
if (selectionState.picked !== undefined) return selectionState.picked
|
|
226
|
+
const logged = agentCtx.agent?.session.requestHeader()?.config
|
|
227
|
+
if (logged !== undefined) {
|
|
228
|
+
return {
|
|
229
|
+
provider: logged.provider,
|
|
230
|
+
model: logged.model,
|
|
231
|
+
...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
|
|
200
232
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
}
|
|
234
|
+
return defaults
|
|
235
|
+
},
|
|
236
|
+
set current(value: ModelSelection | undefined) { selectionState.picked = value },
|
|
237
|
+
assembled: undefined,
|
|
238
|
+
}
|
|
239
|
+
installModelSelection(agentCtx, selection)
|
|
240
|
+
}
|
|
241
|
+
const handle = next.resume
|
|
242
|
+
? await agents.resume({
|
|
243
|
+
resumeSessionId: SessionId(next.sessionId),
|
|
244
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
245
|
+
setup,
|
|
246
|
+
})
|
|
247
|
+
: await agents.create({
|
|
248
|
+
sessionId: SessionId(next.sessionId),
|
|
249
|
+
meta: { cwd: nextCwd, agentPreset: mode },
|
|
250
|
+
agentOptions: { provider: defaults.provider, model: defaults.model },
|
|
251
|
+
setup,
|
|
252
|
+
})
|
|
253
|
+
const session = handle.agent.session
|
|
254
|
+
const sessionCwd = session.header.cwd ?? nextCwd
|
|
255
|
+
return {
|
|
256
|
+
handle,
|
|
257
|
+
agent: handle.agent,
|
|
258
|
+
session,
|
|
259
|
+
store: createTranscriptStore(session.events),
|
|
260
|
+
mentions: createMentions(ctx, handle.agent, sessionCwd),
|
|
261
|
+
mode: mode ?? 'standard',
|
|
262
|
+
selection: selectionState,
|
|
263
|
+
resumed: next.resume,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let active: ActiveSession | undefined
|
|
268
|
+
let agent: Agent | undefined
|
|
269
|
+
let session: Session | undefined
|
|
270
|
+
let store: TranscriptStore = createTranscriptStore()
|
|
271
|
+
let mentions: MentionsApi | undefined
|
|
272
|
+
|
|
273
|
+
if (!lazy) {
|
|
274
|
+
const target = await resolveTarget(startup, persistence, cwd)
|
|
275
|
+
const prepared = await prepare(target)
|
|
276
|
+
active = prepared
|
|
277
|
+
agent = prepared.agent
|
|
278
|
+
session = prepared.session
|
|
279
|
+
store = prepared.store
|
|
280
|
+
mentions = prepared.mentions
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
// Seed the transcript from the full session log: constructor seeds never
|
|
238
|
-
// fire on `session/event`, so a resumed session paints its history once
|
|
239
|
-
//
|
|
240
|
-
|
|
284
|
+
// fire on `session/event`, so a resumed session paints its history once
|
|
285
|
+
// before the first render. The handler reads the current session/store, so
|
|
286
|
+
// the deferred first session of a bare launch is covered by the same feed.
|
|
241
287
|
const off = ctx.on('session/event', (subject: Session, event: SessionEvent) => {
|
|
242
|
-
if (subject.id === session.id) store.apply(event)
|
|
288
|
+
if (session !== undefined && subject.id === session.id) store.apply(event)
|
|
243
289
|
})
|
|
244
290
|
|
|
245
291
|
const commands: CommandsView = watchCommands(ctx)
|
|
246
|
-
commands.setAgent(agent)
|
|
292
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
247
293
|
|
|
248
294
|
const skills: SkillsView = watchSkills(ctx)
|
|
249
|
-
skills.setAgent(agent)
|
|
295
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
250
296
|
|
|
251
297
|
// Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
|
|
252
|
-
// claimed, every other ask falls through to the fail-closed waterfall.
|
|
298
|
+
// claimed, every other ask falls through to the fail-closed waterfall. The
|
|
299
|
+
// owner predicate is empty until the first session exists.
|
|
253
300
|
const approval: ApprovalStore = mountApprovalAnswerer(
|
|
254
301
|
ctx,
|
|
255
|
-
candidate => candidate.id === agent.id,
|
|
302
|
+
candidate => agent !== undefined && candidate.id === agent.id,
|
|
256
303
|
request => approvalCommandPreview(store.getView().entries, request.callId, request.toolName),
|
|
257
304
|
)
|
|
258
305
|
|
|
@@ -261,14 +308,75 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
261
308
|
// through this same pipe.
|
|
262
309
|
const questions: QuestionStore = mountQuestionProvider(ctx)
|
|
263
310
|
|
|
264
|
-
// @mention support: workspace file scan plus the opt-in session-reference
|
|
265
|
-
// service (the patch mounts it); submission expands session mentions.
|
|
266
|
-
const mentions: MentionsApi = createMentions(ctx, agent, session.header.cwd ?? cwd)
|
|
267
|
-
|
|
268
311
|
// The bridge the React app registers on mount: local notices from the
|
|
269
312
|
// process side (unknown commands, switch confirmations, cancels).
|
|
270
313
|
const bridge: AppBridge = { notify: () => {} }
|
|
271
314
|
|
|
315
|
+
// /statusline persistence: one user-level JSON file under the DSH home.
|
|
316
|
+
// Missing file means defaults; a corrupt file degrades to defaults with a
|
|
317
|
+
// surfaced warning (the customization is user-authored, never silent).
|
|
318
|
+
const statuslinePath = join(homedir(), '.dsh', 'dsh-code', 'statusline.json')
|
|
319
|
+
let statuslineWarning: string | undefined
|
|
320
|
+
let statuslineItems: readonly string[] = []
|
|
321
|
+
try {
|
|
322
|
+
statuslineItems = parseStatuslineItems(JSON.parse(readFileSync(statuslinePath, 'utf8')).items)
|
|
323
|
+
} catch (error) {
|
|
324
|
+
statuslineItems = parseStatuslineItems(undefined)
|
|
325
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
326
|
+
statuslineWarning = error instanceof Error ? error.message : String(error)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const saveStatusline = (items: readonly string[]): void => {
|
|
330
|
+
statuslineItems = [...items]
|
|
331
|
+
// The config directory may not exist on a first save; create it before
|
|
332
|
+
// the write so a fresh install persists customizations.
|
|
333
|
+
void mkdir(dirname(statuslinePath), { recursive: true })
|
|
334
|
+
.then(() => writeFileAsync(statuslinePath, JSON.stringify({ items }, null, 2) + '\n', 'utf8'))
|
|
335
|
+
.catch((writeError: unknown) => {
|
|
336
|
+
bridge.notify('statusline save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Global input recall (Codex composer-history contract): one JSONL file
|
|
341
|
+
// under the DSH home. A missing file means an empty history; unreadable or
|
|
342
|
+
// corrupt content degrades to the valid lines it could parse, silently —
|
|
343
|
+
// recall is a convenience surface, never a gate.
|
|
344
|
+
const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
|
|
345
|
+
let inputHistory: readonly string[] = []
|
|
346
|
+
try {
|
|
347
|
+
inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
|
|
348
|
+
} catch {
|
|
349
|
+
inputHistory = []
|
|
350
|
+
}
|
|
351
|
+
const recordHistory = (text: string): void => {
|
|
352
|
+
if (text === '') return
|
|
353
|
+
inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
|
|
354
|
+
// A missing file on the first save is not an error: start from empty.
|
|
355
|
+
let current = ''
|
|
356
|
+
try {
|
|
357
|
+
current = readFileSync(historyPath, 'utf8')
|
|
358
|
+
} catch {
|
|
359
|
+
current = ''
|
|
360
|
+
}
|
|
361
|
+
void mkdir(dirname(historyPath), { recursive: true })
|
|
362
|
+
.then(() => writeFileAsync(historyPath, appendHistoryContent(current, text), 'utf8'))
|
|
363
|
+
.catch((writeError: unknown) => {
|
|
364
|
+
bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Cancel one queued inbox message (Delete on the empty composer); the durable splice retires its pending row. */
|
|
369
|
+
const cancelQueued = (messageId: string): void => {
|
|
370
|
+
if (agent === undefined) return
|
|
371
|
+
try {
|
|
372
|
+
if (agent.inbox.remove(MessageId(messageId))) {
|
|
373
|
+
bridge.notify('queued message cancelled')
|
|
374
|
+
}
|
|
375
|
+
} catch (error: unknown) {
|
|
376
|
+
bridge.notify('queue cancel failed: ' + (error instanceof Error ? error.message : String(error)), 'error')
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
272
380
|
// The mount handle lives in a box: quit closes over it, while the mount
|
|
273
381
|
// itself is created after quit (the App element needs quit as a prop).
|
|
274
382
|
const mountRef: { current?: TuiMount } = {}
|
|
@@ -276,44 +384,71 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
276
384
|
const quit = (): void => {
|
|
277
385
|
if (quitting) return
|
|
278
386
|
quitting = true
|
|
387
|
+
switchQueue.cancel()
|
|
279
388
|
off()
|
|
280
389
|
mountRef.current?.unmount()
|
|
281
|
-
|
|
390
|
+
// A bare launch that exits before the first input has no session: exit
|
|
391
|
+
// cleanly without flushing or disposing anything.
|
|
392
|
+
const currentSession = session
|
|
393
|
+
const currentActive = active
|
|
394
|
+
if (currentSession === undefined || currentActive === undefined) {
|
|
395
|
+
io.exit(0)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
void sessions.flush(currentSession)
|
|
282
399
|
.catch((flushError: unknown) => {
|
|
283
400
|
// The session log already carries every durable event; a failed flush
|
|
284
401
|
// must not trap the user in a dead terminal, so report and still exit.
|
|
285
402
|
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`)
|
|
286
403
|
})
|
|
404
|
+
.then(() => currentActive.handle.dispose())
|
|
405
|
+
.catch((disposeError: unknown) => {
|
|
406
|
+
internals.stderr.write(`dsh: agent disposal failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}\n`)
|
|
407
|
+
})
|
|
287
408
|
.then(() => { io.exit(0) })
|
|
288
409
|
}
|
|
289
410
|
|
|
290
411
|
/** Run one slash line through the command registry (closed namespace). */
|
|
291
412
|
const runSlash = (line: string): void => {
|
|
413
|
+
const currentAgent = agent
|
|
414
|
+
if (currentAgent === undefined) return
|
|
415
|
+
if (line.startsWith('/mode ')) {
|
|
416
|
+
void switchModeAction(line.slice(6).trim())
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
if (line.startsWith('/resume ')) {
|
|
420
|
+
requestResume(line.slice(8).trim())
|
|
421
|
+
return
|
|
422
|
+
}
|
|
292
423
|
const registry = ctx.get('commands')
|
|
293
424
|
if (registry === undefined) {
|
|
294
|
-
bridge.notify('no command registry is mounted in this composition')
|
|
425
|
+
bridge.notify('no command registry is mounted in this composition', 'error')
|
|
295
426
|
return
|
|
296
427
|
}
|
|
297
428
|
const controller = new AbortController()
|
|
298
|
-
void registry.execute(
|
|
429
|
+
void Promise.resolve().then(() => registry.execute(currentAgent, line, controller.signal)).then((execution) => {
|
|
299
430
|
if (execution === undefined) {
|
|
300
431
|
// No command owns this line: send it verbatim so a user-invocable
|
|
301
432
|
// skill gesture (`/skill-name`) reaches the host's tool-skill
|
|
302
433
|
// pre-step injection — the web composer's same fall-through.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
434
|
+
try {
|
|
435
|
+
currentAgent.followup(createUserMessage({
|
|
436
|
+
content: [{ type: 'text', text: line }],
|
|
437
|
+
source: { kind: 'user' },
|
|
438
|
+
}))
|
|
439
|
+
} catch (error: unknown) {
|
|
440
|
+
bridge.notify(`command fallback failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
441
|
+
}
|
|
307
442
|
}
|
|
308
443
|
}, (error: unknown) => {
|
|
309
|
-
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}
|
|
444
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
310
445
|
})
|
|
311
446
|
}
|
|
312
447
|
|
|
313
|
-
/** Deliver one
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
|
|
448
|
+
/** Deliver one trimmed line to the live session, expanding mentions first. */
|
|
449
|
+
const deliverLine = (line: string, mode: 'followup' | 'steer'): void => {
|
|
450
|
+
const currentAgent = agent!
|
|
451
|
+
const currentMentions = mentions!
|
|
317
452
|
// The command registry is a closed namespace: slash lines run out of
|
|
318
453
|
// band and never reach the model through this path (steering keeps the
|
|
319
454
|
// registry out of the inbox, so slash lines steer as literal text).
|
|
@@ -321,27 +456,32 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
321
456
|
runSlash(line)
|
|
322
457
|
return
|
|
323
458
|
}
|
|
324
|
-
let parsed: ReturnType<
|
|
459
|
+
let parsed: ReturnType<MentionsApi['parse']>
|
|
325
460
|
try {
|
|
326
|
-
parsed =
|
|
461
|
+
parsed = currentMentions.parse(line)
|
|
327
462
|
} catch (error: unknown) {
|
|
328
|
-
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}
|
|
463
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
329
464
|
return
|
|
330
465
|
}
|
|
331
466
|
const deliver = (readable: string, context?: UserMessage): void => {
|
|
332
467
|
// Session snapshots ride the inbox as model-facing context ahead of
|
|
333
468
|
// the readable message (upstream README wiring: inject before the
|
|
334
469
|
// followup/steer that wakes the driver).
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
470
|
+
try {
|
|
471
|
+
if (context !== undefined) currentAgent.inject(context)
|
|
472
|
+
const message = createUserMessage({
|
|
473
|
+
content: [{ type: 'text', text: readable }],
|
|
474
|
+
source: { kind: 'user' },
|
|
475
|
+
})
|
|
476
|
+
if (mode === 'steer') {
|
|
477
|
+
// The queued message is visible as a pending transcript row (the
|
|
478
|
+
// web queue-mirror contract); no notice noise on the happy path.
|
|
479
|
+
currentAgent.steer(message)
|
|
480
|
+
} else {
|
|
481
|
+
currentAgent.followup(message)
|
|
482
|
+
}
|
|
483
|
+
} catch (error: unknown) {
|
|
484
|
+
bridge.notify(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
345
485
|
}
|
|
346
486
|
}
|
|
347
487
|
if (parsed.references.length === 0) {
|
|
@@ -349,12 +489,62 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
349
489
|
return
|
|
350
490
|
}
|
|
351
491
|
const controller = new AbortController()
|
|
352
|
-
void
|
|
492
|
+
void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
353
493
|
deliver(prepared.text, prepared.additionalContext)
|
|
354
494
|
}, (error: unknown) => {
|
|
355
495
|
if (controller.signal.aborted) return
|
|
356
|
-
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}
|
|
496
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Deferred first-session creation for a bare launch: the session is composed
|
|
501
|
+
// only when the user submits real input (or /new), and every line that
|
|
502
|
+
// arrives during creation is delivered in order afterwards. A creation
|
|
503
|
+
// failure reports and clears the queue, leaving the transient state ready
|
|
504
|
+
// for the next attempt.
|
|
505
|
+
const pendingInputs: Array<{ text: string; mode: 'followup' | 'steer' }> = []
|
|
506
|
+
let creating: Promise<void> | undefined
|
|
507
|
+
const ensureSession = (mode?: string): void => {
|
|
508
|
+
if (creating !== undefined) return
|
|
509
|
+
const attempt = (async () => {
|
|
510
|
+
const next = await prepare({
|
|
511
|
+
sessionId: `session-${randomUUID()}`,
|
|
512
|
+
resume: false,
|
|
513
|
+
...(mode === undefined ? {} : { mode }),
|
|
514
|
+
})
|
|
515
|
+
if (quitting) {
|
|
516
|
+
void next.handle.dispose().catch(() => {})
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
active = next
|
|
520
|
+
agent = next.agent
|
|
521
|
+
session = next.session
|
|
522
|
+
store = next.store
|
|
523
|
+
mentions = next.mentions
|
|
524
|
+
commands.setAgent(agent)
|
|
525
|
+
skills.setAgent(agent)
|
|
526
|
+
renderCurrent()
|
|
527
|
+
const queued = pendingInputs.splice(0)
|
|
528
|
+
for (const item of queued) deliverLine(item.text, item.mode)
|
|
529
|
+
})().catch((error: unknown) => {
|
|
530
|
+
pendingInputs.length = 0
|
|
531
|
+
bridge.notify(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
532
|
+
}).finally(() => {
|
|
533
|
+
creating = undefined
|
|
357
534
|
})
|
|
535
|
+
creating = attempt
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
539
|
+
const send = (text: string, mode: 'followup' | 'steer'): void => {
|
|
540
|
+
const line = text.trim()
|
|
541
|
+
if (line === '') return
|
|
542
|
+
if (session === undefined) {
|
|
543
|
+
pendingInputs.push({ text: line, mode })
|
|
544
|
+
ensureSession()
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
deliverLine(line, mode)
|
|
358
548
|
}
|
|
359
549
|
|
|
360
550
|
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
@@ -373,10 +563,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
373
563
|
|
|
374
564
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
375
565
|
const interrupt = (): boolean => {
|
|
376
|
-
if (agent.status !== 'running') return false
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
566
|
+
if (agent === undefined || agent.status !== 'running') return false
|
|
567
|
+
try {
|
|
568
|
+
agent.cancel({ kind: 'user' })
|
|
569
|
+
bridge.notify('turn cancelled — Ctrl+C or /quit to exit')
|
|
570
|
+
return true
|
|
571
|
+
} catch (error: unknown) {
|
|
572
|
+
bridge.notify(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
380
575
|
}
|
|
381
576
|
|
|
382
577
|
/**
|
|
@@ -385,6 +580,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
385
580
|
* custom knob state wraps to the first declared preset.
|
|
386
581
|
*/
|
|
387
582
|
const cyclePermission = (): string => {
|
|
583
|
+
if (session === undefined) throw new Error('no session yet — submit a message to start')
|
|
388
584
|
const service = ctx.get('permissionPresets') as
|
|
389
585
|
| {
|
|
390
586
|
names: readonly string[]
|
|
@@ -393,19 +589,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
393
589
|
}
|
|
394
590
|
| undefined
|
|
395
591
|
if (service === undefined || service.names.length === 0) {
|
|
396
|
-
bridge.notify('permission presets are not mounted in this composition')
|
|
592
|
+
bridge.notify('permission presets are not mounted in this composition', 'warning')
|
|
397
593
|
return ''
|
|
398
594
|
}
|
|
399
595
|
const at = service.names.indexOf(service.current(session.events))
|
|
400
596
|
const next = service.names[(at + 1) % service.names.length] ?? ''
|
|
401
597
|
if (next === '') return ''
|
|
402
|
-
|
|
403
|
-
|
|
598
|
+
try {
|
|
599
|
+
service.set(session, next)
|
|
600
|
+
return next
|
|
601
|
+
} catch (error: unknown) {
|
|
602
|
+
bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
603
|
+
return ''
|
|
604
|
+
}
|
|
404
605
|
}
|
|
405
606
|
|
|
406
607
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
407
608
|
const selectModel = (row: ModelRow): string => {
|
|
408
|
-
|
|
609
|
+
if (active === undefined) throw new Error('no session yet — submit a message to start')
|
|
610
|
+
active.selection.picked = { provider: row.provider, model: row.model }
|
|
409
611
|
return `${row.provider}/${row.model}`
|
|
410
612
|
}
|
|
411
613
|
|
|
@@ -415,19 +617,24 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
415
617
|
* workspace; an absolute or cwd-relative argument overrides it.
|
|
416
618
|
*/
|
|
417
619
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
620
|
+
if (session === undefined) {
|
|
621
|
+
bridge.notify('no session yet — submit a message to start', 'warning')
|
|
622
|
+
return
|
|
623
|
+
}
|
|
418
624
|
const wanted = argument.trim()
|
|
625
|
+
const sessionCwd = session.header.cwd ?? cwd
|
|
419
626
|
const defaultName = `dsh-session-${session.id.slice(-8)}.md`
|
|
420
627
|
const target = wanted === ''
|
|
421
|
-
? join(
|
|
628
|
+
? join(sessionCwd, defaultName)
|
|
422
629
|
: /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith('/')
|
|
423
630
|
? wanted
|
|
424
|
-
: join(
|
|
631
|
+
: join(sessionCwd, wanted)
|
|
425
632
|
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
426
633
|
try {
|
|
427
634
|
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
428
635
|
bridge.notify(`exported to ${target}`)
|
|
429
636
|
} catch (error: unknown) {
|
|
430
|
-
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}
|
|
637
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
431
638
|
}
|
|
432
639
|
}
|
|
433
640
|
|
|
@@ -439,6 +646,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
439
646
|
const renameTitle = (argument: string): string => {
|
|
440
647
|
const title = argument.trim()
|
|
441
648
|
if (title === '') return 'usage: /title <text>'
|
|
649
|
+
if (session === undefined) return 'no session yet — submit a message to start'
|
|
442
650
|
const service = ctx.get('sessionTitle')
|
|
443
651
|
if (service === undefined) return 'session titles are unavailable in this profile'
|
|
444
652
|
try {
|
|
@@ -449,35 +657,222 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
449
657
|
}
|
|
450
658
|
}
|
|
451
659
|
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
660
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
661
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
662
|
+
const projected = projectSessionRows(await sessionQuery.listSessions(signal), options)
|
|
663
|
+
// Titles are the expensive fold. Fetch only the first bounded picker page;
|
|
664
|
+
// navigation/filter changes trigger a fresh, cancellable observation.
|
|
665
|
+
const page = projected.slice(0, 32)
|
|
666
|
+
if (page.length === 0) return projected
|
|
667
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
668
|
+
return mergeSessionTitles(projected, observations)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
672
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
673
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
674
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const switchModeAction = async (id: string): Promise<string> => {
|
|
678
|
+
if (id === '') throw new Error('usage: /mode <preset>')
|
|
679
|
+
const currentAgent = agent
|
|
680
|
+
const currentActive = active
|
|
681
|
+
if (currentAgent === undefined || currentActive === undefined) {
|
|
682
|
+
throw new Error('no session yet — submit a message to start')
|
|
683
|
+
}
|
|
684
|
+
const preset = await switchPreset(presets, currentAgent, id)
|
|
685
|
+
currentActive.mode = preset.id
|
|
686
|
+
commands.setAgent(currentAgent)
|
|
687
|
+
skills.setAgent(currentAgent)
|
|
688
|
+
renderCurrent()
|
|
689
|
+
return preset.id
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
interface PendingSwitch { readonly target: Target; readonly label: string }
|
|
693
|
+
|
|
694
|
+
const activate = async (nextTarget: Target): Promise<void> => {
|
|
695
|
+
const previous = active
|
|
696
|
+
const next = await prepare(nextTarget)
|
|
697
|
+
active = next
|
|
698
|
+
agent = next.agent
|
|
699
|
+
session = next.session
|
|
700
|
+
store = next.store
|
|
701
|
+
mentions = next.mentions
|
|
702
|
+
commands.setAgent(agent)
|
|
703
|
+
skills.setAgent(agent)
|
|
704
|
+
try {
|
|
705
|
+
process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
|
|
706
|
+
renderCurrent()
|
|
707
|
+
} catch (error: unknown) {
|
|
708
|
+
active = previous
|
|
709
|
+
agent = previous?.agent
|
|
710
|
+
session = previous?.session
|
|
711
|
+
store = previous === undefined ? createTranscriptStore() : previous.store
|
|
712
|
+
mentions = previous?.mentions
|
|
713
|
+
if (agent !== undefined) commands.setAgent(agent)
|
|
714
|
+
if (agent !== undefined) skills.setAgent(agent)
|
|
715
|
+
await next.handle.dispose()
|
|
716
|
+
renderCurrent()
|
|
717
|
+
throw error
|
|
718
|
+
}
|
|
719
|
+
// No previous session (a bare launch switched straight into a resume):
|
|
720
|
+
// nothing to flush or dispose, so just confirm the activation.
|
|
721
|
+
if (previous === undefined) {
|
|
722
|
+
bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
let cleanupWarning: string | undefined
|
|
726
|
+
try {
|
|
727
|
+
await sessions.flush(previous.session)
|
|
728
|
+
} catch (error: unknown) {
|
|
729
|
+
cleanupWarning = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
await previous.handle.dispose()
|
|
733
|
+
} catch (error: unknown) {
|
|
734
|
+
cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
|
|
735
|
+
}
|
|
736
|
+
bridge.notify(cleanupWarning === undefined
|
|
737
|
+
? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
|
|
738
|
+
: `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
|
|
739
|
+
cleanupWarning === undefined ? 'info' : 'warning')
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
743
|
+
async request => { if (!quitting) await activate(request.target) },
|
|
744
|
+
error => bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
const requestSwitch = (request: PendingSwitch): void => {
|
|
748
|
+
if (session === undefined) {
|
|
749
|
+
// No session yet (a bare launch using /resume before any input): activate
|
|
750
|
+
// the target directly — there is no running turn to wait on and nothing
|
|
751
|
+
// to flush.
|
|
752
|
+
void activate(request.target).catch((error: unknown) => {
|
|
753
|
+
bridge.notify(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
754
|
+
})
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
if (request.target.sessionId === session.id) {
|
|
758
|
+
bridge.notify('that session is already active', 'warning')
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
const outcome = switchQueue.request(agent!, request)
|
|
762
|
+
if (outcome === 'queued') {
|
|
763
|
+
bridge.notify(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const resolveResumeId = async (wanted: string): Promise<string> => {
|
|
768
|
+
if (wanted === '') throw new Error('usage: /resume <id|prefix>')
|
|
769
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
770
|
+
const records = await sessionQuery.listSessions()
|
|
771
|
+
const exact = records.filter(record => record.header.id === wanted)
|
|
772
|
+
const matches = exact.length > 0 ? exact : records.filter(record => record.header.id.startsWith(wanted))
|
|
773
|
+
if (matches.length === 0) throw new Error(`no session matches "${wanted}"`)
|
|
774
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches)`)
|
|
775
|
+
if (matches[0]!.header.parentSession !== undefined || matches[0]!.header.origin === 'subagent') {
|
|
776
|
+
throw new Error('subagent conversations are read-only in /resume; resume a root session')
|
|
777
|
+
}
|
|
778
|
+
if (session !== undefined && agents.get(SessionId(matches[0]!.header.id)) !== undefined && matches[0]!.header.id !== session.id) {
|
|
779
|
+
throw new Error('that session is already live in another owner')
|
|
780
|
+
}
|
|
781
|
+
return matches[0]!.header.id
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const requestResume = (wanted: string): void => {
|
|
785
|
+
void resolveResumeId(wanted).then(id => {
|
|
786
|
+
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
787
|
+
}, (error: unknown) => bridge.notify(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const createSession = (mode?: string): void => {
|
|
791
|
+
// /new before any input is the first-session creation itself, not a switch.
|
|
792
|
+
if (session === undefined) {
|
|
793
|
+
ensureSession(mode)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
const nextCwd = session.header.cwd ?? cwd
|
|
797
|
+
const id = `session-${randomUUID()}`
|
|
798
|
+
requestSwitch({ target: { sessionId: id, resume: false, mode, cwd: nextCwd }, label: id.slice(-12) })
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const switchSession = (row: SessionRow): void => {
|
|
802
|
+
if (!row.resumable) {
|
|
803
|
+
bridge.notify('subagent conversations are read-only', 'warning')
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const cancelSessionSwitch = (): boolean => {
|
|
810
|
+
return switchQueue.cancel()
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const appElement = (): ReturnType<typeof createElement> => {
|
|
814
|
+
// A bare launch mounts with placeholder facts until the first input
|
|
815
|
+
// composes a real session: empty session id/mode, the deployment default
|
|
816
|
+
// model, and the working directory's basename. `status.ts` drops empty
|
|
817
|
+
// mode/sessionId, so the bar renders only the identity it actually has.
|
|
818
|
+
const sessionCwd = session?.header.cwd ?? cwd
|
|
819
|
+
const model = store.getView().model !== '' ? store.getView().model : `${defaults.provider}/${defaults.model}`
|
|
820
|
+
return createElement(App, {
|
|
821
|
+
key: session?.id ?? 'pending',
|
|
822
|
+
store,
|
|
823
|
+
approval,
|
|
824
|
+
questions,
|
|
825
|
+
commands,
|
|
826
|
+
skills,
|
|
827
|
+
model,
|
|
828
|
+
cwd: basename(sessionCwd),
|
|
829
|
+
workspaceRoot: sessionCwd,
|
|
830
|
+
branch: gitBranch(sessionCwd),
|
|
831
|
+
sessionId: session === undefined ? '' : session.id.slice(-8),
|
|
832
|
+
resumed: active?.resumed ?? false,
|
|
833
|
+
mode: active?.mode ?? '',
|
|
834
|
+
dispatch,
|
|
835
|
+
steer,
|
|
836
|
+
interrupt,
|
|
837
|
+
quit,
|
|
838
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
839
|
+
loadMentions: mentions === undefined
|
|
840
|
+
? () => Promise.resolve<readonly MentionCandidate[]>([])
|
|
841
|
+
: mentions.candidates,
|
|
842
|
+
cyclePermission,
|
|
843
|
+
selectModel,
|
|
844
|
+
exportTranscript,
|
|
845
|
+
renameTitle,
|
|
846
|
+
loadPresets: () => presets.list(),
|
|
847
|
+
switchMode: switchModeAction,
|
|
848
|
+
createSession,
|
|
849
|
+
loadSessions,
|
|
850
|
+
loadSessionTranscript,
|
|
851
|
+
switchSession,
|
|
852
|
+
cancelSessionSwitch,
|
|
853
|
+
loadPlugins: () => listPluginRows(ctx),
|
|
854
|
+
statusline: statuslineItems,
|
|
855
|
+
saveStatusline,
|
|
856
|
+
history: inputHistory,
|
|
857
|
+
recordHistory,
|
|
858
|
+
cancelQueued,
|
|
859
|
+
onBridgeReady: (instance: AppBridge) => { bridge.notify = instance.notify },
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const renderCurrent = (): void => {
|
|
864
|
+
mountRef.current?.rerender(appElement())
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
mountRef.current = io.mount(appElement())
|
|
868
|
+
|
|
869
|
+
// A corrupt statusline config must not vanish silently: surface it once
|
|
870
|
+
// the notice channel is live, after the first frame settles.
|
|
871
|
+
if (statuslineWarning !== undefined) {
|
|
872
|
+
setTimeout(() => {
|
|
873
|
+
bridge.notify('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
|
|
874
|
+
}, 50)
|
|
875
|
+
}
|
|
481
876
|
}
|
|
482
877
|
|
|
483
878
|
/**
|
|
@@ -492,8 +887,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
492
887
|
: config.startup.kind === 'latest'
|
|
493
888
|
? { kind: 'latest' }
|
|
494
889
|
: config.startup.kind === 'named' && config.startup.sessionId !== undefined
|
|
495
|
-
? { kind: 'named', sessionId: config.startup.sessionId }
|
|
496
|
-
: { kind: 'fresh' }
|
|
890
|
+
? { kind: 'named', sessionId: config.startup.sessionId, ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
|
|
891
|
+
: { kind: 'fresh', ...config.startup.mode === undefined ? {} : { mode: config.startup.mode } }
|
|
497
892
|
// Read through the global service store, not the property proxy: appExit is
|
|
498
893
|
// an optional host value, never an injected dependency.
|
|
499
894
|
const exit = ctx.get('appExit')
|