dsh-code 0.7.0 → 0.9.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 +30 -7
- package/README.md +30 -7
- package/lib/index.mjs +3791 -853
- package/lib/types/app.d.ts +90 -1
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/history.d.ts +15 -4
- package/lib/types/index.d.ts +48 -0
- package/lib/types/kernel-panels.d.ts +65 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/permissions.d.ts +37 -0
- package/lib/types/presets.d.ts +2 -0
- package/lib/types/provider-settings.d.ts +144 -0
- package/lib/types/questions.d.ts +2 -0
- package/lib/types/render/animations.d.ts +8 -6
- package/lib/types/render/lines.d.ts +6 -0
- package/lib/types/render/markdown.d.ts +3 -3
- package/lib/types/render/projection.d.ts +97 -3
- package/lib/types/render/status.d.ts +26 -36
- package/lib/types/render/text.d.ts +14 -7
- package/lib/types/render/tool-detail.d.ts +3 -1
- package/lib/types/render/tool-preview.d.ts +14 -1
- package/lib/types/session-directory.d.ts +61 -2
- package/lib/types/store.d.ts +13 -2
- package/lib/types/subagents.d.ts +60 -0
- package/lib/types/version.d.ts +5 -0
- package/package.json +1 -1
- package/src/app.ts +1200 -219
- package/src/approval.ts +161 -126
- package/src/history.ts +20 -5
- package/src/index.ts +577 -167
- package/src/kernel-panels.ts +354 -37
- package/src/models.ts +26 -0
- package/src/permissions.ts +85 -0
- package/src/presets.ts +12 -0
- package/src/provider-settings.ts +520 -0
- package/src/questions.ts +15 -5
- package/src/render/animations.ts +32 -18
- package/src/render/lines.ts +236 -218
- package/src/render/markdown.ts +302 -4
- package/src/render/projection.ts +670 -11
- package/src/render/status.ts +68 -162
- package/src/render/text.ts +28 -9
- package/src/render/tool-detail.ts +81 -40
- package/src/render/tool-preview.ts +77 -34
- package/src/session-directory.ts +171 -10
- package/src/skills.ts +8 -4
- package/src/store.ts +26 -8
- package/src/subagents.ts +165 -0
- package/src/version.ts +16 -0
|
@@ -1,34 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
|
-
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
|
-
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
-
* transcript and the approval bar's command preview.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
|
+
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
|
+
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
+
* transcript and the approval bar's command preview. Arguments longer than
|
|
6
|
+
* {@link MAX_PARSE_CHARS} are never parsed: the preview is a display concern,
|
|
7
|
+
* and a synchronous `JSON.parse` plus string copies of an unbounded model
|
|
8
|
+
* payload must not run on the approval or projection paths.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Keys searched in declaration order when building a preview. */
|
|
14
|
+
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Raw arguments longer than this are skipped without parsing and fall back
|
|
18
|
+
* to the bounded raw preview. Well above any realistic command/path/query
|
|
19
|
+
* string while keeping the synchronous parse cost negligible.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_PARSE_CHARS = 4096
|
|
22
|
+
|
|
23
|
+
/** Bounded raw-arguments fallback shared by the skip-parse and parse-failure paths. */
|
|
24
|
+
function boundedRawPreview(args: string): string {
|
|
25
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve one bounded preview for raw tool arguments.
|
|
30
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
31
|
+
* @param toolName - the tool the arguments belong to (fallback label).
|
|
32
|
+
* @returns the preview line; empty when nothing useful resolves.
|
|
33
|
+
*/
|
|
34
|
+
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
35
|
+
if (args === '') return toolName
|
|
36
|
+
if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args)
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(args)
|
|
39
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
40
|
+
const record = parsed as Record<string, unknown>
|
|
41
|
+
for (const key of PREVIEW_KEYS) {
|
|
42
|
+
const value = record[key]
|
|
43
|
+
if (typeof value === 'string' && value !== '') return value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
48
|
+
}
|
|
49
|
+
return boundedRawPreview(args)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Visible budget for one delegation prompt row on the tool card. */
|
|
53
|
+
const MAX_PROMPT_CHARS = 160
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bounded prompt preview for delegation-style tools (`subagent`): the
|
|
57
|
+
* `prompt` argument rendered as the card's second row, so the transcript
|
|
58
|
+
* shows what the child agent was asked — not just its description label —
|
|
59
|
+
* while it runs (Codex's SpawnAgent card preview). Anything else returns ''.
|
|
60
|
+
* @param toolName - the tool the arguments belong to.
|
|
61
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
62
|
+
* @returns the one-line prompt preview, or '' when none applies.
|
|
63
|
+
*/
|
|
64
|
+
export function toolPromptPreview(toolName: string, args: string): string {
|
|
65
|
+
if (toolName !== 'subagent' || args === '' || args.length > MAX_PARSE_CHARS) return ''
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(args)
|
|
68
|
+
if (parsed === null || typeof parsed !== 'object') return ''
|
|
69
|
+
const prompt = (parsed as Record<string, unknown>)['prompt']
|
|
70
|
+
if (typeof prompt !== 'string' || prompt === '') return ''
|
|
71
|
+
const flat = prompt.replace(/\s+/gu, ' ').trim()
|
|
72
|
+
return flat.length > MAX_PROMPT_CHARS ? `${flat.slice(0, MAX_PROMPT_CHARS - 1)}…` : flat
|
|
73
|
+
} catch {
|
|
74
|
+
// Malformed arguments degrade to no prompt row, never a thrown parse.
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/session-directory.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Lightweight session-directory projection for the /resume picker. */
|
|
2
2
|
|
|
3
|
-
import { basename, resolve } from 'node:path'
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
4
|
+
import { realpathSync } from 'node:fs'
|
|
4
5
|
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
5
6
|
|
|
6
7
|
export interface SessionRecord {
|
|
@@ -42,6 +43,8 @@ export interface SessionDirectoryOptions {
|
|
|
42
43
|
export interface SessionRow {
|
|
43
44
|
readonly id: string
|
|
44
45
|
readonly createdAt: number
|
|
46
|
+
/** Last-activity timestamp: artifact mtime when known, else createdAt. */
|
|
47
|
+
readonly updatedAt: number
|
|
45
48
|
readonly cwd: string
|
|
46
49
|
readonly workspace: string
|
|
47
50
|
readonly parent?: string
|
|
@@ -53,24 +56,88 @@ export interface SessionRow {
|
|
|
53
56
|
readonly title?: string
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
|
|
59
|
+
/** Case-insensitive filesystems (Windows, macOS) compare paths by lowercased form. */
|
|
60
|
+
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin'
|
|
61
|
+
|
|
62
|
+
/** True when the header describes a subagent conversation (durable lineage). */
|
|
63
|
+
export function isSubagentSession(header: SessionHeader): boolean {
|
|
64
|
+
return header.origin === 'subagent' || header.parentSession !== undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function comparablePath(value: string): string {
|
|
68
|
+
const resolved = resolve(value)
|
|
69
|
+
// Codex's paths_match_after_normalization pattern: canonicalize through the
|
|
70
|
+
// filesystem when the path exists (resolving symlinks, subst drives, and
|
|
71
|
+
// junctions that plain `resolve` keeps distinct), falling back to lexical
|
|
72
|
+
// resolution when the directory no longer does.
|
|
73
|
+
const fold = (path: string): string => CASE_INSENSITIVE_FS ? path.toLowerCase() : path
|
|
74
|
+
try {
|
|
75
|
+
return fold(realpathSync(resolved))
|
|
76
|
+
} catch {
|
|
77
|
+
return fold(resolved)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Platform-consistent path equality for session cwd comparisons. */
|
|
82
|
+
export function samePath(left: string | undefined, right: string): boolean {
|
|
57
83
|
if (left === undefined) return false
|
|
58
|
-
return
|
|
84
|
+
return comparablePath(left) === comparablePath(right)
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Unique header match by exact id or unique id prefix (root and subagent
|
|
89
|
+
* headers alike); the caller applies any lineage gate.
|
|
90
|
+
* @param headers - the persisted headers.
|
|
91
|
+
* @param wanted - the id or id prefix.
|
|
92
|
+
* @returns the uniquely matched header.
|
|
93
|
+
* @throws when nothing matches or the prefix is ambiguous.
|
|
94
|
+
*/
|
|
95
|
+
export function matchSessionId(headers: readonly SessionHeader[], wanted: string): SessionHeader {
|
|
96
|
+
const exact = headers.filter(header => header.id === wanted)
|
|
97
|
+
const matches = exact.length > 0 ? exact : headers.filter(header => header.id.startsWith(wanted))
|
|
98
|
+
if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`)
|
|
99
|
+
if (matches.length > 1) {
|
|
100
|
+
throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`)
|
|
101
|
+
}
|
|
102
|
+
return matches[0]!
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The newest persisted ROOT session pinned to this cwd, or undefined. */
|
|
106
|
+
export function newestRootForCwd(headers: readonly SessionHeader[], cwd: string): SessionHeader | undefined {
|
|
107
|
+
const local = headers
|
|
108
|
+
.filter(header => !isSubagentSession(header) && samePath(header.cwd, cwd))
|
|
109
|
+
.sort((left, right) => right.createdAt - left.createdAt)
|
|
110
|
+
return local[0]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Filter/sort header-only records. No session log is loaded here. Sorting is
|
|
115
|
+
* by LAST ACTIVITY (`updated` — artifact mtime when the caller resolved one,
|
|
116
|
+
* else createdAt), matching the codex resume picker's default UpdatedAt
|
|
117
|
+
* ordering: a session you kept talking in outranks one created later but idle.
|
|
118
|
+
* @param records - the header-only records.
|
|
119
|
+
* @param options - filter/sort options.
|
|
120
|
+
* @param updated - per-session last-activity timestamps, when resolved.
|
|
121
|
+
*/
|
|
122
|
+
export function projectSessionRows(
|
|
123
|
+
records: readonly SessionRecord[],
|
|
124
|
+
options: SessionDirectoryOptions,
|
|
125
|
+
updated?: ReadonlyMap<string, number>,
|
|
126
|
+
): SessionRow[] {
|
|
63
127
|
const needle = options.query.trim().toLowerCase()
|
|
64
128
|
return records
|
|
65
|
-
.filter(record => options.sessions === 'all'
|
|
66
|
-
|| (record.header.parentSession === undefined && record.header.origin !== 'subagent'))
|
|
129
|
+
.filter(record => options.sessions === 'all' || !isSubagentSession(record.header))
|
|
67
130
|
.filter(record => options.cwd === 'all' || samePath(record.header.cwd, options.currentCwd))
|
|
68
131
|
.map(record => {
|
|
69
132
|
const cwd = record.header.cwd ?? ''
|
|
70
|
-
const subagent = record.header
|
|
133
|
+
const subagent = isSubagentSession(record.header)
|
|
134
|
+
const activity = updated?.get(record.header.id)
|
|
71
135
|
return {
|
|
72
136
|
id: record.header.id,
|
|
73
137
|
createdAt: record.header.createdAt,
|
|
138
|
+
updatedAt: activity === undefined || !Number.isFinite(activity) || activity < record.header.createdAt
|
|
139
|
+
? record.header.createdAt
|
|
140
|
+
: activity,
|
|
74
141
|
cwd,
|
|
75
142
|
workspace: cwd === '' ? '(no workspace)' : basename(cwd),
|
|
76
143
|
parent: record.header.parentSession,
|
|
@@ -83,8 +150,8 @@ export function projectSessionRows(records: readonly SessionRecord[], options: S
|
|
|
83
150
|
})
|
|
84
151
|
.filter(row => needle === '' || `${row.id} ${row.cwd} ${row.workspace} ${row.preset}`.toLowerCase().includes(needle))
|
|
85
152
|
.sort((left, right) => options.sort === 'newest'
|
|
86
|
-
? right.createdAt - left.createdAt
|
|
87
|
-
: left.createdAt - right.createdAt)
|
|
153
|
+
? right.updatedAt - left.updatedAt || right.createdAt - left.createdAt
|
|
154
|
+
: left.updatedAt - right.updatedAt || left.createdAt - right.createdAt)
|
|
88
155
|
}
|
|
89
156
|
|
|
90
157
|
/** Merge page-local title observations without disturbing directory order. */
|
|
@@ -100,3 +167,97 @@ export function mergeSessionTitles(
|
|
|
100
167
|
}
|
|
101
168
|
return rows.map(row => titles.has(row.id) ? { ...row, title: titles.get(row.id) } : row)
|
|
102
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Encode a session id the way the JSONL backend does for its on-disk layout
|
|
173
|
+
* (`encodeSegment`: safe units literal, everything else `~XXXX`). Used ONLY to
|
|
174
|
+
* validate that a `locate()` path really is this session's directory before
|
|
175
|
+
* any deletion touches the filesystem — a local copy of the pure upstream
|
|
176
|
+
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
177
|
+
*/
|
|
178
|
+
export function encodeSessionSegment(raw: string): string {
|
|
179
|
+
if (raw.length === 0) throw new Error('cannot encode an empty path segment')
|
|
180
|
+
if (raw === '.') return '~002E'
|
|
181
|
+
if (raw === '..') return '~002E~002E'
|
|
182
|
+
let out = ''
|
|
183
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
184
|
+
const code = raw.charCodeAt(i)
|
|
185
|
+
const ch = String.fromCharCode(code)
|
|
186
|
+
if (ch !== '~' && /^[A-Za-z0-9._-]$/u.test(ch)) {
|
|
187
|
+
out += ch
|
|
188
|
+
} else {
|
|
189
|
+
out += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The session-log artifact names the JSONL backend may create. */
|
|
196
|
+
export const SESSION_ARTIFACT_NAMES: readonly string[] = ['session.jsonl', 'session.jsonl.zstd']
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Guard one `locate()` artifact path before deletion (codex's scoped-path
|
|
200
|
+
* check, adapted to the JSONL layout): the file must be a `session.jsonl`
|
|
201
|
+
* artifact sitting in the directory named exactly `encodeSegment(id)`.
|
|
202
|
+
* @param artifact - the path the persistence backend located.
|
|
203
|
+
* @param id - the session id the artifact claims to belong to.
|
|
204
|
+
* @returns the owning session directory, or undefined when the layout is unexpected.
|
|
205
|
+
*/
|
|
206
|
+
export function sessionArtifactDirectory(artifact: string, id: string): string | undefined {
|
|
207
|
+
if (basename(artifact) !== 'session.jsonl' && basename(artifact) !== 'session.jsonl.zstd') return undefined
|
|
208
|
+
const dir = dirname(artifact)
|
|
209
|
+
if (basename(dir) !== encodeSessionSegment(id)) return undefined
|
|
210
|
+
return dir
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Collect one session's deletion subtree: the id plus every record whose
|
|
215
|
+
* parent chain leads to it (codex deletes subagent threads with their root).
|
|
216
|
+
* @param records - the full directory listing.
|
|
217
|
+
* @param id - the root session id to delete.
|
|
218
|
+
* @returns the ids to delete, root first.
|
|
219
|
+
*/
|
|
220
|
+
export function collectDeletionSubtree(records: readonly SessionRecord[], id: string): string[] {
|
|
221
|
+
const parentOf = new Map<string, string | undefined>()
|
|
222
|
+
for (const record of records) parentOf.set(record.header.id, record.header.parentSession)
|
|
223
|
+
const doomed = new Set<string>([id])
|
|
224
|
+
// Iterate to a fixed point: children may be listed before their parents.
|
|
225
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
226
|
+
for (const candidate of parentOf.keys()) {
|
|
227
|
+
if (doomed.has(candidate)) continue
|
|
228
|
+
let ancestor = parentOf.get(candidate)
|
|
229
|
+
let depth = 0
|
|
230
|
+
while (ancestor !== undefined && depth < 64) {
|
|
231
|
+
if (doomed.has(ancestor)) {
|
|
232
|
+
doomed.add(candidate)
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
ancestor = parentOf.get(ancestor)
|
|
236
|
+
depth += 1
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [...doomed]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Codex-style relative time for session rows ("now", "5m ago", "3h ago",
|
|
245
|
+
* "2d ago"; older than a week falls back to the local date).
|
|
246
|
+
* @param timestamp - epoch milliseconds of the last activity.
|
|
247
|
+
* @param now - the pinned reference clock (one value per list render).
|
|
248
|
+
*/
|
|
249
|
+
export function formatRelativeTime(timestamp: number, now: number): string {
|
|
250
|
+
const seconds = Math.round((now - timestamp) / 1000)
|
|
251
|
+
if (seconds < 0) return 'now'
|
|
252
|
+
if (seconds < 60) return 'now'
|
|
253
|
+
const minutes = Math.round(seconds / 60)
|
|
254
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
255
|
+
const hours = Math.round(minutes / 60)
|
|
256
|
+
if (hours < 24) return `${hours}h ago`
|
|
257
|
+
const days = Math.round(hours / 24)
|
|
258
|
+
if (days < 7) return `${days}d ago`
|
|
259
|
+
const date = new Date(timestamp)
|
|
260
|
+
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
261
|
+
const day = `${date.getDate()}`.padStart(2, '0')
|
|
262
|
+
return `${date.getFullYear()}-${month}-${day}`
|
|
263
|
+
}
|
package/src/skills.ts
CHANGED
|
@@ -69,12 +69,15 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
69
69
|
const listeners = new Set<() => void>()
|
|
70
70
|
|
|
71
71
|
const reload = (): void => {
|
|
72
|
-
const
|
|
73
|
-
if (skills === undefined ||
|
|
72
|
+
const target = agent
|
|
73
|
+
if (skills === undefined || target === undefined) return
|
|
74
74
|
Promise.resolve().then(() => skills.list({
|
|
75
|
-
cwd:
|
|
76
|
-
scope:
|
|
75
|
+
cwd: target.session.header.cwd,
|
|
76
|
+
scope: target,
|
|
77
77
|
})).then((summaries: readonly SkillSummary[]) => {
|
|
78
|
+
// A retarget landed while this catalog was loading: the rows belong to
|
|
79
|
+
// another agent's workspace and must never overwrite the current view.
|
|
80
|
+
if (agent !== target) return
|
|
78
81
|
const next = toRows(summaries)
|
|
79
82
|
const unchanged = next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)
|
|
80
83
|
rows = next
|
|
@@ -83,6 +86,7 @@ export function watchSkills(ctx: Context): SkillsWatch {
|
|
|
83
86
|
if (unchanged && !recovered) return
|
|
84
87
|
for (const listener of listeners) listener()
|
|
85
88
|
}).catch((cause: unknown) => {
|
|
89
|
+
if (agent !== target) return
|
|
86
90
|
// Discovery failure keeps the last good rows; the next skills/change
|
|
87
91
|
// notification is the retry surface (mirrors the web directory).
|
|
88
92
|
rows = [...rows]
|
package/src/store.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Observable transcript store: folds session events into the projection view
|
|
3
3
|
* and notifies subscribers. The renderer subscribes through
|
|
4
|
-
* `useSyncExternalStore`; the runner owns event feeding.
|
|
5
|
-
*
|
|
4
|
+
* `useSyncExternalStore`; the runner owns event feeding.
|
|
5
|
+
*
|
|
6
|
+
* Notification coalescing: the fold stays synchronous — `getView()` always
|
|
7
|
+
* returns the latest state the moment `apply` returns — but listener
|
|
8
|
+
* notification is scheduled on a microtask and deduplicated, so N events
|
|
9
|
+
* delivered inside one synchronous drain (the zai/GLM adapter drains its
|
|
10
|
+
* token buffer in sub-millisecond bursts) produce ONE React re-render.
|
|
11
|
+
* Synchronous per-event notification instead cascades one
|
|
12
|
+
* `useSyncExternalStore` force-update per token inside a single flush; the
|
|
13
|
+
* reconciler counts those as nested passive updates and floods React's
|
|
14
|
+
* "Maximum update depth exceeded" warning past 50 events, besides rendering
|
|
15
|
+
* the whole live tree once per token. A microtask keeps latency within the
|
|
16
|
+
* same macrotask, before Ink's throttled paint.
|
|
6
17
|
*
|
|
7
18
|
* @module @deepseek-ai/dsh-tui/store
|
|
8
19
|
*/
|
|
@@ -35,6 +46,17 @@ export interface TranscriptStore {
|
|
|
35
46
|
export function createTranscriptStore(replay?: readonly SessionEvent[]): TranscriptStore {
|
|
36
47
|
let view = replay === undefined ? createTranscriptView() : projectEvents(replay)
|
|
37
48
|
const listeners = new Set<() => void>()
|
|
49
|
+
let scheduled = false
|
|
50
|
+
const notify = (): void => {
|
|
51
|
+
if (scheduled) return
|
|
52
|
+
scheduled = true
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
scheduled = false
|
|
55
|
+
for (const listener of listeners) {
|
|
56
|
+
listener()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
38
60
|
return {
|
|
39
61
|
getView: () => view,
|
|
40
62
|
subscribe(listener: () => void): () => void {
|
|
@@ -47,15 +69,11 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
47
69
|
const next = projectEvent(view, event)
|
|
48
70
|
if (next === view) return
|
|
49
71
|
view = next
|
|
50
|
-
|
|
51
|
-
listener()
|
|
52
|
-
}
|
|
72
|
+
notify()
|
|
53
73
|
},
|
|
54
74
|
reset(): void {
|
|
55
75
|
view = createTranscriptView()
|
|
56
|
-
|
|
57
|
-
listener()
|
|
58
|
-
}
|
|
76
|
+
notify()
|
|
59
77
|
},
|
|
60
78
|
}
|
|
61
79
|
}
|
package/src/subagents.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live subagent activity feed: a bounded, display-only projection of CHILD
|
|
3
|
+
* session events. The transcript store folds only the root session (the
|
|
4
|
+
* durable truth this TUI renders); subagent conversations are their own
|
|
5
|
+
* sessions, and before this module their events were dropped entirely —
|
|
6
|
+
* a running subagent was invisible until its parent tool call settled.
|
|
7
|
+
*
|
|
8
|
+
* This is NOT a second transcript: each child folds to ONE row (label,
|
|
9
|
+
* running state, bounded last-activity text), capped at
|
|
10
|
+
* {@link MAX_SUBAGENT_ROWS}. Rows are advisory display state, rebuilt from
|
|
11
|
+
* live events; nothing here persists or replays. Notification is coalesced
|
|
12
|
+
* to one microtask per delivery burst, mirroring the transcript store's
|
|
13
|
+
* contract (per-token synchronous notify once cascaded past React's nested
|
|
14
|
+
* update limit on the GLM thinking path).
|
|
15
|
+
*
|
|
16
|
+
* @module @deepseek-ai/dsh-code/subagents
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
20
|
+
|
|
21
|
+
/** Hard row cap: a fan-out larger than this stays summarized by the head. */
|
|
22
|
+
export const MAX_SUBAGENT_ROWS = 8
|
|
23
|
+
|
|
24
|
+
/** Bounded last-activity text (plain characters, display-sliced later). */
|
|
25
|
+
const MAX_ACTIVITY_CHARS = 80
|
|
26
|
+
|
|
27
|
+
/** One live subagent row in the feed. */
|
|
28
|
+
export interface SubagentRow {
|
|
29
|
+
/** Child session id. */
|
|
30
|
+
readonly id: string
|
|
31
|
+
/** Display label (session title when observed, else a short id form). */
|
|
32
|
+
readonly label: string
|
|
33
|
+
/** Coarse lifecycle state folded from the child's events. */
|
|
34
|
+
readonly state: 'running' | 'idle' | 'done'
|
|
35
|
+
/** Bounded last-activity text for the status line. */
|
|
36
|
+
readonly activity: string
|
|
37
|
+
/** Last fold time (ms, event clock) — newest-first ordering key. */
|
|
38
|
+
readonly updatedAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The read-only snapshot surface the renderer subscribes to. */
|
|
42
|
+
export interface SubagentFeedView {
|
|
43
|
+
/** Subscribe to feed changes; returns the unsubscribe function. */
|
|
44
|
+
subscribe(listener: () => void): () => void
|
|
45
|
+
/** Read the current rows (identity-stable between changes). */
|
|
46
|
+
getSnapshot(): readonly SubagentRow[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Single-line bounded preview of an assembled message's text content. */
|
|
50
|
+
function messagePreview(content: unknown): string {
|
|
51
|
+
if (!Array.isArray(content)) return 'replied'
|
|
52
|
+
const texts: string[] = []
|
|
53
|
+
for (const block of content) {
|
|
54
|
+
if (texts.join(' ').length >= MAX_ACTIVITY_CHARS) break
|
|
55
|
+
if (typeof block === 'object' && block !== null) {
|
|
56
|
+
const { type, text } = block as Record<string, unknown>
|
|
57
|
+
if (type === 'text' && typeof text === 'string' && text !== '') texts.push(text)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const joined = texts.join(' ').replace(/\s+/gu, ' ').trim()
|
|
61
|
+
return joined === '' ? 'replied' : bound(joined)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Bound one activity string to the display budget. */
|
|
65
|
+
function bound(text: string): string {
|
|
66
|
+
const flat = text.replace(/\s+/gu, ' ').trim()
|
|
67
|
+
return flat.length > MAX_ACTIVITY_CHARS ? `${flat.slice(0, MAX_ACTIVITY_CHARS - 1)}…` : flat
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fold one child-session event into its feed row (pure).
|
|
72
|
+
* Unknown event kinds leave the row untouched.
|
|
73
|
+
* @param previous - the row's current state, when any.
|
|
74
|
+
* @param sessionId - the child session id.
|
|
75
|
+
* @param event - the child session event.
|
|
76
|
+
* @returns the next row state.
|
|
77
|
+
*/
|
|
78
|
+
export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: string, event: SessionEvent): SubagentRow {
|
|
79
|
+
const base: SubagentRow = previous ?? {
|
|
80
|
+
id: sessionId,
|
|
81
|
+
label: `agent ${sessionId.slice(-6)}`,
|
|
82
|
+
state: 'running',
|
|
83
|
+
activity: 'starting…',
|
|
84
|
+
updatedAt: event.time,
|
|
85
|
+
}
|
|
86
|
+
const data = event.data as Record<string, unknown>
|
|
87
|
+
switch (event.type) {
|
|
88
|
+
case 'session/title': {
|
|
89
|
+
const title = data['title']
|
|
90
|
+
const text = typeof title === 'string' && title.trim() !== '' ? title : undefined
|
|
91
|
+
return text === undefined || text === base.label ? base : { ...base, label: bound(text), updatedAt: event.time }
|
|
92
|
+
}
|
|
93
|
+
case 'request/header':
|
|
94
|
+
return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
|
|
95
|
+
case 'user/message':
|
|
96
|
+
return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
|
|
97
|
+
case 'assistant/chunk':
|
|
98
|
+
return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
|
|
99
|
+
case 'assistant/message':
|
|
100
|
+
return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
|
|
101
|
+
case 'tool/call': {
|
|
102
|
+
const name = typeof data['name'] === 'string' ? data['name'] : 'tool'
|
|
103
|
+
return { ...base, state: 'running', activity: `tool ${name}`, updatedAt: event.time }
|
|
104
|
+
}
|
|
105
|
+
case 'tool/result':
|
|
106
|
+
return { ...base, state: 'running', activity: 'tool done', updatedAt: event.time }
|
|
107
|
+
case 'turn/start':
|
|
108
|
+
return { ...base, state: 'running', activity: base.activity === 'starting…' ? 'working…' : base.activity, updatedAt: event.time }
|
|
109
|
+
case 'turn/end':
|
|
110
|
+
return { ...base, state: 'done', activity: 'finished', updatedAt: event.time }
|
|
111
|
+
default:
|
|
112
|
+
// Unknown kinds leave the row untouched (identity-stable: a no-op
|
|
113
|
+
// fold must not churn the snapshot array).
|
|
114
|
+
return base
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create one subagent feed. `apply` folds a child event (the caller gates
|
|
120
|
+
* which sessions are children); `reset` clears on a session switch. Row
|
|
121
|
+
* order is first-seen; the snapshot array is frozen and only replaced when
|
|
122
|
+
* a row actually changed.
|
|
123
|
+
* @returns the mutable feed handle plus its `SubagentFeedView`.
|
|
124
|
+
*/
|
|
125
|
+
export function createSubagentFeed(): SubagentFeedView & {
|
|
126
|
+
apply(sessionId: string, event: SessionEvent): void
|
|
127
|
+
reset(): void
|
|
128
|
+
} {
|
|
129
|
+
let rows: readonly SubagentRow[] = Object.freeze([])
|
|
130
|
+
const listeners = new Set<() => void>()
|
|
131
|
+
let scheduled = false
|
|
132
|
+
const notify = (): void => {
|
|
133
|
+
if (scheduled) return
|
|
134
|
+
scheduled = true
|
|
135
|
+
queueMicrotask(() => {
|
|
136
|
+
scheduled = false
|
|
137
|
+
for (const listener of listeners) listener()
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
apply(sessionId: string, event: SessionEvent): void {
|
|
142
|
+
const index = rows.findIndex(row => row.id === sessionId)
|
|
143
|
+
const previous = index === -1 ? undefined : rows[index]
|
|
144
|
+
const next = foldSubagentRow(previous, sessionId, event)
|
|
145
|
+
if (next === previous) return
|
|
146
|
+
if (index === -1 && rows.length >= MAX_SUBAGENT_ROWS) return
|
|
147
|
+
rows = Object.freeze(index === -1 ? [...rows, next] : rows.map((row, at) => at === index ? next : row))
|
|
148
|
+
notify()
|
|
149
|
+
},
|
|
150
|
+
reset(): void {
|
|
151
|
+
if (rows.length === 0) return
|
|
152
|
+
rows = Object.freeze([])
|
|
153
|
+
notify()
|
|
154
|
+
},
|
|
155
|
+
subscribe(listener: () => void): () => void {
|
|
156
|
+
listeners.add(listener)
|
|
157
|
+
return () => {
|
|
158
|
+
listeners.delete(listener)
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
getSnapshot(): readonly SubagentRow[] {
|
|
162
|
+
return rows
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Installed dsh-code version exposed by the terminal header. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
/** Read one package manifest version without making terminal startup depend on it. */
|
|
6
|
+
export function readPackageVersion(manifest = new URL('../package.json', import.meta.url)): string {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown }
|
|
9
|
+
return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : '0.0.0'
|
|
10
|
+
} catch {
|
|
11
|
+
return '0.0.0'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Version of the installed dsh-code package. */
|
|
16
|
+
export const DSH_CODE_VERSION = readPackageVersion()
|