@banbolee/dsh-fish-shell 0.5.0 → 0.7.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.md +10 -3
- package/README.zh.md +63 -0
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +45 -43
- package/persistent.js +238 -45
- package/policy.js +73 -14
- package/terminal-fish.js +164 -36
- package/tool.js +258 -60
package/persistent.js
CHANGED
|
@@ -56,9 +56,71 @@
|
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
58
|
import { randomUUID } from 'node:crypto'
|
|
59
|
+
import { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
|
59
60
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
60
61
|
import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The live owner of one persistent shell: the harness's runtime `Agent`
|
|
65
|
+
* (identity, the agent-scoped context, and the session whose header carries the
|
|
66
|
+
* cwd). Test doubles construct a minimal object and assert it once.
|
|
67
|
+
* @typedef {import('@deepseek-ai/dsh-agent').Agent} PersistentOwner
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The unique start/end marker pair one wrapped command's output is delimited by
|
|
72
|
+
* (`markers()`).
|
|
73
|
+
* @typedef {{ start: string, end: string }} CommandMarkers
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One retained scrollback page — `@deepseek-ai/dsh-terminal`'s
|
|
78
|
+
* `TerminalReadResult`, which is what `session.read(...)` resolves.
|
|
79
|
+
* @typedef {import('@deepseek-ai/dsh-terminal').TerminalReadResult} ScrollbackPage
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The PTY session surface this module drives: the backend session with its
|
|
84
|
+
* startup `motd` writable (`FishTerminalBackend.spawn` resolves with the
|
|
85
|
+
* concrete `LocalPtySession`, whose `motd` is mutable).
|
|
86
|
+
* @typedef {import('./terminal-fish.js').PersistentPtySession} PersistentPtySession
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The per-owner shell cache {@link persistentShells} returns.
|
|
91
|
+
* @typedef {object} PersistentShellRegistry
|
|
92
|
+
* @property {(owner: PersistentOwner, signal: AbortSignal) => Promise<PersistentPtySession>} get - resolve (creating lazily) the owner's live session.
|
|
93
|
+
* @property {(owner: PersistentOwner, reason: string) => Promise<void>} reset - close and forget the owner's session.
|
|
94
|
+
* @property {() => Promise<void>} dispose - tear every live session down (the plugin's own cleanup effect).
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The persistent tool's resolved configuration: every default materialized.
|
|
99
|
+
* @typedef {object} ResolvedPersistentConfig
|
|
100
|
+
* @property {string} backendType - PTY backend registry type.
|
|
101
|
+
* @property {number} timeoutMs - per-command deadline.
|
|
102
|
+
* @property {number} maxOutputChars - rendered-output cap.
|
|
103
|
+
* @property {string} description - model-facing tool description.
|
|
104
|
+
* @property {string | undefined} shellPath - fish executable override, when configured.
|
|
105
|
+
* @property {string[] | undefined} shellArgs - fish argument override, when configured.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One unpublished spawn reservation: its owner, the cancellation controller the
|
|
110
|
+
* owner-cleanup path aborts, the settlement it awaits, and the release that
|
|
111
|
+
* drops the record once the creation settled.
|
|
112
|
+
* @typedef {object} SpawnReservation
|
|
113
|
+
* @property {PersistentOwner} owner - the owner the reservation belongs to.
|
|
114
|
+
* @property {AbortController} controller - aborts the in-flight creation.
|
|
115
|
+
* @property {Promise<void>} settled - resolves when the creation published or failed.
|
|
116
|
+
* @property {() => void} release - drops the record from the owner's tracking set (idempotent).
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The exited branch of a session status snapshot.
|
|
121
|
+
* @typedef {Extract<import('@deepseek-ai/dsh-terminal').TerminalSessionStatus, { kind: 'exited' }>} ExitedSessionStatus
|
|
122
|
+
*/
|
|
123
|
+
|
|
62
124
|
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
|
|
63
125
|
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
|
|
64
126
|
const SHELL_RESET_MESSAGE = 'The persistent fish shell was reset; the next fish call starts from the workspace with a fresh current directory and environment.'
|
|
@@ -79,9 +141,15 @@ const DEFAULT_DESCRIPTION = 'Run commands in a persistent fish shell. State, inc
|
|
|
79
141
|
* self-contained).
|
|
80
142
|
*/
|
|
81
143
|
export class TimeoutReason extends Error {
|
|
144
|
+
/** @type {string} */
|
|
82
145
|
code
|
|
146
|
+
/** @type {number} */
|
|
83
147
|
timeoutMs
|
|
84
148
|
name = 'TimeoutReason'
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} code - the timeout code stamped onto this reason.
|
|
151
|
+
* @param {number} timeoutMs - the elapsed deadline in milliseconds.
|
|
152
|
+
*/
|
|
85
153
|
constructor(code, timeoutMs) {
|
|
86
154
|
super(`${code} after ${timeoutMs}ms`)
|
|
87
155
|
this.code = code
|
|
@@ -92,10 +160,10 @@ export class TimeoutReason extends Error {
|
|
|
92
160
|
/**
|
|
93
161
|
* Fuse upstream cancellation with an identifiable timeout, without pulling
|
|
94
162
|
* in `@deepseek-ai/dsh-timeout`. `timeoutMs <= 0` arms no timer.
|
|
95
|
-
* @param upstream - caller cancellation, if any.
|
|
96
|
-
* @param timeoutMs - deadline in milliseconds; `<= 0` means "no timeout".
|
|
97
|
-
* @param code - timeout code stamped onto the {@link TimeoutReason}.
|
|
98
|
-
* @returns the fused signal plus a disposer that clears the armed timer.
|
|
163
|
+
* @param {AbortSignal | undefined} upstream - caller cancellation, if any.
|
|
164
|
+
* @param {number} timeoutMs - deadline in milliseconds; `<= 0` means "no timeout".
|
|
165
|
+
* @param {string} code - timeout code stamped onto the {@link TimeoutReason}.
|
|
166
|
+
* @returns {{ signal: AbortSignal, dispose: () => void }} the fused signal plus a disposer that clears the armed timer.
|
|
99
167
|
*/
|
|
100
168
|
export function deadline(upstream, timeoutMs, code) {
|
|
101
169
|
if (timeoutMs <= 0) {
|
|
@@ -120,9 +188,9 @@ export function deadline(upstream, timeoutMs, code) {
|
|
|
120
188
|
|
|
121
189
|
/**
|
|
122
190
|
* Recover a timeout reason from a reason-bearing carrier.
|
|
123
|
-
* @param x - an AbortSignal or any `{ reason }` carrier.
|
|
124
|
-
* @param code - only a {@link TimeoutReason} with this exact code matches.
|
|
125
|
-
* @returns the matching reason, else `undefined`.
|
|
191
|
+
* @param {{ reason?: unknown } | undefined} x - an AbortSignal or any `{ reason }` carrier.
|
|
192
|
+
* @param {string} [code] - only a {@link TimeoutReason} with this exact code matches.
|
|
193
|
+
* @returns {TimeoutReason | undefined} the matching reason, else `undefined`.
|
|
126
194
|
*/
|
|
127
195
|
export function timeoutOf(x, code) {
|
|
128
196
|
const reason = x?.reason
|
|
@@ -130,6 +198,13 @@ export function timeoutOf(x, code) {
|
|
|
130
198
|
return code === undefined || reason.code === code ? reason : undefined
|
|
131
199
|
}
|
|
132
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Bound one rendered chunk, appending the truncation note when it was cut.
|
|
203
|
+
* @param {string} content - the rendered text so far.
|
|
204
|
+
* @param {number} maxOutputChars - the configured character cap.
|
|
205
|
+
* @param {boolean} [incomplete] - whether the underlying capture was already incomplete.
|
|
206
|
+
* @returns {string} the bounded text.
|
|
207
|
+
*/
|
|
133
208
|
export function maybeTruncate(content, maxOutputChars, incomplete = false) {
|
|
134
209
|
if (content.length <= maxOutputChars && !incomplete) return content
|
|
135
210
|
return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
|
|
@@ -147,8 +222,8 @@ export function markers() {
|
|
|
147
222
|
* Quote a value as one fish single-quoted token. fish single-quoted strings
|
|
148
223
|
* only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
|
|
149
224
|
* the decode is exact for arbitrary input (verified against fish 4.0.0).
|
|
150
|
-
* @param value - the raw string to embed.
|
|
151
|
-
* @returns a fish single-quoted literal.
|
|
225
|
+
* @param {string} value - the raw string to embed.
|
|
226
|
+
* @returns {string} a fish single-quoted literal.
|
|
152
227
|
*/
|
|
153
228
|
export function quoteForFish(value) {
|
|
154
229
|
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
|
@@ -158,23 +233,28 @@ export function quoteForFish(value) {
|
|
|
158
233
|
* Wrap a command so its output and exit status are delimited by unique
|
|
159
234
|
* markers. fish 4's `eval` evaluates one command string (no `--`), and
|
|
160
235
|
* `$status` must be captured before anything else runs.
|
|
161
|
-
* @param command - the raw fish command text.
|
|
162
|
-
* @param marker - the unique start/end marker pair.
|
|
163
|
-
* @returns the wrapped command line submitted to the persistent shell.
|
|
236
|
+
* @param {string} command - the raw fish command text.
|
|
237
|
+
* @param {CommandMarkers} marker - the unique start/end marker pair.
|
|
238
|
+
* @returns {string} the wrapped command line submitted to the persistent shell.
|
|
164
239
|
*/
|
|
165
240
|
export function wrapCommand(command, marker) {
|
|
166
241
|
return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
|
|
167
242
|
}
|
|
168
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Drop the trailing newline run a PTY echoes after a command.
|
|
246
|
+
* @param {string} text - the captured text.
|
|
247
|
+
* @returns {string} the text without its trailing newlines.
|
|
248
|
+
*/
|
|
169
249
|
export function trimTrailingNewline(text) {
|
|
170
250
|
return text.replace(/(?:\r?\n)+$/, '')
|
|
171
251
|
}
|
|
172
252
|
|
|
173
253
|
/**
|
|
174
254
|
* Extract the marker-delimited command output from a scrollback snapshot.
|
|
175
|
-
* @param snapshot - the retained scrollback.
|
|
176
|
-
* @param marker - the unique start/end marker pair.
|
|
177
|
-
* @returns the captured text, exit code, and whether the start was lost.
|
|
255
|
+
* @param {{ text: string }} snapshot - the retained scrollback text.
|
|
256
|
+
* @param {CommandMarkers} marker - the unique start/end marker pair.
|
|
257
|
+
* @returns {{ text: string, incomplete: boolean, exitCode: number } | undefined} the captured text, exit code, and whether the start was lost; `undefined` while the end status is not in the snapshot yet.
|
|
178
258
|
*/
|
|
179
259
|
export function commandOutput(snapshot, marker) {
|
|
180
260
|
const text = snapshot.text
|
|
@@ -191,8 +271,14 @@ export function commandOutput(snapshot, marker) {
|
|
|
191
271
|
}
|
|
192
272
|
|
|
193
273
|
/**
|
|
194
|
-
* Render the
|
|
195
|
-
* the incremental send delta when the scrollback lost the
|
|
274
|
+
* Render the output captured so far for a command that has not finished,
|
|
275
|
+
* falling back to the incremental send delta when the scrollback lost the
|
|
276
|
+
* start marker.
|
|
277
|
+
* @param {{ text: string }} snapshot - the retained scrollback text.
|
|
278
|
+
* @param {CommandMarkers} marker - the unique start/end marker pair.
|
|
279
|
+
* @param {string} fallback - the incremental delta accumulated so far.
|
|
280
|
+
* @param {boolean} [fallbackTruncated] - whether the fallback lost bytes.
|
|
281
|
+
* @returns {{ text: string, incomplete: boolean }} the partial text and whether the start was lost.
|
|
196
282
|
*/
|
|
197
283
|
export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
|
|
198
284
|
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
|
@@ -211,15 +297,31 @@ export function partialOutput(snapshot, marker, fallback, fallbackTruncated = fa
|
|
|
211
297
|
}
|
|
212
298
|
}
|
|
213
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Wait one polling interval between readiness/scrollback probes.
|
|
302
|
+
* @returns {Promise<void>} resolves after the interval.
|
|
303
|
+
*/
|
|
214
304
|
export async function pause() {
|
|
215
305
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
216
306
|
}
|
|
217
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The next backward page offset when older lines remain.
|
|
310
|
+
* @param {ScrollbackPage} page - one retained page.
|
|
311
|
+
* @param {number} offset - the newest-relative offset the page started at.
|
|
312
|
+
* @returns {number | undefined} the next offset, or `undefined` when the scrollback is exhausted.
|
|
313
|
+
*/
|
|
218
314
|
export function nextScrollbackOffset(page, offset) {
|
|
219
315
|
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
|
|
220
316
|
return page.lineEnd
|
|
221
317
|
}
|
|
222
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Page back through the session's scrollback until the retained window is read.
|
|
321
|
+
* @param {PersistentPtySession} session - the owner's live PTY session.
|
|
322
|
+
* @param {ScrollbackPage} [latest] - the already-read newest page (read here when omitted).
|
|
323
|
+
* @returns {{ text: string, truncated: boolean }} the retained text and whether the window was cut.
|
|
324
|
+
*/
|
|
223
325
|
export function retainedScrollback(session, latest = session.read({
|
|
224
326
|
offset: 0,
|
|
225
327
|
count: SCROLLBACK_PAGE_LINES,
|
|
@@ -245,16 +347,35 @@ export function retainedScrollback(session, latest = session.read({
|
|
|
245
347
|
}
|
|
246
348
|
}
|
|
247
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Render one captured command output with its truncation and exit markers.
|
|
352
|
+
* @param {{ text: string, incomplete: boolean, exitCode?: number }} output - the captured chunk.
|
|
353
|
+
* @param {number} maxOutputChars - the configured character cap.
|
|
354
|
+
* @returns {string} the model-facing text.
|
|
355
|
+
*/
|
|
248
356
|
export function renderCaptured(output, maxOutputChars) {
|
|
249
357
|
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
|
|
250
358
|
return appendStatusMarker(output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered, output.exitCode !== undefined ? `[Command finished with exit code ${output.exitCode}]` : undefined)
|
|
251
359
|
}
|
|
252
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Append one status line to already-rendered content.
|
|
363
|
+
* @param {string} content - the rendered text.
|
|
364
|
+
* @param {string | undefined} marker - the status line, when there is one.
|
|
365
|
+
* @returns {string} the content with the marker appended.
|
|
366
|
+
*/
|
|
253
367
|
export function appendStatusMarker(content, marker) {
|
|
254
368
|
if (marker === undefined) return content
|
|
255
369
|
return content.length === 0 ? marker : `${content}\n${marker}`
|
|
256
370
|
}
|
|
257
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Append the shell-exit status line for a session that went away.
|
|
374
|
+
* @param {string} content - the rendered text.
|
|
375
|
+
* @param {number | null} exitCode - the shell's exit code, when it exited.
|
|
376
|
+
* @param {NodeJS.Signals | null} signal - the terminating signal, when it was killed.
|
|
377
|
+
* @returns {string} the content with its exit status.
|
|
378
|
+
*/
|
|
258
379
|
export function renderShellExitStatus(content, exitCode, signal) {
|
|
259
380
|
return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
|
|
260
381
|
}
|
|
@@ -262,6 +383,15 @@ export function renderShellExitStatus(content, exitCode, signal) {
|
|
|
262
383
|
/**
|
|
263
384
|
* Render the exited-session result, reset the owner's shell, and carry the
|
|
264
385
|
* notice that the next call starts fresh.
|
|
386
|
+
* @param {PersistentPtySession} session - the session that exited.
|
|
387
|
+
* @param {PersistentShellRegistry} shells - the per-owner cache to reset.
|
|
388
|
+
* @param {PersistentOwner} owner - the owning agent.
|
|
389
|
+
* @param {ExitedSessionStatus} status - the exited status snapshot.
|
|
390
|
+
* @param {CommandMarkers} marker - the unique start/end marker pair.
|
|
391
|
+
* @param {string} fallback - the incremental delta accumulated so far.
|
|
392
|
+
* @param {boolean} fallbackTruncated - whether the fallback lost bytes.
|
|
393
|
+
* @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
|
|
394
|
+
* @returns {Promise<string>} the model-facing reset notice.
|
|
265
395
|
*/
|
|
266
396
|
async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
|
|
267
397
|
const snapshot = retainedScrollback(session)
|
|
@@ -290,30 +420,43 @@ async function respondToSessionExit(session, shells, owner, status, marker, fall
|
|
|
290
420
|
* close keeps the session retryable instead of losing the handle, and a
|
|
291
421
|
* re-entrant close awaits the in-flight one.
|
|
292
422
|
*
|
|
293
|
-
* @param ctx - the policy plugin's context (services resolved via `ctx.get`).
|
|
294
|
-
* @param config - the resolved persistent configuration.
|
|
295
|
-
* @param backend - the PTY backend (duck-typed: only `spawn(spec)` is read);
|
|
296
|
-
*
|
|
297
|
-
* (injectable so tests can fake spawn/close).
|
|
423
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context (services resolved via `ctx.get`).
|
|
424
|
+
* @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
|
|
425
|
+
* @param {{ spawn: (spec: import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec) => Promise<PersistentPtySession> }} [backend] - the PTY backend (duck-typed: only `spawn(spec)` is read); defaults to a real `FishTerminalBackend` constructed against `ctx` (injectable so tests can fake spawn/close).
|
|
426
|
+
* @returns {PersistentShellRegistry} the per-owner cache the persistent tool drives.
|
|
298
427
|
*/
|
|
299
428
|
export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
|
|
300
429
|
backendType: config.backendType,
|
|
301
430
|
...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
|
|
302
431
|
...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
|
|
303
|
-
}), (spec) =>
|
|
432
|
+
}), (spec) => {
|
|
433
|
+
// `ctx.get` (never property access) keeps this working in a policy context
|
|
434
|
+
// whose inject list does not declare `subprocess`; a composition without the
|
|
435
|
+
// service fails loud here instead of with an undefined-property TypeError.
|
|
436
|
+
const subprocess = ctx.get('subprocess')
|
|
437
|
+
if (subprocess === undefined) throw new Error('persistent fish: the PTY backend requires ctx.subprocess in this world')
|
|
438
|
+
return subprocess.spawnTerminal(spec)
|
|
439
|
+
})) {
|
|
440
|
+
/** @type {WeakMap<PersistentOwner, Promise<PersistentPtySession>>} */
|
|
304
441
|
const pending = new WeakMap()
|
|
442
|
+
/** @type {Map<PersistentOwner, PersistentPtySession>} */
|
|
305
443
|
const live = new Map()
|
|
444
|
+
/** @type {Set<Promise<PersistentPtySession>>} */
|
|
306
445
|
const creating = new Set()
|
|
307
446
|
/** Owner → in-flight close promise. A session stays here until its close
|
|
308
447
|
* settles, so it still counts as activity while the PTY is being torn down
|
|
309
448
|
* and a failed close leaves the session retryable (the `live` entry is
|
|
310
449
|
* removed only on success). */
|
|
450
|
+
/** @type {Map<PersistentOwner, Promise<void>>} */
|
|
311
451
|
const closing = new Map()
|
|
312
|
-
/** Owner → Set of unpublished spawn reservations (official registry shape). */
|
|
452
|
+
/** Owner → Set of unpublished spawn reservations (official registry shape). @type {Map<PersistentOwner, Set<SpawnReservation>>} */
|
|
313
453
|
const pendingSpawns = new Map()
|
|
454
|
+
/** @type {WeakSet<PersistentOwner>} */
|
|
314
455
|
const ownerCleanupInstalled = new WeakSet()
|
|
456
|
+
/** @type {WeakSet<PersistentOwner>} */
|
|
315
457
|
const disposedOwners = new WeakSet()
|
|
316
458
|
const lifecycle = new AbortController()
|
|
459
|
+
/** @param {PersistentOwner} owner - the owner whose activity is probed. @returns {boolean} whether that owner has a live, pending, or closing session. */
|
|
317
460
|
const hasActivity = (owner) =>
|
|
318
461
|
pending.get(owner) !== undefined
|
|
319
462
|
|| (pendingSpawns.get(owner)?.size ?? 0) > 0
|
|
@@ -323,7 +466,8 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
323
466
|
/** Register the owner-scoped cleanup effect BEFORE the first spawn: on
|
|
324
467
|
* owner disposal it aborts and awaits pending creations, then closes the
|
|
325
468
|
* live session. Throws when the owner context is already disposed (no
|
|
326
|
-
* registration happens on a disposed context).
|
|
469
|
+
* registration happens on a disposed context).
|
|
470
|
+
* @param {PersistentOwner} owner - the owner to install cleanup for. */
|
|
327
471
|
const ensureOwnerCleanup = (owner) => {
|
|
328
472
|
if (ownerCleanupInstalled.has(owner)) return
|
|
329
473
|
if (disposedOwners.has(owner)) {
|
|
@@ -347,30 +491,43 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
347
491
|
|
|
348
492
|
/** One unpublished spawn reservation: an AbortController plus a settlement
|
|
349
493
|
* the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
|
|
350
|
-
* creations before they are published.
|
|
494
|
+
* creations before they are published. `release` lives ON the record:
|
|
495
|
+
* `abortPendingSpawns` releases every record it snapshotted, and a snapshot
|
|
496
|
+
* that only carried the wrapper's closure threw `release is not a function`.
|
|
497
|
+
* @param {PersistentOwner} owner - the owner the reservation belongs to.
|
|
498
|
+
* @returns {{ signal: AbortSignal, release: () => void }} the reservation's cancellation signal and release. */
|
|
351
499
|
const reserveSpawn = (owner) => {
|
|
352
500
|
ensureOwnerCleanup(owner)
|
|
353
501
|
const controller = new AbortController()
|
|
502
|
+
/** @type {PromiseWithResolvers<void>} */
|
|
354
503
|
const settlement = Promise.withResolvers()
|
|
355
|
-
const pendingSpawn = {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
owned.add(pendingSpawn)
|
|
362
|
-
return {
|
|
363
|
-
signal: controller.signal,
|
|
504
|
+
const pendingSpawn = {
|
|
505
|
+
owner,
|
|
506
|
+
controller,
|
|
507
|
+
settled: settlement.promise,
|
|
508
|
+
/** Drop this reservation from the owner's set and settle the cleanup wait (idempotent). */
|
|
364
509
|
release() {
|
|
510
|
+
const owned = pendingSpawns.get(owner)
|
|
511
|
+
if (owned === undefined) return
|
|
365
512
|
owned.delete(pendingSpawn)
|
|
366
513
|
if (owned.size === 0) pendingSpawns.delete(owner)
|
|
367
514
|
settlement.resolve()
|
|
368
515
|
},
|
|
369
516
|
}
|
|
517
|
+
let owned = pendingSpawns.get(owner)
|
|
518
|
+
if (owned === undefined) {
|
|
519
|
+
owned = new Set()
|
|
520
|
+
pendingSpawns.set(owner, owned)
|
|
521
|
+
}
|
|
522
|
+
owned.add(pendingSpawn)
|
|
523
|
+
return { signal: controller.signal, release: () => pendingSpawn.release() }
|
|
370
524
|
}
|
|
371
525
|
|
|
372
526
|
/** Abort every pending creation of one owner and await its settlement
|
|
373
|
-
* (idempotent with each creation's own `release`).
|
|
527
|
+
* (idempotent with each creation's own `release`).
|
|
528
|
+
* @param {PersistentOwner} owner - the owner whose creations are aborted.
|
|
529
|
+
* @param {string} reason - forwarded to each reservation's abort.
|
|
530
|
+
* @returns {Promise<void>} resolves once every pending creation settled. */
|
|
374
531
|
const abortPendingSpawns = async (owner, reason) => {
|
|
375
532
|
const owned = pendingSpawns.get(owner)
|
|
376
533
|
if (owned === undefined) return
|
|
@@ -384,7 +541,12 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
384
541
|
* until the close settles. On success the `live` entry is removed; on
|
|
385
542
|
* failure the session stays retryable and the failure is recorded, then
|
|
386
543
|
* rethrown so callers can retry. A second close of the same session awaits
|
|
387
|
-
* the in-flight one instead of stacking another close.
|
|
544
|
+
* the in-flight one instead of stacking another close.
|
|
545
|
+
* @param {PersistentOwner} owner - the session's owner.
|
|
546
|
+
* @param {PersistentPtySession} session - the session to close.
|
|
547
|
+
* @param {string} reason - the close reason recorded on the PTY session.
|
|
548
|
+
* @param {() => void} [onClosed] - invoked once the close succeeded.
|
|
549
|
+
* @returns {Promise<void>} resolves when the close settled. */
|
|
388
550
|
const closeSession = async (owner, session, reason, onClosed) => {
|
|
389
551
|
if (closing.has(owner)) return closing.get(owner)
|
|
390
552
|
const promise = (async () => {
|
|
@@ -408,12 +570,14 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
408
570
|
const owners = [...live.keys()]
|
|
409
571
|
await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
|
|
410
572
|
}, 'persistent fish shell cleanup')
|
|
573
|
+
/** @param {PersistentOwner} owner - the owner whose session is reset. @param {string} reason - the close reason. @returns {Promise<void>} resolves when the session is closed and forgotten. */
|
|
411
574
|
const reset = async (owner, reason) => {
|
|
412
575
|
pending.delete(owner)
|
|
413
576
|
const session = live.get(owner)
|
|
414
577
|
if (session === undefined) return
|
|
415
578
|
await closeSession(owner, session, reason, () => live.delete(owner))
|
|
416
579
|
}
|
|
580
|
+
/** @param {PersistentOwner} owner - the owner whose session is resolved. @param {AbortSignal} signal - caller cancellation. @returns {Promise<PersistentPtySession>} the live (lazily created) session. */
|
|
417
581
|
const get = (owner, signal) => {
|
|
418
582
|
const existing = pending.get(owner)
|
|
419
583
|
if (existing !== undefined) return existing
|
|
@@ -436,8 +600,14 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
436
600
|
// the backend's own fence call reuses this entry.
|
|
437
601
|
ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
|
|
438
602
|
const spawned = await backend.spawn({
|
|
603
|
+
// The registry mints `type` from the request's registered backend;
|
|
604
|
+
// the self-managed path names the configured type directly.
|
|
605
|
+
type: config.backendType,
|
|
439
606
|
owner,
|
|
440
|
-
|
|
607
|
+
// The registry normally mints this identity; the self-managed path
|
|
608
|
+
// mints its own with the seam's branding helper (a runtime identity
|
|
609
|
+
// function) so the branded `TerminalSessionIdValue` stays honest.
|
|
610
|
+
sessionId: TerminalSessionId(randomUUID()),
|
|
441
611
|
...(cwd === undefined ? {} : { cwd }),
|
|
442
612
|
signal: combinedSignal,
|
|
443
613
|
})
|
|
@@ -468,6 +638,13 @@ export function persistentShells(ctx, config, backend = new FishTerminalBackend(
|
|
|
468
638
|
/**
|
|
469
639
|
* Execute one wrapped command against the owner's persistent fish session,
|
|
470
640
|
* reading the marker-delimited result from the scrollback.
|
|
641
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the policy plugin's context, threaded through for the call shape (the owner-scoped fence bookkeeping lives in {@link persistentShells}; this function reads no context service).
|
|
642
|
+
* @param {PersistentShellRegistry} shells - the per-owner session cache.
|
|
643
|
+
* @param {PersistentOwner} owner - the owning agent.
|
|
644
|
+
* @param {string} command - the raw fish command text.
|
|
645
|
+
* @param {ResolvedPersistentConfig} config - the resolved persistent configuration.
|
|
646
|
+
* @param {AbortSignal | undefined} upstream - the caller's cancellation signal.
|
|
647
|
+
* @returns {Promise<string>} the model-facing command result.
|
|
471
648
|
*/
|
|
472
649
|
async function executeCommand(ctx, shells, owner, command, config, upstream) {
|
|
473
650
|
const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
|
|
@@ -529,11 +706,24 @@ async function executeCommand(ctx, shells, owner, command, config, upstream) {
|
|
|
529
706
|
}
|
|
530
707
|
}
|
|
531
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Optional overrides for the persistent tool, as accepted by
|
|
711
|
+
* {@link resolvePersistentConfig} and passed through by
|
|
712
|
+
* {@link registerPersistentFish}.
|
|
713
|
+
* @typedef {object} PersistentFishOverrides
|
|
714
|
+
* @property {string} [backendType] - PTY backend registry type (default `fish`).
|
|
715
|
+
* @property {number} [timeoutMs] - per-command deadline in milliseconds (default 300000).
|
|
716
|
+
* @property {number} [maxOutputChars] - rendered-output cap in characters (default 16000).
|
|
717
|
+
* @property {string} [description] - model-facing tool description.
|
|
718
|
+
* @property {string} [shellPath] - fish executable override.
|
|
719
|
+
* @property {string[]} [shellArgs] - fish argument override.
|
|
720
|
+
*/
|
|
721
|
+
|
|
532
722
|
/**
|
|
533
723
|
* Resolve the persistent tool configuration with defaults applied.
|
|
534
|
-
* @param config - optional overrides (backend type, deadline, output cap,
|
|
724
|
+
* @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
|
|
535
725
|
* fish executable).
|
|
536
|
-
* @returns the fully resolved configuration (validated).
|
|
726
|
+
* @returns {ResolvedPersistentConfig} the fully resolved configuration (validated).
|
|
537
727
|
*/
|
|
538
728
|
export function resolvePersistentConfig(config = {}) {
|
|
539
729
|
const resolved = {
|
|
@@ -552,7 +742,8 @@ export function resolvePersistentConfig(config = {}) {
|
|
|
552
742
|
* Validate the resolved persistent configuration. `timeoutMs` is additionally
|
|
553
743
|
* bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
|
|
554
744
|
* the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
|
|
555
|
-
* @param resolved - the fully resolved configuration.
|
|
745
|
+
* @param {ResolvedPersistentConfig} resolved - the fully resolved configuration.
|
|
746
|
+
* @returns {void} throws on a value the tool cannot run with.
|
|
556
747
|
*/
|
|
557
748
|
export function validatePersistentConfig(resolved) {
|
|
558
749
|
if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
|
|
@@ -568,15 +759,15 @@ export function validatePersistentConfig(resolved) {
|
|
|
568
759
|
* shadowing the host-global one-shot `fish` tool. Intended to be called by
|
|
569
760
|
* `policy.js` from the agent boundary when the agent's standing bash tool is
|
|
570
761
|
* the persistent form (`minimal` preset).
|
|
571
|
-
* @param ctx - the host context carrying `sandboxPolicy` and `subprocess`
|
|
762
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the host context carrying `sandboxPolicy` and `subprocess`
|
|
572
763
|
* (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
|
|
573
764
|
* in the composition test profiles); `terminals` is deliberately NOT
|
|
574
765
|
* needed — sessions are self-managed through a `FishTerminalBackend`.
|
|
575
|
-
* @param agentCtx - the agent's scoped context; the tool is registered here
|
|
766
|
+
* @param {import('@deepseek-ai/cordis').Context} agentCtx - the agent's scoped context; the tool is registered here
|
|
576
767
|
* so the agent sees it (shadowing the host-global fish).
|
|
577
|
-
* @param config - optional overrides (backend type, deadline, output cap,
|
|
768
|
+
* @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
|
|
578
769
|
* fish executable).
|
|
579
|
-
* @returns the disposer; it unregisters the tool and returns a Promise that
|
|
770
|
+
* @returns {() => Promise<void> | undefined} the disposer; it unregisters the tool and returns a Promise that
|
|
580
771
|
* settles when the session cleanup has quiesced (policy.js awaits it
|
|
581
772
|
* out-of-band via `Promise.resolve(...).catch`).
|
|
582
773
|
*/
|
|
@@ -584,7 +775,9 @@ export function registerPersistentFish(ctx, agentCtx, config = {}) {
|
|
|
584
775
|
const resolved = resolvePersistentConfig(config)
|
|
585
776
|
|
|
586
777
|
const shells = persistentShells(ctx, resolved)
|
|
778
|
+
/** @type {WeakMap<PersistentOwner, Promise<void>>} */
|
|
587
779
|
const queues = new WeakMap()
|
|
780
|
+
/** @param {PersistentOwner} owner - the agent whose calls are serialized. @param {() => Promise<string>} operation - one call's work. @returns {Promise<string>} the call's result, in submission order per owner. */
|
|
588
781
|
const serialized = async (owner, operation) => {
|
|
589
782
|
const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
|
|
590
783
|
const tail = run.then(() => undefined, () => undefined)
|