dsh-code 1.0.4 → 1.0.6
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 +287 -286
- package/README.md +16 -13
- package/bin/deepseek.mjs +336 -11
- package/cordis.patch.yml +26 -18
- package/lib/index.mjs +1310 -439
- package/lib/types/app.d.ts +25 -6
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/git-workflow.d.ts +7 -2
- package/lib/types/history.d.ts +18 -11
- package/lib/types/index.d.ts +11 -2
- package/lib/types/presets.d.ts +4 -1
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/questions.d.ts +16 -12
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/projection.d.ts +47 -5
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/settings-file.d.ts +8 -0
- package/lib/types/store.d.ts +3 -0
- package/package.json +168 -159
- package/src/app.ts +480 -199
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/git-workflow.ts +29 -10
- package/src/history.ts +22 -13
- package/src/index.ts +1868 -1752
- package/src/internals.ts +61 -40
- package/src/permissions.ts +1 -1
- package/src/presets.ts +19 -6
- package/src/provider-settings.ts +12 -12
- package/src/questions.ts +57 -74
- package/src/render/animations.ts +606 -403
- package/src/render/export.ts +20 -10
- package/src/render/fuzzy.ts +83 -0
- package/src/render/projection.ts +1833 -1620
- package/src/session-directory.ts +94 -16
- package/src/settings-file.ts +38 -6
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
package/src/session-directory.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { basename, dirname, resolve } from 'node:path'
|
|
4
4
|
import { realpathSync } from 'node:fs'
|
|
5
|
-
import type
|
|
5
|
+
import { SESSION_FORMAT_VERSION, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
|
6
6
|
|
|
7
7
|
export interface SessionRecord {
|
|
8
8
|
readonly header: SessionHeader
|
|
@@ -170,9 +170,8 @@ export function mergeSessionTitles(
|
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
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
|
|
174
|
-
* validate
|
|
175
|
-
* any deletion touches the filesystem — a local copy of the pure upstream
|
|
173
|
+
* (`encodeSegment`: safe units literal, everything else `~XXXX`). Used to
|
|
174
|
+
* validate and derive session directories — a local copy of the pure upstream
|
|
176
175
|
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
177
176
|
*/
|
|
178
177
|
export function encodeSessionSegment(raw: string): string {
|
|
@@ -192,22 +191,101 @@ export function encodeSessionSegment(raw: string): string {
|
|
|
192
191
|
return out
|
|
193
192
|
}
|
|
194
193
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Encode a project cwd the way the JSONL backend groups sessions on disk
|
|
196
|
+
* (`projectKey`: separators collapse to one `-`, everything else mirrors
|
|
197
|
+
* `encodeSegment`, bounded to 251 chars). A local copy of the pure upstream
|
|
198
|
+
* contract, kept in sync with `session-persistence-jsonl/src/format.ts`.
|
|
199
|
+
*/
|
|
200
|
+
export function encodeProjectKey(cwd: string): string {
|
|
201
|
+
if (cwd.length === 0) throw new Error('cannot encode an empty project path')
|
|
202
|
+
let readable = ''
|
|
203
|
+
let separatorRun = false
|
|
204
|
+
for (let i = 0; i < cwd.length; i += 1) {
|
|
205
|
+
const code = cwd.charCodeAt(i)
|
|
206
|
+
const ch = String.fromCharCode(code)
|
|
207
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
208
|
+
if (!separatorRun) readable += '-'
|
|
209
|
+
separatorRun = true
|
|
210
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
211
|
+
readable += ch
|
|
212
|
+
separatorRun = false
|
|
213
|
+
} else {
|
|
214
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
|
|
215
|
+
separatorRun = false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const slug = readable.replace(/^-+/, '') || 'root'
|
|
219
|
+
return `--${slug.slice(0, 251)}--`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The project-level directory name the JSONL backend uses for a missing cwd. */
|
|
223
|
+
const NO_CWD_DIRECTORY = '_no-cwd'
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Derive one session's artifact directory under the JSONL backend root,
|
|
227
|
+
* mirroring the upstream `<root>/<projectKey(cwd)>/<encodeSegment(id)>/`
|
|
228
|
+
* layout (0.1.5 `sessionDir`/`projectDir`).
|
|
229
|
+
* @param root - the JSONL backend's configured session root.
|
|
230
|
+
* @param cwd - the session's pinned working directory, when the header has one.
|
|
231
|
+
* @param id - the session id.
|
|
232
|
+
* @returns the absolute session directory path.
|
|
233
|
+
*/
|
|
234
|
+
export function sessionDirectoryFor(root: string, cwd: string | undefined, id: string): string {
|
|
235
|
+
const project = cwd === undefined || cwd === '' ? NO_CWD_DIRECTORY : encodeProjectKey(cwd)
|
|
236
|
+
return resolve(root, project, encodeSessionSegment(id))
|
|
237
|
+
}
|
|
197
238
|
|
|
198
239
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
240
|
+
* The canonical session-log artifact filenames the JSONL backend may create:
|
|
241
|
+
* format v0 writes the bare `session.jsonl` name; v1+ write
|
|
242
|
+
* `session.vN.jsonl`, each generation optionally zstd-compressed. Multiple
|
|
243
|
+
* immutable generations may coexist in one session directory (0.1.5). The
|
|
244
|
+
* range follows the installed session package's `SESSION_FORMAT_VERSION`, so
|
|
245
|
+
* a future generation joins the enumeration with the dependency bump.
|
|
205
246
|
*/
|
|
206
|
-
export function
|
|
207
|
-
|
|
208
|
-
|
|
247
|
+
export function sessionArtifactNames(): readonly string[] {
|
|
248
|
+
const names: string[] = ['session.jsonl', 'session.jsonl.zstd']
|
|
249
|
+
for (let version = 1; version <= SESSION_FORMAT_VERSION; version += 1) {
|
|
250
|
+
names.push(`session.v${version}.jsonl`, `session.v${version}.jsonl.zstd`)
|
|
251
|
+
}
|
|
252
|
+
return names
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Canonical generation-log filenames as a lookup set (bare v0 or `vN`-suffixed, ± zstd). */
|
|
256
|
+
const SESSION_ARTIFACT_NAME_SET: ReadonlySet<string> = new Set(sessionArtifactNames())
|
|
257
|
+
|
|
258
|
+
/** True for one canonical session-log artifact filename the backend may own. */
|
|
259
|
+
export function isSessionArtifactName(name: string): boolean {
|
|
260
|
+
return SESSION_ARTIFACT_NAME_SET.has(name)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Guard a derived session directory before deletion (codex's scoped-path
|
|
265
|
+
* check, adapted to the JSONL layout): the directory's base name must be
|
|
266
|
+
* exactly `encodeSegment(id)` beneath its project grouping.
|
|
267
|
+
* @param dir - the derived session artifact directory.
|
|
268
|
+
* @param id - the session id the directory claims to belong to.
|
|
269
|
+
* @returns the guarded directory, or undefined when the layout is unexpected.
|
|
270
|
+
*/
|
|
271
|
+
export function sessionArtifactDirectory(dir: string, id: string): string | undefined {
|
|
209
272
|
if (basename(dir) !== encodeSessionSegment(id)) return undefined
|
|
210
|
-
return dir
|
|
273
|
+
if (basename(dirname(dir)) === NO_CWD_DIRECTORY) return dir
|
|
274
|
+
return /^--.*--$|^~/.test(basename(dirname(dir))) ? dir : undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The JSONL backend's configured session root, when the mounted backend
|
|
279
|
+
* exposes one. The upstream service contract dropped `locate()` in 0.1.5
|
|
280
|
+
* (artifact paths are backend-private; only refusal diagnostics carry them),
|
|
281
|
+
* so the TUI derives artifact paths from the backend's public plugin config.
|
|
282
|
+
* Backends without a JSONL-style config (or a foreign shape) yield undefined
|
|
283
|
+
* and callers degrade: mtime sorting falls back to createdAt and /delete
|
|
284
|
+
* refuses, exactly as before.
|
|
285
|
+
*/
|
|
286
|
+
export function jsonlSessionRoot(persistence: unknown): string | undefined {
|
|
287
|
+
const root = (persistence as { config?: { root?: unknown } } | undefined)?.config?.root
|
|
288
|
+
return typeof root === 'string' && root !== '' ? root : undefined
|
|
211
289
|
}
|
|
212
290
|
|
|
213
291
|
/**
|
package/src/settings-file.ts
CHANGED
|
@@ -16,9 +16,46 @@
|
|
|
16
16
|
* @module @deepseek-ai/dsh-code/settings-file
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
19
20
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
20
21
|
import { dirname } from 'node:path'
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Run one file operation with a bounded retry: one initial try plus at
|
|
25
|
+
* most `retries` more. Creating or replacing a file can fail transiently
|
|
26
|
+
* with EPERM/EACCES while an antivirus scanner or search indexer holds
|
|
27
|
+
* it — the standard graceful-fs remedy, not a workaround for a
|
|
28
|
+
* persistent permission problem. A save that still fails leaves its
|
|
29
|
+
* uniquely named temp file behind, so repeated crashed saves accumulate
|
|
30
|
+
* distinct leftovers rather than corrupting a shared one.
|
|
31
|
+
*/
|
|
32
|
+
async function withTransientRetry(operation: () => Promise<void>, retries = 5): Promise<void> {
|
|
33
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await operation()
|
|
36
|
+
return
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
39
|
+
if (attempt >= retries || (code !== 'EPERM' && code !== 'EACCES')) throw error
|
|
40
|
+
await new Promise(resolve => setTimeout(resolve, 30 * (attempt + 1)))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write one file atomically: create the parent directory, write to a
|
|
47
|
+
* uniquely named temp file, and rename it into place. A crash midway
|
|
48
|
+
* can never leave a half-written document behind. Unique temp names
|
|
49
|
+
* keep concurrent writers (two terminals, two chains in one process)
|
|
50
|
+
* from sharing one temp path.
|
|
51
|
+
*/
|
|
52
|
+
export async function writeFileAtomically(path: string, text: string): Promise<void> {
|
|
53
|
+
await mkdir(dirname(path), { recursive: true })
|
|
54
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
55
|
+
await withTransientRetry(() => writeFile(temp, text, 'utf8'))
|
|
56
|
+
await withTransientRetry(() => rename(temp, path))
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
/** The serialized persistence surface; flush() is handed to the quit sequence. */
|
|
23
60
|
export interface UserSettingsPersistence {
|
|
24
61
|
/**
|
|
@@ -39,12 +76,7 @@ export function createUserSettingsPersistence(): UserSettingsPersistence {
|
|
|
39
76
|
let chain: Promise<void> = Promise.resolve()
|
|
40
77
|
return {
|
|
41
78
|
save(path: string, text: string): Promise<void> {
|
|
42
|
-
const write = chain.then(
|
|
43
|
-
await mkdir(dirname(path), { recursive: true })
|
|
44
|
-
const temp = `${path}.tmp`
|
|
45
|
-
await writeFile(temp, text, 'utf8')
|
|
46
|
-
await rename(temp, path)
|
|
47
|
-
})
|
|
79
|
+
const write = chain.then(() => writeFileAtomically(path, text))
|
|
48
80
|
// A failed write must not break the chain for later saves.
|
|
49
81
|
chain = write.catch(() => {})
|
|
50
82
|
return write
|
package/src/skills.ts
CHANGED
|
@@ -74,11 +74,17 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
|
74
74
|
|
|
75
75
|
const reload = (): void => {
|
|
76
76
|
const target = agent
|
|
77
|
-
if (skills === undefined
|
|
78
|
-
Promise.resolve().then(() => skills.list(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
if (skills === undefined) return
|
|
78
|
+
Promise.resolve().then(() => skills.list(target === undefined
|
|
79
|
+
// No session exists yet (a bare launch keeps the agent unset until the
|
|
80
|
+
// first message): read the global skill layer for the working directory.
|
|
81
|
+
// The upstream contract makes `scope` optional — omitted reads the
|
|
82
|
+
// global layer alone — so the menu offers skills before a session does.
|
|
83
|
+
? { cwd: fallbackCwd }
|
|
84
|
+
: {
|
|
85
|
+
cwd: target.session.header.cwd ?? fallbackCwd,
|
|
86
|
+
scope: target,
|
|
87
|
+
})).then((summaries: readonly SkillSummary[]) => {
|
|
82
88
|
// A retarget landed while this catalog was loading: the rows belong to
|
|
83
89
|
// another agent's workspace and must never overwrite the current view.
|
|
84
90
|
if (agent !== target) return
|
|
@@ -97,19 +103,27 @@ export function watchSkills(ctx: Context, fallbackCwd?: string): SkillsWatch {
|
|
|
97
103
|
for (const listener of listeners) listener()
|
|
98
104
|
}).catch((cause: unknown) => {
|
|
99
105
|
if (agent !== target) return
|
|
100
|
-
// Discovery failure keeps the last good rows for the SAME
|
|
106
|
+
// Discovery failure keeps the last good rows for the SAME target (the
|
|
101
107
|
// next skills/change notification is the retry surface, mirroring the
|
|
102
|
-
// web directory);
|
|
108
|
+
// web directory); a target that never loaded starts from empty rows —
|
|
103
109
|
// stale rows from a previous workspace must not keep completing here.
|
|
110
|
+
// The rows array keeps its identity unless the failure text itself
|
|
111
|
+
// changed: a repeated identical error on the 0.1.5 event storm must not
|
|
112
|
+
// churn fresh identities into React's update chain.
|
|
113
|
+
const nextError = cause instanceof Error ? cause.message : String(cause)
|
|
104
114
|
if (loadedFor !== target) rows = []
|
|
105
|
-
|
|
106
|
-
error =
|
|
115
|
+
const errorChanged = nextError !== error
|
|
116
|
+
error = nextError
|
|
117
|
+
if (!errorChanged) return
|
|
107
118
|
for (const listener of listeners) listener()
|
|
108
119
|
})
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
if (skills !== undefined) {
|
|
112
123
|
ctx.on('skills/change', reload)
|
|
124
|
+
// Read the global layer immediately: a bare launch has no agent yet, and
|
|
125
|
+
// waiting for the first skills/change would leave the menu empty.
|
|
126
|
+
reload()
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
const view: SkillsWatch = {
|
package/src/store.ts
CHANGED
|
@@ -30,8 +30,16 @@
|
|
|
30
30
|
* @module @deepseek-ai/dsh-tui/store
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
|
33
34
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
applyAssistantStreamChunk,
|
|
37
|
+
clearAssistantStream,
|
|
38
|
+
createReplayAccumulator,
|
|
39
|
+
replayProjectEvent,
|
|
40
|
+
snapshotReplayView,
|
|
41
|
+
type TranscriptView,
|
|
42
|
+
} from './render/projection.ts'
|
|
35
43
|
|
|
36
44
|
/** Render frame budget: the notification cadence's upper bound. */
|
|
37
45
|
const NOTIFY_FRAME_MS = 16
|
|
@@ -44,6 +52,8 @@ export interface TranscriptStore {
|
|
|
44
52
|
subscribe(listener: () => void): () => void
|
|
45
53
|
/** Fold one session event; ignored events change nothing and notify nobody. */
|
|
46
54
|
apply(event: SessionEvent): void
|
|
55
|
+
/** Fold one live assistant-stream frame; frames without visible deltas stay silent. */
|
|
56
|
+
applyStreamFrame(frame: AssistantStreamFrame): void
|
|
47
57
|
/** Drop the folded view entirely (/clear): the next event starts a fresh one. */
|
|
48
58
|
reset(): void
|
|
49
59
|
}
|
|
@@ -66,6 +76,11 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
66
76
|
const listeners = new Set<() => void>()
|
|
67
77
|
let scheduled = false
|
|
68
78
|
let lastNotifyAt = 0
|
|
79
|
+
// Live attempt → `turn:step` key: chunk frames name only their attempt, so
|
|
80
|
+
// the start frame's turn/step anchor is remembered until the end frame
|
|
81
|
+
// retires the attempt. A replacement attempt (new start frame) overwrites
|
|
82
|
+
// the entry; committed settlements already cleared the tails it replaces.
|
|
83
|
+
const attemptKeys = new Map<string, string>()
|
|
69
84
|
const notify = (): void => {
|
|
70
85
|
if (scheduled) return
|
|
71
86
|
scheduled = true
|
|
@@ -101,8 +116,31 @@ export function createTranscriptStore(replay?: readonly SessionEvent[]): Transcr
|
|
|
101
116
|
dirty = true
|
|
102
117
|
notify()
|
|
103
118
|
},
|
|
119
|
+
applyStreamFrame(frame: AssistantStreamFrame): void {
|
|
120
|
+
if (frame.type === 'start') {
|
|
121
|
+
attemptKeys.set(frame.attemptId, `${frame.turn}:${frame.step}`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (frame.type === 'chunk') {
|
|
125
|
+
const key = attemptKeys.get(frame.attemptId)
|
|
126
|
+
if (key === undefined) return
|
|
127
|
+
if (!applyAssistantStreamChunk(acc, key, frame.time, frame.chunk)) return
|
|
128
|
+
dirty = true
|
|
129
|
+
notify()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
// End frame: committed settlements arrive as durable events before
|
|
133
|
+
// their end frame and already cleared the tails; an abandoned attempt
|
|
134
|
+
// has no settlement, so its partial tail is dropped here.
|
|
135
|
+
attemptKeys.delete(frame.attemptId)
|
|
136
|
+
if (frame.outcome.kind === 'abandoned' && clearAssistantStream(acc)) {
|
|
137
|
+
dirty = true
|
|
138
|
+
notify()
|
|
139
|
+
}
|
|
140
|
+
},
|
|
104
141
|
reset(): void {
|
|
105
142
|
acc = createReplayAccumulator()
|
|
143
|
+
attemptKeys.clear()
|
|
106
144
|
dirty = true
|
|
107
145
|
notify()
|
|
108
146
|
},
|
package/src/subagents.ts
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
25
|
+
// Type-only import merges the subagent package's SessionEventMap variant
|
|
26
|
+
// ('subagent/catalog') into the union this fold switches on.
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-subagent'
|
|
25
28
|
|
|
26
29
|
/** Hard row cap: overflow evicts the oldest settled row; a fully busy feed waits. */
|
|
27
30
|
export const MAX_SUBAGENT_ROWS = 8
|
|
@@ -107,8 +110,27 @@ export function foldSubagentRow(previous: SubagentRow | undefined, sessionId: st
|
|
|
107
110
|
return { ...base, state: 'running', activity: 'working…', updatedAt: event.time }
|
|
108
111
|
case 'user/message':
|
|
109
112
|
return { ...base, state: 'running', activity: 'prompted', updatedAt: event.time }
|
|
110
|
-
case 'assistant/
|
|
113
|
+
case 'assistant/attempt':
|
|
114
|
+
// Durable logs are settlement-only since session-log v2; an attempt
|
|
115
|
+
// landing without a surface message means the model is retrying or
|
|
116
|
+
// recovered from a stream error, so the child stays running.
|
|
111
117
|
return { ...base, state: 'running', activity: 'thinking…', updatedAt: event.time }
|
|
118
|
+
case 'subagent/catalog': {
|
|
119
|
+
// Parent-owned durable discovery fact (0.1.5): the catalog names the
|
|
120
|
+
// child's mode (one-shot vs continuable) and its authored label — the
|
|
121
|
+
// most semantic label the row can carry. It is a discovery fact, not a
|
|
122
|
+
// lifecycle signal: a fresh row starts idle, but a late delivery never
|
|
123
|
+
// regresses a row that already ran or finished. An unchanged fact keeps
|
|
124
|
+
// the row's identity (the no-op discipline of the default branch), so
|
|
125
|
+
// repeated deliveries never churn the snapshot array.
|
|
126
|
+
const mode = event.data.mode === 'continuable' ? 'continuable' : 'one-shot'
|
|
127
|
+
const label = event.data.label !== undefined && event.data.label.trim() !== '' ? bound(event.data.label) : undefined
|
|
128
|
+
const nextLabel = label === undefined ? base.label : label
|
|
129
|
+
const activity = label === undefined ? `catalog · ${mode}` : `catalog · ${mode} · ${label}`
|
|
130
|
+
const state = previous === undefined ? 'idle' : base.state
|
|
131
|
+
if (nextLabel === base.label && state === base.state && activity === base.activity) return base
|
|
132
|
+
return { ...base, state, label: nextLabel, activity, updatedAt: event.time }
|
|
133
|
+
}
|
|
112
134
|
case 'assistant/message':
|
|
113
135
|
return { ...base, state: 'idle', activity: messagePreview(data['message'] === undefined ? undefined : (data['message'] as { content?: unknown }).content), updatedAt: event.time }
|
|
114
136
|
case 'tool/call': {
|
|
@@ -169,11 +191,12 @@ export function createSubagentFeed(): SubagentFeedView & {
|
|
|
169
191
|
}
|
|
170
192
|
// A child this feed has not shown yet: the honest total grows even
|
|
171
193
|
// when every row is busy; admission then prefers evicting the OLDEST
|
|
172
|
-
// settled row
|
|
194
|
+
// settled row (idle or done — both are non-running) so a new running
|
|
195
|
+
// agent never waits on one that already settled.
|
|
173
196
|
const counted = !seen.has(sessionId)
|
|
174
197
|
if (counted) seen.add(sessionId)
|
|
175
198
|
if (rows.length >= MAX_SUBAGENT_ROWS) {
|
|
176
|
-
const evict = rows.findIndex(row => row.state
|
|
199
|
+
const evict = rows.findIndex(row => row.state !== 'running')
|
|
177
200
|
if (evict === -1) {
|
|
178
201
|
if (counted) notify()
|
|
179
202
|
return
|