dsh-code 1.3.0 → 1.4.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 +2 -2
- package/README.md +2 -2
- package/lib/index.mjs +5115 -4390
- package/lib/types/app.d.ts +18 -51
- package/lib/types/completion.d.ts +29 -0
- package/lib/types/composer.d.ts +150 -0
- package/lib/types/git-workflow.d.ts +6 -0
- package/lib/types/index.d.ts +6 -188
- package/lib/types/locales/en.d.ts +16 -4
- package/lib/types/{authorization-panel.d.ts → panels/authorization-panel.d.ts} +6 -1
- package/lib/types/panels/completion-panel.d.ts +13 -0
- package/lib/types/panels/interaction-bars.d.ts +38 -0
- package/lib/types/{kernel-panels.d.ts → panels/kernel-panels.d.ts} +16 -37
- package/lib/types/{language-panel.d.ts → panels/language-panel.d.ts} +1 -1
- package/lib/types/panels/model-panels.d.ts +86 -0
- package/lib/types/{theme-panel.d.ts → panels/theme-panel.d.ts} +1 -1
- package/lib/types/{update-panel.d.ts → panels/update-panel.d.ts} +1 -1
- package/lib/types/provider-settings.d.ts +11 -0
- package/lib/types/render/inspector.d.ts +8 -0
- package/lib/types/render/text.d.ts +4 -0
- package/lib/types/runner/harness-gate.d.ts +83 -0
- package/lib/types/runner/input-history.d.ts +31 -0
- package/lib/types/runner/mode-cycle.d.ts +44 -0
- package/lib/types/runner/preferences.d.ts +40 -0
- package/lib/types/runner/quit.d.ts +27 -0
- package/lib/types/runner/search-rows.d.ts +38 -0
- package/lib/types/runner/session-io.d.ts +46 -0
- package/lib/types/runner/session-target.d.ts +42 -0
- package/lib/types/runner/startup-config.d.ts +33 -0
- package/lib/types/runner/submissions.d.ts +87 -0
- package/lib/types/session/attach.d.ts +39 -0
- package/lib/types/{store.d.ts → session/store.d.ts} +1 -1
- package/lib/types/{subagents.d.ts → session/subagents.d.ts} +8 -1
- package/lib/types/settings-file.d.ts +10 -0
- package/lib/types/{panel-accent.d.ts → ui/panel-accent.d.ts} +1 -1
- package/lib/types/ui/panel-gap.d.ts +6 -0
- package/lib/types/ui/query-editor.d.ts +10 -0
- package/lib/types/ui/styled-rows.d.ts +8 -0
- package/lib/types/{terminal-title.d.ts → ui/terminal-title.d.ts} +1 -1
- package/lib/types/ui/ui-contract.d.ts +12 -0
- package/lib/types/ui/use-frames.d.ts +6 -0
- package/lib/types/ui/use-stable-input.d.ts +7 -0
- package/package.json +1 -1
- package/src/app.ts +543 -4066
- package/src/completion.ts +117 -0
- package/src/composer.ts +1956 -0
- package/src/git-workflow.ts +18 -0
- package/src/index.ts +113 -633
- package/src/internals.ts +1 -1
- package/src/locales/en.ts +16 -4
- package/src/locales/zh.ts +16 -4
- package/src/{authorization-panel.ts → panels/authorization-panel.ts} +25 -6
- package/src/panels/completion-panel.ts +79 -0
- package/src/panels/interaction-bars.ts +567 -0
- package/src/{kernel-panels.ts → panels/kernel-panels.ts} +84 -65
- package/src/{language-panel.ts → panels/language-panel.ts} +4 -4
- package/src/panels/model-panels.ts +1021 -0
- package/src/{theme-panel.ts → panels/theme-panel.ts} +5 -5
- package/src/{update-panel.ts → panels/update-panel.ts} +6 -6
- package/src/provider-settings.ts +38 -0
- package/src/render/inspector.ts +23 -0
- package/src/render/text.ts +8 -0
- package/src/runner/harness-gate.ts +168 -0
- package/src/runner/input-history.ts +77 -0
- package/src/runner/mode-cycle.ts +49 -0
- package/src/runner/preferences.ts +67 -0
- package/src/runner/quit.ts +53 -0
- package/src/runner/search-rows.ts +55 -0
- package/src/runner/session-io.ts +206 -0
- package/src/runner/session-target.ts +81 -0
- package/src/runner/startup-config.ts +54 -0
- package/src/runner/submissions.ts +157 -0
- package/src/session/attach.ts +87 -0
- package/src/{session-directory.ts → session/session-directory.ts} +1 -1
- package/src/{store.ts → session/store.ts} +1 -1
- package/src/{subagents.ts → session/subagents.ts} +12 -1
- package/src/settings-file.ts +19 -1
- package/src/{panel-accent.ts → ui/panel-accent.ts} +1 -1
- package/src/ui/panel-gap.ts +9 -0
- package/src/ui/query-editor.ts +16 -0
- package/src/ui/styled-rows.ts +124 -0
- package/src/{terminal-title.ts → ui/terminal-title.ts} +1 -1
- package/src/ui/ui-contract.ts +10 -0
- package/src/ui/use-frames.ts +23 -0
- package/src/ui/use-stable-input.ts +17 -0
- /package/lib/types/{fork.d.ts → session/fork.d.ts} +0 -0
- /package/lib/types/{history.d.ts → session/history.d.ts} +0 -0
- /package/lib/types/{session-directory.d.ts → session/session-directory.d.ts} +0 -0
- /package/lib/types/{session-switch.d.ts → session/session-switch.d.ts} +0 -0
- /package/src/{fork.ts → session/fork.ts} +0 -0
- /package/src/{history.ts → session/history.ts} +0 -0
- /package/src/{session-switch.ts → session/session-switch.ts} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Session directory IO exposed to the picker, /delete, and /export.
|
|
2
|
+
*
|
|
3
|
+
* The kernel persistence seam has NO deletion API by design — logs accumulate
|
|
4
|
+
* "until removed externally" — so removal is planned, layout-checked, and
|
|
5
|
+
* lease-guarded here before any file is touched. Every read goes through the
|
|
6
|
+
* in-process session-query engine, so this module stays free of `app.ts` and
|
|
7
|
+
* of the Ink tree.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/runner/session-io
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, rm, stat } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
|
|
15
|
+
import { buildExportMarkdown } from '../render/export.ts'
|
|
16
|
+
import {
|
|
17
|
+
acquireSessionDeletionLeases,
|
|
18
|
+
isSessionArtifactName,
|
|
19
|
+
jsonlSessionRoot,
|
|
20
|
+
mergeSessionTitles,
|
|
21
|
+
planSessionDeletion,
|
|
22
|
+
projectSessionRows,
|
|
23
|
+
releaseSessionDeletionLeases,
|
|
24
|
+
sessionArtifactDirectory,
|
|
25
|
+
sessionDirectoryFor,
|
|
26
|
+
sessionRowMatchesQuery,
|
|
27
|
+
type SessionDeletionPersistence,
|
|
28
|
+
type SessionDirectoryOptions,
|
|
29
|
+
type SessionQueryService,
|
|
30
|
+
type SessionRow,
|
|
31
|
+
} from '../session/session-directory.ts'
|
|
32
|
+
import { createTranscriptStore } from '../session/store.ts'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The persistence surface this module needs: the JSONL backend's public
|
|
36
|
+
* session root plus its per-session write handles. Typing the narrow shape
|
|
37
|
+
* instead of the upstream service keeps the IO injectable from a test double.
|
|
38
|
+
*/
|
|
39
|
+
export interface SessionIoPersistence extends SessionDeletionPersistence {
|
|
40
|
+
/** JSONL backend plugin config carrying the session root, when exposed. */
|
|
41
|
+
readonly config?: { readonly root?: unknown }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Services the session IO closes over. */
|
|
45
|
+
export interface SessionIoServices {
|
|
46
|
+
/** In-process session-query engine; absent in profiles without one. */
|
|
47
|
+
readonly sessionQuery?: SessionQueryService
|
|
48
|
+
/** The durable persistence service; absent in profiles without one. */
|
|
49
|
+
readonly persistence?: SessionIoPersistence
|
|
50
|
+
/** The session currently visible in the UI (self-deletion guard). */
|
|
51
|
+
readonly activeSessionId: () => string | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Session directory reads and the guarded /delete operation. */
|
|
55
|
+
export interface SessionIo {
|
|
56
|
+
/** Project the session directory for the picker (query-filtered). */
|
|
57
|
+
readonly loadSessions: (options: SessionDirectoryOptions, signal?: AbortSignal) => Promise<readonly SessionRow[]>
|
|
58
|
+
/** Delete one session subtree, returning the outcome line. */
|
|
59
|
+
readonly deleteSession: (id: string) => Promise<string>
|
|
60
|
+
/** Render one session's whole transcript as export Markdown. */
|
|
61
|
+
readonly loadSessionTranscript: (id: string, signal?: AbortSignal) => Promise<string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bind the session IO to one runner's services.
|
|
66
|
+
* @param services - the query engine, persistence, and the live-session probe.
|
|
67
|
+
* @returns the picker/delete/export reads used by the app bridge.
|
|
68
|
+
*/
|
|
69
|
+
export function createSessionIo(services: SessionIoServices): SessionIo {
|
|
70
|
+
const { sessionQuery, persistence } = services
|
|
71
|
+
|
|
72
|
+
const loadSessions = async (options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]> => {
|
|
73
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
74
|
+
const records = await sessionQuery.listSessions(signal)
|
|
75
|
+
// Last-activity timestamps for sorting (codex UpdatedAt default): the
|
|
76
|
+
// newest generation artifact's mtime under the JSONL layout. 0.1.5 dropped
|
|
77
|
+
// the persistence `locate()` query, so paths are derived from the
|
|
78
|
+
// backend's public config root. Backends without a JSONL config (or
|
|
79
|
+
// vanished directories) fall back to createdAt inside the projection.
|
|
80
|
+
const root = jsonlSessionRoot(persistence)
|
|
81
|
+
const updated = new Map<string, number>()
|
|
82
|
+
if (root !== undefined) {
|
|
83
|
+
await Promise.all(records.map(async record => {
|
|
84
|
+
try {
|
|
85
|
+
const dir = sessionDirectoryFor(root, record.header.cwd, record.header.id)
|
|
86
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
87
|
+
const stats = await Promise.all(
|
|
88
|
+
entries.filter(entry => entry.isFile() && isSessionArtifactName(entry.name))
|
|
89
|
+
.map(entry => stat(join(dir, entry.name))),
|
|
90
|
+
)
|
|
91
|
+
const newest = Math.max(...stats.map(info => info.mtimeMs))
|
|
92
|
+
if (Number.isFinite(newest)) updated.set(record.header.id, newest)
|
|
93
|
+
} catch {
|
|
94
|
+
// Artifact gone or unreadable: the projection falls back to createdAt.
|
|
95
|
+
}
|
|
96
|
+
}))
|
|
97
|
+
}
|
|
98
|
+
const projected = projectSessionRows(records, { ...options, query: '' }, updated)
|
|
99
|
+
// Titles are the expensive fold. Fetch the first picker page when idle;
|
|
100
|
+
// a non-empty query loads more so the displayed title can match.
|
|
101
|
+
const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
|
|
102
|
+
const page = projected.slice(0, titleBudget)
|
|
103
|
+
if (page.length === 0) return projected
|
|
104
|
+
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
105
|
+
const titled = mergeSessionTitles(projected, observations)
|
|
106
|
+
return titled.filter(row => sessionRowMatchesQuery(row, options.query))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Delete one session subtree (/delete, codex semantics: subagent threads go
|
|
111
|
+
* with their root). The kernel persistence seam has NO deletion API by
|
|
112
|
+
* design — logs accumulate "until removed externally" — so this is the
|
|
113
|
+
* controlled external removal, in three phases with a hard boundary
|
|
114
|
+
* between planning and touching the filesystem:
|
|
115
|
+
*
|
|
116
|
+
* 1. `planSessionDeletion` collects the subtree and refuses when the root
|
|
117
|
+
* or ANY member is live (a live child would outlive its deleted
|
|
118
|
+
* parent), ordering the plan children-first.
|
|
119
|
+
* 2. Every plan node must derive to a guarded artifact directory
|
|
120
|
+
* (`encodeSegment(id)` layout beneath the backend's config root).
|
|
121
|
+
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
122
|
+
* deletion here — no file has been touched yet, so a backend or layout
|
|
123
|
+
* surprise can never strand a half-deleted subtree.
|
|
124
|
+
* 3. Acquire every node's public persistence write handle before touching
|
|
125
|
+
* files. The JSONL backend holds its cross-process kernel lease for each
|
|
126
|
+
* handle, so another terminal's live session refuses the whole deletion.
|
|
127
|
+
* 4. Artifacts are removed children-first while every lease remains held:
|
|
128
|
+
* only an I/O error mid-delete can stop it short (reported with
|
|
129
|
+
* removed/total counts), leaving the shallowest lineage intact.
|
|
130
|
+
*
|
|
131
|
+
* @param id - the root session id to delete.
|
|
132
|
+
* @returns the outcome line for the panel/notice.
|
|
133
|
+
*/
|
|
134
|
+
const deleteSession = async (id: string): Promise<string> => {
|
|
135
|
+
if (sessionQuery === undefined) return 'session query is unavailable in this profile'
|
|
136
|
+
const activeId = services.activeSessionId()
|
|
137
|
+
if (activeId !== undefined && activeId === id) return 'cannot delete the session you are using — switch or /new first'
|
|
138
|
+
const records = await sessionQuery.listSessions()
|
|
139
|
+
const plan = planSessionDeletion(records, id)
|
|
140
|
+
if (!plan.ok) return plan.reason
|
|
141
|
+
// Phase 2 completes the plan before the first rm: derive and
|
|
142
|
+
// layout-check every node up front, so a refusal never leaves a
|
|
143
|
+
// partially removed subtree behind.
|
|
144
|
+
const root = jsonlSessionRoot(persistence)
|
|
145
|
+
if (root === undefined || persistence === undefined) {
|
|
146
|
+
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
147
|
+
}
|
|
148
|
+
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
149
|
+
const dirs = new Map<string, string>()
|
|
150
|
+
for (const node of plan.nodes) {
|
|
151
|
+
const record = byId.get(node.id)
|
|
152
|
+
if (record === undefined) return `no persisted session matches "${node.id}"`
|
|
153
|
+
const dir = sessionArtifactDirectory(sessionDirectoryFor(root, record.header.cwd, node.id), node.id)
|
|
154
|
+
if (dir === undefined) {
|
|
155
|
+
return `refusing to delete: unexpected artifact layout for ${node.id.slice(-12)}`
|
|
156
|
+
}
|
|
157
|
+
dirs.set(node.id, dir)
|
|
158
|
+
}
|
|
159
|
+
let leases
|
|
160
|
+
try {
|
|
161
|
+
leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
|
|
162
|
+
} catch (error: unknown) {
|
|
163
|
+
if (error instanceof SessionAlreadyOwnedError) {
|
|
164
|
+
return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
|
|
165
|
+
}
|
|
166
|
+
return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let removed = 0
|
|
170
|
+
let outcome: string | undefined
|
|
171
|
+
for (const node of plan.nodes) {
|
|
172
|
+
const dir = dirs.get(node.id)!
|
|
173
|
+
try {
|
|
174
|
+
// Remove every canonical generation artifact this build knows; other
|
|
175
|
+
// sibling files are never ours to delete. The POSIX session.lock file
|
|
176
|
+
// deliberately remains because unlinking a held flock inode would
|
|
177
|
+
// forfeit the backend's exclusion guarantee.
|
|
178
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
181
|
+
await rm(join(dir, entry.name), { force: true })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
185
|
+
removed += 1
|
|
186
|
+
} catch (error: unknown) {
|
|
187
|
+
outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
192
|
+
try {
|
|
193
|
+
await releaseSessionDeletionLeases(leases)
|
|
194
|
+
} catch (error: unknown) {
|
|
195
|
+
return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
|
|
196
|
+
}
|
|
197
|
+
return outcome
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
201
|
+
if (sessionQuery === undefined) throw new Error('session query is unavailable in this profile')
|
|
202
|
+
const snapshot = await sessionQuery.readSession(id, signal)
|
|
203
|
+
return buildExportMarkdown(createTranscriptStore(snapshot.events).getView(), snapshot.session.id)
|
|
204
|
+
}
|
|
205
|
+
return { loadSessions, deleteSession, loadSessionTranscript }
|
|
206
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Session identity resolution for one terminal runner invocation.
|
|
2
|
+
*
|
|
3
|
+
* Turns parsed startup flags into the session this process will run, and maps
|
|
4
|
+
* a session id onto a filename-safe export default. Resolution reads persisted
|
|
5
|
+
* headers, so it is the runner's only pre-composition persistence IO; the
|
|
6
|
+
* remaining helpers are pure.
|
|
7
|
+
*
|
|
8
|
+
* @module @deepseek-ai/dsh-code/session-target
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
import type { SessionId, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
14
|
+
import { isSubagentSession, matchSessionId, newestRootForCwd } from '../session/session-directory.ts'
|
|
15
|
+
import type { TuiStartup } from '../startup.ts'
|
|
16
|
+
|
|
17
|
+
/** The session identity this invocation will run, plus whether it is resumed. */
|
|
18
|
+
export interface Target {
|
|
19
|
+
sessionId: string
|
|
20
|
+
resume: boolean
|
|
21
|
+
mode?: string
|
|
22
|
+
cwd?: string
|
|
23
|
+
seed?: readonly SessionEvent[]
|
|
24
|
+
parentSession?: SessionId
|
|
25
|
+
/** Marks the session as a subagent conversation in the durable header. */
|
|
26
|
+
origin?: 'subagent'
|
|
27
|
+
seedLength?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reduce a session id to a filename-safe /export default-name suffix. Session
|
|
32
|
+
* ids are normally minted `session-<uuid>`, but `--session` accepts arbitrary
|
|
33
|
+
* user text: path separators must never leak into the default export filename
|
|
34
|
+
* (which would escape the session cwd).
|
|
35
|
+
* @param id - the session id.
|
|
36
|
+
* @returns at most the last 8 filename-safe characters.
|
|
37
|
+
*/
|
|
38
|
+
export function exportSessionIdSuffix(id: string): string {
|
|
39
|
+
return id.replace(/[^a-zA-Z0-9._-]/gu, '_').slice(-8)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the invocation's target session against the persisted headers.
|
|
44
|
+
* @param startup - the parsed startup flags.
|
|
45
|
+
* @param persistence - the persistence service; required for resume/latest.
|
|
46
|
+
* @param cwd - the working directory `--continue` filters by.
|
|
47
|
+
* @returns the target identity.
|
|
48
|
+
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveTarget(startup: TuiStartup, persistence: SessionPersistence | undefined, cwd: string): Promise<Target> {
|
|
51
|
+
if (startup.kind === 'fresh') return { sessionId: `session-${randomUUID()}`, resume: false, mode: startup.mode }
|
|
52
|
+
if (startup.kind === 'named') {
|
|
53
|
+
// The id must not exist yet: reject before any Agent composition when the
|
|
54
|
+
// backend can tell us (a live collision is still caught by the session
|
|
55
|
+
// store at create time).
|
|
56
|
+
if (persistence !== undefined) {
|
|
57
|
+
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
58
|
+
if (headers.some(header => header.id === startup.sessionId)) {
|
|
59
|
+
throw new Error(`session "${startup.sessionId}" already exists; use --resume to continue it`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { sessionId: startup.sessionId, resume: false, mode: startup.mode }
|
|
63
|
+
}
|
|
64
|
+
if (persistence === undefined) {
|
|
65
|
+
throw new Error('cannot resolve the requested session: session persistence is not configured')
|
|
66
|
+
}
|
|
67
|
+
const headers: readonly SessionHeader[] = (await persistence.list()).map(snapshot => snapshot.header)
|
|
68
|
+
if (startup.kind === 'resume') {
|
|
69
|
+
const matched = matchSessionId(headers, startup.sessionId)
|
|
70
|
+
// Subagent conversations are read-only everywhere else; the CLI must not
|
|
71
|
+
// be a back door into appending root turns to a child's durable log.
|
|
72
|
+
if (isSubagentSession(matched)) {
|
|
73
|
+
throw new Error('subagent conversations are read-only; resume a root session')
|
|
74
|
+
}
|
|
75
|
+
return { sessionId: matched.id, resume: true }
|
|
76
|
+
}
|
|
77
|
+
// --continue: the newest persisted ROOT session whose header pins this cwd.
|
|
78
|
+
const newest = newestRootForCwd(headers, cwd)
|
|
79
|
+
if (newest === undefined) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`)
|
|
80
|
+
return { sessionId: newest.id, resume: true }
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the runner's startup flags from the loose plugin config.
|
|
3
|
+
*
|
|
4
|
+
* The Cordis config schema is deliberately loose (`kind` is an unvalidated
|
|
5
|
+
* string, narrowed here), so this is the single place that maps a config row
|
|
6
|
+
* onto the `TuiStartup` the runner consumes — pure, and unit-testable without
|
|
7
|
+
* a plugin context.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-code/runner/startup-config
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseThemeName } from '../theme.ts'
|
|
13
|
+
import type { TuiStartup } from '../startup.ts'
|
|
14
|
+
|
|
15
|
+
/** The config row shape this resolver narrows; satisfied by the plugin schema. */
|
|
16
|
+
export interface StartupConfigRow {
|
|
17
|
+
/** How this invocation obtains its session identity. */
|
|
18
|
+
readonly kind: string
|
|
19
|
+
readonly sessionId?: string
|
|
20
|
+
readonly mode?: string
|
|
21
|
+
readonly theme?: string
|
|
22
|
+
readonly prompt?: string
|
|
23
|
+
readonly images?: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Narrow one config row to a startup.
|
|
28
|
+
*
|
|
29
|
+
* `resume`/`named` without a session id degrade to a fresh launch, an invalid
|
|
30
|
+
* theme string narrows to the dark default (and is therefore still present),
|
|
31
|
+
* and `mode` only survives on the kinds that can pre-compose a session.
|
|
32
|
+
* @param config - the validated plugin config.
|
|
33
|
+
* @returns the startup the runner will resolve a target from.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveStartupConfig(config: { readonly startup: StartupConfigRow }): TuiStartup {
|
|
36
|
+
const row = config.startup
|
|
37
|
+
// The CLI validated --theme at parse time; the loose config schema falls
|
|
38
|
+
// back to dark for anything unexpected.
|
|
39
|
+
const theme = row.theme === undefined ? undefined : parseThemeName(row.theme)
|
|
40
|
+
const input = {
|
|
41
|
+
...(theme === undefined ? {} : { theme }),
|
|
42
|
+
...(row.prompt === undefined ? {} : { prompt: row.prompt }),
|
|
43
|
+
...(row.images === undefined ? {} : { images: [...row.images] }),
|
|
44
|
+
}
|
|
45
|
+
if (row.kind === 'resume' && row.sessionId !== undefined) {
|
|
46
|
+
return { kind: 'resume', sessionId: row.sessionId, ...input }
|
|
47
|
+
}
|
|
48
|
+
if (row.kind === 'latest') return { kind: 'latest', ...input }
|
|
49
|
+
const mode = row.mode === undefined ? {} : { mode: row.mode }
|
|
50
|
+
if (row.kind === 'named' && row.sessionId !== undefined) {
|
|
51
|
+
return { kind: 'named', sessionId: row.sessionId, ...mode, ...input }
|
|
52
|
+
}
|
|
53
|
+
return { kind: 'fresh', ...mode, ...input }
|
|
54
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Submission vocabulary and the startup input gate for the terminal runner.
|
|
2
|
+
*
|
|
3
|
+
* Pure over the handles they are given: the queue mutations and the session
|
|
4
|
+
* guard are decisions the runner applies, and the gate only orders deliveries,
|
|
5
|
+
* so every branch is testable without composing an Agent.
|
|
6
|
+
*
|
|
7
|
+
* @module @deepseek-ai/dsh-code/submissions
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
|
|
12
|
+
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
|
13
|
+
import type { QueueMutation } from '../ui/ui-contract.ts'
|
|
14
|
+
|
|
15
|
+
/** One composer submission waiting behind the startup delivery. */
|
|
16
|
+
export interface QueuedSubmission {
|
|
17
|
+
readonly text: string
|
|
18
|
+
/** `steer` inserts into the running turn; `followup` waits for the next one. */
|
|
19
|
+
readonly mode: 'followup' | 'steer'
|
|
20
|
+
readonly images: readonly ContentBlock[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What one requested queue mutation did; the runner maps it to one notice. */
|
|
24
|
+
export type QueueMutationOutcome =
|
|
25
|
+
| 'removed'
|
|
26
|
+
| 'edited'
|
|
27
|
+
| 'steered'
|
|
28
|
+
| 'unavailable'
|
|
29
|
+
| 'empty'
|
|
30
|
+
| 'steerUnavailable'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace one queued message's text while keeping its attachments. A queue
|
|
34
|
+
* edit rewrites what the user typed, not what they attached: image and file
|
|
35
|
+
* blocks ride through in delivery order (text first, then attachments, the
|
|
36
|
+
* shape {@link deliverLine} submits). Dropping them here would silently strip
|
|
37
|
+
* an attachment the user already confirmed, so this is the edit's single
|
|
38
|
+
* definition and the panel's read-only marker only mirrors it.
|
|
39
|
+
*/
|
|
40
|
+
export function queueEditContent(content: readonly ContentBlock[], text: string): ContentBlock[] {
|
|
41
|
+
const attachments = content.filter(block => block.type !== 'text')
|
|
42
|
+
return [{ type: 'text', text }, ...attachments]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply one terminal queue mutation to the live inbox. The decision and the
|
|
47
|
+
* inbox change are pure over the supplied handles so every branch is testable
|
|
48
|
+
* without an agent; steering itself is injected because it wakes the driver
|
|
49
|
+
* rather than mutating the inbox. The durable inbox splices remain the UI's
|
|
50
|
+
* single source of truth — this helper never reports a state the inbox did not
|
|
51
|
+
* actually reach.
|
|
52
|
+
* @param inbox - the live agent inbox (pending lists plus its mutators).
|
|
53
|
+
* @param status - the agent's lifecycle status; steering needs `running`.
|
|
54
|
+
* @param messageId - identity of the queued message to mutate.
|
|
55
|
+
* @param action - the requested mutation.
|
|
56
|
+
* @param steer - submits the removed message as next-step steering.
|
|
57
|
+
* @returns the outcome the caller reports.
|
|
58
|
+
*/
|
|
59
|
+
export function applyQueueMutation(
|
|
60
|
+
inbox: Pick<Inbox, 'nextTurn' | 'append' | 'remove' | 'replace'>,
|
|
61
|
+
status: AgentStatus,
|
|
62
|
+
messageId: string,
|
|
63
|
+
action: QueueMutation,
|
|
64
|
+
steer: (message: UserMessage) => void,
|
|
65
|
+
): QueueMutationOutcome {
|
|
66
|
+
const id = MessageId(messageId)
|
|
67
|
+
const message = inbox.nextTurn.find(candidate => candidate.id === id)
|
|
68
|
+
if (message === undefined) return 'unavailable'
|
|
69
|
+
switch (action.kind) {
|
|
70
|
+
case 'remove':
|
|
71
|
+
return inbox.remove(id) ? 'removed' : 'unavailable'
|
|
72
|
+
case 'edit':
|
|
73
|
+
if (action.text.trim() === '') return 'empty'
|
|
74
|
+
inbox.replace(id, createUserMessage({
|
|
75
|
+
content: queueEditContent(message.content, action.text),
|
|
76
|
+
source: message.source,
|
|
77
|
+
}))
|
|
78
|
+
return 'edited'
|
|
79
|
+
case 'steer':
|
|
80
|
+
if (status !== 'running') return 'steerUnavailable'
|
|
81
|
+
// Steer promotes the message out of next-turn, so a failing submit must
|
|
82
|
+
// put it back: the row the user was looking at never just disappears.
|
|
83
|
+
if (!inbox.remove(id)) return 'unavailable'
|
|
84
|
+
try {
|
|
85
|
+
steer(message)
|
|
86
|
+
} catch (error: unknown) {
|
|
87
|
+
inbox.append('next-turn', message)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
return 'steered'
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cancel the active turn while keeping the next-turn queue, then wake the
|
|
96
|
+
* driver again so the preserved messages actually run. `cancel` clears
|
|
97
|
+
* pending work by default and never wakes the driver on its own, so the queue
|
|
98
|
+
* is captured first and re-submitted afterwards: a waking submission latches
|
|
99
|
+
* the wake while the aborted activity converges to idle, which is what turns
|
|
100
|
+
* "preserved" into "sent next" instead of "parked forever". Next-step
|
|
101
|
+
* steering is deliberately dropped — it belonged to the cancelled turn.
|
|
102
|
+
* @param agent - the live agent handle.
|
|
103
|
+
* @returns how many queued messages were preserved across the abort.
|
|
104
|
+
*/
|
|
105
|
+
export function cancelPreservingQueue(agent: Pick<Agent, 'inbox' | 'cancel' | 'followup'>): number {
|
|
106
|
+
const queued = [...agent.inbox.nextTurn]
|
|
107
|
+
agent.cancel({ kind: 'user' })
|
|
108
|
+
for (const message of queued) agent.followup(message)
|
|
109
|
+
return queued.length
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a tagged submission still belongs to the active session. Attachment
|
|
114
|
+
* prepares resolve on the microtask timeline, while a queued session switch
|
|
115
|
+
* remounts the app asynchronously — the composing instance's unmount cleanup
|
|
116
|
+
* runs too late to abort, so the delivery itself carries the composing
|
|
117
|
+
* session's full id and the runner drops it here when the world moved on.
|
|
118
|
+
* An untagged (synchronous) or pending-session ('') submission always passes.
|
|
119
|
+
*/
|
|
120
|
+
export function submissionBelongsToSession(origin: string | undefined, activeSessionId: string | undefined): boolean {
|
|
121
|
+
return origin === undefined || origin === '' || origin === activeSessionId
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Order-preserving gate for composer input while the startup prompt/images
|
|
126
|
+
* are still preparing. Anything submitted before the startup delivery settles
|
|
127
|
+
* queues and flushes afterwards in submit order, so the initial request can
|
|
128
|
+
* never be overtaken by typing that raced a slow image preparation. The flush
|
|
129
|
+
* also runs when the startup delivery fails: user input is never stranded.
|
|
130
|
+
*/
|
|
131
|
+
export class StartupInputGate {
|
|
132
|
+
private readonly queued: QueuedSubmission[] = []
|
|
133
|
+
private pending = false
|
|
134
|
+
constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
|
|
135
|
+
|
|
136
|
+
/** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
|
|
137
|
+
submit(submission: QueuedSubmission): void {
|
|
138
|
+
if (this.pending) this.queued.push(submission)
|
|
139
|
+
else this.deliver(submission)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run the startup delivery — the callback receives the direct-delivery sink
|
|
144
|
+
* for the startup prompt itself — then flush everything that queued behind
|
|
145
|
+
* it, in order, even when the callback rejects.
|
|
146
|
+
*/
|
|
147
|
+
async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
|
|
148
|
+
this.pending = true
|
|
149
|
+
try {
|
|
150
|
+
await startup(submission => this.deliver(submission))
|
|
151
|
+
} finally {
|
|
152
|
+
this.pending = false
|
|
153
|
+
const queued = this.queued.splice(0)
|
|
154
|
+
for (const submission of queued) this.deliver(submission)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live attachment to a subagent conversation: a transcript store seeded
|
|
3
|
+
* from the child's durable log and then fed by the process-local event and
|
|
4
|
+
* stream buses in real time. The parent's own store is never touched.
|
|
5
|
+
*
|
|
6
|
+
* @module @deepseek-ai/dsh-code/session/attach
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
10
|
+
import { createTranscriptStore, type TranscriptStore } from './store.ts'
|
|
11
|
+
|
|
12
|
+
/** The bus surfaces one attachment reads; the runner wires these to ctx. */
|
|
13
|
+
export interface SubagentAttachmentServices {
|
|
14
|
+
/** Seed: the child's full durable event log, oldest first. */
|
|
15
|
+
load(id: string, signal?: AbortSignal): Promise<readonly SessionEvent[]>
|
|
16
|
+
/** Durable events for exactly this child session, live. */
|
|
17
|
+
subscribeEvents(id: string, onEvent: (event: SessionEvent) => void): () => void
|
|
18
|
+
/** Token stream frames for exactly this child agent, live. */
|
|
19
|
+
subscribeStream(id: string, onFrame: (frame: Parameters<TranscriptStore['applyStreamFrame']>[0]) => void): () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A live child view handed to the App while attached. */
|
|
23
|
+
export interface SubagentAttachment {
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly label: string
|
|
26
|
+
readonly store: TranscriptStore
|
|
27
|
+
/** Whether the durable seed has landed (frames are held back until then). */
|
|
28
|
+
seeded(): boolean
|
|
29
|
+
/** Drop the bus subscriptions; the store freezes at its last state. */
|
|
30
|
+
dispose(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An inert empty store keeps the App's external-store hook unconditional. */
|
|
34
|
+
export const EMPTY_ATTACH_STORE: TranscriptStore = createTranscriptStore()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Attach to one subagent conversation.
|
|
38
|
+
*
|
|
39
|
+
* Ordering contract: durable events carry a per-session sequence, so the seed
|
|
40
|
+
* sets a watermark and live events at or below it are dropped as duplicates;
|
|
41
|
+
* stream frames are held until the seed lands so a frame whose settlement
|
|
42
|
+
* already arrived cannot resurrect a stale tail.
|
|
43
|
+
*/
|
|
44
|
+
export function createSubagentAttachment(services: SubagentAttachmentServices, id: string, label: string): SubagentAttachment {
|
|
45
|
+
const store = createTranscriptStore()
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
let watermark = -1
|
|
48
|
+
let seeded = false
|
|
49
|
+
const pending: SessionEvent[] = []
|
|
50
|
+
const applyDurable = (event: SessionEvent): void => {
|
|
51
|
+
const seq = Number(event.seq ?? -1)
|
|
52
|
+
if (seq >= 0 && seq <= watermark) return
|
|
53
|
+
if (seq >= 0) watermark = seq
|
|
54
|
+
store.apply(event)
|
|
55
|
+
}
|
|
56
|
+
const offEvents = services.subscribeEvents(id, event => {
|
|
57
|
+
if (!seeded) pending.push(event)
|
|
58
|
+
else applyDurable(event)
|
|
59
|
+
})
|
|
60
|
+
const offFrames = services.subscribeStream(id, frame => {
|
|
61
|
+
if (seeded) store.applyStreamFrame(frame)
|
|
62
|
+
})
|
|
63
|
+
services.load(id, controller.signal).then(events => {
|
|
64
|
+
if (controller.signal.aborted) return
|
|
65
|
+
for (const event of events) applyDurable(event)
|
|
66
|
+
seeded = true
|
|
67
|
+
for (const event of pending) applyDurable(event)
|
|
68
|
+
pending.length = 0
|
|
69
|
+
}, () => {
|
|
70
|
+
// The live buses still feed the view; a failed seed leaves the watermark
|
|
71
|
+
// unset so nothing durable is skipped, and the store shows what arrives.
|
|
72
|
+
seeded = true
|
|
73
|
+
for (const event of pending) applyDurable(event)
|
|
74
|
+
pending.length = 0
|
|
75
|
+
})
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
label,
|
|
79
|
+
store,
|
|
80
|
+
seeded: () => seeded,
|
|
81
|
+
dispose: () => {
|
|
82
|
+
controller.abort()
|
|
83
|
+
offEvents()
|
|
84
|
+
offFrames()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
import { realpathSync } from 'node:fs'
|
|
5
5
|
import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
6
|
-
import { t } from '
|
|
6
|
+
import { t } from '../i18n.ts'
|
|
7
7
|
|
|
8
8
|
export interface SessionRecord {
|
|
9
9
|
readonly header: SessionHeader
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* microtask notify chained SyncLane rerenders past React's nested update
|
|
19
19
|
* limit; a bare macrotask merge repaints a whole turn's bursts at once).
|
|
20
20
|
*
|
|
21
|
-
* @module @deepseek-ai/dsh-code/subagents
|
|
21
|
+
* @module @deepseek-ai/dsh-code/session/subagents
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
@@ -227,3 +227,14 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
227
227
|
},
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Root-log catalog facts a resumed session must replay into the subagent
|
|
233
|
+
* feed: constructor seeds never fire on the live bus, so without this the
|
|
234
|
+
* children of a resumed session vanish behind a restart. The empty-child
|
|
235
|
+
* placeholder row (childId '') is a placeholder, not a child, and stays out.
|
|
236
|
+
*/
|
|
237
|
+
export function subagentCatalogSeed(events: readonly SessionEvent[]): readonly SessionEvent<'subagent/catalog'>[] {
|
|
238
|
+
return events.filter((event): event is SessionEvent<'subagent/catalog'> =>
|
|
239
|
+
event.type === 'subagent/catalog' && event.data.childId !== '')
|
|
240
|
+
}
|
package/src/settings-file.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from 'node:crypto'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
20
21
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
21
|
-
import { dirname } from 'node:path'
|
|
22
|
+
import { basename, dirname } from 'node:path'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Run one file operation with a bounded retry: one initial try plus at
|
|
@@ -86,3 +87,20 @@ export function createUserSettingsPersistence(): UserSettingsPersistence {
|
|
|
86
87
|
},
|
|
87
88
|
}
|
|
88
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read one user-level settings file as a plain object. The callers all treat a
|
|
93
|
+
* missing file as "unset" and a corrupt one as "warn and fall back", so this
|
|
94
|
+
* helper owns the one distinction they share: readable JSON that is not an
|
|
95
|
+
* object is corruption, not an absent preference, and must not surface as a
|
|
96
|
+
* cryptic property access on `null`.
|
|
97
|
+
* @param path - absolute path of the settings file.
|
|
98
|
+
* @returns the parsed object; the caller narrows each field itself.
|
|
99
|
+
*/
|
|
100
|
+
export function readSettingsObject(path: string): Record<string, unknown> {
|
|
101
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
102
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
103
|
+
throw new Error(`${basename(path)} must contain a JSON object`)
|
|
104
|
+
}
|
|
105
|
+
return parsed as Record<string, unknown>
|
|
106
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module @deepseek-ai/dsh-tui/panel-accent
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { surfaceAccent, type RgbTriple } from '
|
|
14
|
+
import { surfaceAccent, type RgbTriple } from '../theme.ts'
|
|
15
15
|
|
|
16
16
|
/** First-seen slot per panel id; stable for the process lifetime. */
|
|
17
17
|
const panelSlots = new Map<string, number>()
|