@banbolee/dsh-fish-shell 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -41
- package/README.zh.md +63 -0
- package/cordis.patch.yml +39 -32
- package/index.js +60 -33
- package/job-kind.d.ts +24 -0
- package/local.js +13 -14
- package/package.json +52 -14
- package/persistent.js +832 -0
- package/policy.js +405 -0
- package/terminal-fish.js +427 -0
- package/tool.js +546 -112
- package/presets/fish/agent.cordis.yml +0 -241
package/persistent.js
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing persistent `fish` tool over the owner-scoped PTY seam, for
|
|
3
|
+
* agent presets whose standing bash is the PERSISTENT form (`minimal`).
|
|
4
|
+
*
|
|
5
|
+
* This module is a fish translation of the official
|
|
6
|
+
* `@deepseek-ai/dsh-tool-bash-persistent` pattern: a per-owner cached PTY
|
|
7
|
+
* session, serialized command execution, marker-wrapped commands, scrollback
|
|
8
|
+
* reads, deadline/timeout reset, and the shell-reset notice. Differences
|
|
9
|
+
* from the official bash version are all fish dialect:
|
|
10
|
+
*
|
|
11
|
+
* - `quoteForFish` uses fish single-quote escaping (`\\` and `\'` are the
|
|
12
|
+
* only escapes inside fish single quotes) — fish 4.0.0 has NO bash-style
|
|
13
|
+
* `$'...'` ANSI-C quoting, so the official `$'...'` wrapper cannot be
|
|
14
|
+
* reused (verified against real fish 4.0.0).
|
|
15
|
+
* - `wrapCommand` feeds the command to `eval` as ONE quoted string
|
|
16
|
+
* argument: fish 4's `eval` takes a command string (it no longer has an
|
|
17
|
+
* option terminator, so `eval --` fails).
|
|
18
|
+
* - The status is captured with `set __dsh_status $status` (fish's
|
|
19
|
+
* `$status`, not bash's `$?`).
|
|
20
|
+
*
|
|
21
|
+
* PTY self-management: the official persistent bash tool talks to the
|
|
22
|
+
* `terminals` registry (`ctx.terminals.spawn/startSend/read/list/kill`),
|
|
23
|
+
* which only exists inside the `minimal` preset's entry-local realm — it is
|
|
24
|
+
* invisible at the host/agent plane, so an agent-scope tool cannot resolve
|
|
25
|
+
* it. This module therefore manages its own sessions WITHOUT the registry,
|
|
26
|
+
* mirroring the official semantics exactly:
|
|
27
|
+
*
|
|
28
|
+
* - One `FishTerminalBackend` instance (`./terminal-fish.js`) is created
|
|
29
|
+
* per registration and shared by all owners, exactly like one registry
|
|
30
|
+
* backend is shared by all owners.
|
|
31
|
+
* - Spawn is `backend.spawn({ owner, sessionId: <uuid>, cwd,
|
|
32
|
+
* signal })`; the returned session replaces the registry snapshot id
|
|
33
|
+
* everywhere (`session.startSend(...).done` instead of
|
|
34
|
+
* `ctx.terminals.startSend(owner, id, ...)`, `session.read(...)`,
|
|
35
|
+
* `session.status()`).
|
|
36
|
+
* - The registry's `kill(owner, id, reason)` is exactly
|
|
37
|
+
* `session.close(reason)` (verified against
|
|
38
|
+
* `@deepseek-ai/dsh-terminal`'s registry), so reset/close map to
|
|
39
|
+
* `session.close`.
|
|
40
|
+
* - The backend needs `sandboxPolicy` / `subprocess` resolvable from the
|
|
41
|
+
* policy plugin's ctx (true in dsh-base/TUI host planes and in the
|
|
42
|
+
* composition test profiles) plus the sandbox provider via `ctx.get
|
|
43
|
+
* ('sandbox')` for confined modes.
|
|
44
|
+
* - The sandbox-mode fence copy in `terminal-fish.js` is installed with an
|
|
45
|
+
* injectable owner-activity check (this module's own owner→session
|
|
46
|
+
* tracking) because there is no registry to ask; the open-during-change
|
|
47
|
+
* refusal semantics are unchanged.
|
|
48
|
+
*
|
|
49
|
+
* The module deliberately exports NO Cordis `apply`: it is a library invoked
|
|
50
|
+
* by `policy.js` (`registerPersistentFish(ctx, agentCtx)`) when the policy
|
|
51
|
+
* detects the persistent bash form at the agent boundary, registering the
|
|
52
|
+
* persistent `fish` tool at the agent scope so it shadows the host-global
|
|
53
|
+
* one-shot `fish` tool.
|
|
54
|
+
*
|
|
55
|
+
* @module @banbolee/dsh-fish-shell/persistent
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { randomUUID } from 'node:crypto'
|
|
59
|
+
import { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
|
60
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
61
|
+
import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
|
|
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
|
+
|
|
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>'
|
|
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'
|
|
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.'
|
|
127
|
+
const TIMEOUT_STATUS_MARKER = '[Command timed out]'
|
|
128
|
+
export const TIMEOUT_CODE = 'PERSISTENT_FISH_TIMEOUT'
|
|
129
|
+
/** Largest delay Node schedules without clamping it to one millisecond
|
|
130
|
+
* (mirrors `@deepseek-ai/dsh-timeout`'s `MAX_TIMER_DELAY_MS`). */
|
|
131
|
+
export const MAX_TIMER_DELAY_MS = 2147483647
|
|
132
|
+
const MODULE_TAG = '@banbolee/dsh-fish-shell'
|
|
133
|
+
const SCROLLBACK_PAGE_LINES = 1e3
|
|
134
|
+
const POLL_INTERVAL_MS = 25
|
|
135
|
+
const DEFAULT_DESCRIPTION = 'Run commands in a persistent fish shell. State, including the current directory, exported variables, and defined functions, persists across calls for this agent. Use fish syntax: set x 5; echo $x; $status for the last exit code; function name; ...; end for functions.'
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Abort reason carrying this module's timeout code and elapsed deadline.
|
|
139
|
+
* Mirrors `@deepseek-ai/dsh-timeout`'s `TimeoutReason` (the module is NOT a
|
|
140
|
+
* peer dependency here: the minimal deadline implementation below is
|
|
141
|
+
* self-contained).
|
|
142
|
+
*/
|
|
143
|
+
export class TimeoutReason extends Error {
|
|
144
|
+
/** @type {string} */
|
|
145
|
+
code
|
|
146
|
+
/** @type {number} */
|
|
147
|
+
timeoutMs
|
|
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
|
+
*/
|
|
153
|
+
constructor(code, timeoutMs) {
|
|
154
|
+
super(`${code} after ${timeoutMs}ms`)
|
|
155
|
+
this.code = code
|
|
156
|
+
this.timeoutMs = timeoutMs
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fuse upstream cancellation with an identifiable timeout, without pulling
|
|
162
|
+
* in `@deepseek-ai/dsh-timeout`. `timeoutMs <= 0` arms no 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.
|
|
167
|
+
*/
|
|
168
|
+
export function deadline(upstream, timeoutMs, code) {
|
|
169
|
+
if (timeoutMs <= 0) {
|
|
170
|
+
return { signal: upstream ?? new AbortController().signal, dispose() {} }
|
|
171
|
+
}
|
|
172
|
+
// Node clamps delays above 2^31-1 to ~1ms with a TimeoutOverflowWarning;
|
|
173
|
+
// reject such deadlines instead of aborting immediately.
|
|
174
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs > MAX_TIMER_DELAY_MS) {
|
|
175
|
+
throw new Error(`deadline timeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
176
|
+
}
|
|
177
|
+
const timer = new AbortController()
|
|
178
|
+
const id = setTimeout(() => {
|
|
179
|
+
timer.abort(new TimeoutReason(code, timeoutMs))
|
|
180
|
+
}, timeoutMs)
|
|
181
|
+
return {
|
|
182
|
+
signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
|
|
183
|
+
dispose() {
|
|
184
|
+
clearTimeout(id)
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Recover a timeout reason from a reason-bearing carrier.
|
|
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`.
|
|
194
|
+
*/
|
|
195
|
+
export function timeoutOf(x, code) {
|
|
196
|
+
const reason = x?.reason
|
|
197
|
+
if (!(reason instanceof TimeoutReason)) return undefined
|
|
198
|
+
return code === undefined || reason.code === code ? reason : undefined
|
|
199
|
+
}
|
|
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
|
+
*/
|
|
208
|
+
export function maybeTruncate(content, maxOutputChars, incomplete = false) {
|
|
209
|
+
if (content.length <= maxOutputChars && !incomplete) return content
|
|
210
|
+
return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function markers() {
|
|
214
|
+
const nonce = randomUUID()
|
|
215
|
+
return {
|
|
216
|
+
start: `__DSH_PERSISTENT_FISH_START_${nonce}__`,
|
|
217
|
+
end: `__DSH_PERSISTENT_FISH_END_${nonce}:`,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Quote a value as one fish single-quoted token. fish single-quoted strings
|
|
223
|
+
* only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
|
|
224
|
+
* the decode is exact for arbitrary input (verified against fish 4.0.0).
|
|
225
|
+
* @param {string} value - the raw string to embed.
|
|
226
|
+
* @returns {string} a fish single-quoted literal.
|
|
227
|
+
*/
|
|
228
|
+
export function quoteForFish(value) {
|
|
229
|
+
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Wrap a command so its output and exit status are delimited by unique
|
|
234
|
+
* markers. fish 4's `eval` evaluates one command string (no `--`), and
|
|
235
|
+
* `$status` must be captured before anything else runs.
|
|
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.
|
|
239
|
+
*/
|
|
240
|
+
export function wrapCommand(command, marker) {
|
|
241
|
+
return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
|
|
242
|
+
}
|
|
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
|
+
*/
|
|
249
|
+
export function trimTrailingNewline(text) {
|
|
250
|
+
return text.replace(/(?:\r?\n)+$/, '')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Extract the marker-delimited command output from a scrollback snapshot.
|
|
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.
|
|
258
|
+
*/
|
|
259
|
+
export function commandOutput(snapshot, marker) {
|
|
260
|
+
const text = snapshot.text
|
|
261
|
+
const end = text.lastIndexOf(marker.end)
|
|
262
|
+
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
|
|
263
|
+
if (status === undefined) return undefined
|
|
264
|
+
const startMarker = text.lastIndexOf(marker.start, end)
|
|
265
|
+
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
|
|
266
|
+
return {
|
|
267
|
+
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
|
|
268
|
+
incomplete: startMarker < 0,
|
|
269
|
+
exitCode: Number(status),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
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.
|
|
282
|
+
*/
|
|
283
|
+
export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
|
|
284
|
+
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
|
285
|
+
if (startMarker >= 0) {
|
|
286
|
+
return {
|
|
287
|
+
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
|
288
|
+
incomplete: false,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const fallbackStart = fallback.lastIndexOf(marker.start)
|
|
292
|
+
const afterStart = fallbackStart < 0 ? fallback : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
|
|
293
|
+
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
|
294
|
+
return {
|
|
295
|
+
text: trimTrailingNewline(fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)),
|
|
296
|
+
incomplete: fallbackTruncated || fallbackStart < 0,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Wait one polling interval between readiness/scrollback probes.
|
|
302
|
+
* @returns {Promise<void>} resolves after the interval.
|
|
303
|
+
*/
|
|
304
|
+
export async function pause() {
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
306
|
+
}
|
|
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
|
+
*/
|
|
314
|
+
export function nextScrollbackOffset(page, offset) {
|
|
315
|
+
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
|
|
316
|
+
return page.lineEnd
|
|
317
|
+
}
|
|
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
|
+
*/
|
|
325
|
+
export function retainedScrollback(session, latest = session.read({
|
|
326
|
+
offset: 0,
|
|
327
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
328
|
+
})) {
|
|
329
|
+
const pages = latest.text.length === 0 ? [] : [latest.text]
|
|
330
|
+
let offset = latest.lineEnd
|
|
331
|
+
let truncated = latest.truncated
|
|
332
|
+
for (;;) {
|
|
333
|
+
if (offset >= latest.totalLines) break
|
|
334
|
+
const page = session.read({
|
|
335
|
+
offset,
|
|
336
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
337
|
+
})
|
|
338
|
+
truncated ||= page.truncated
|
|
339
|
+
if (page.text.length > 0) pages.unshift(page.text)
|
|
340
|
+
const next = nextScrollbackOffset(page, offset)
|
|
341
|
+
if (next === undefined || next >= page.totalLines) break
|
|
342
|
+
offset = next
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
text: pages.join('\n'),
|
|
346
|
+
truncated,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
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
|
+
*/
|
|
356
|
+
export function renderCaptured(output, maxOutputChars) {
|
|
357
|
+
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
|
|
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)
|
|
359
|
+
}
|
|
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
|
+
*/
|
|
367
|
+
export function appendStatusMarker(content, marker) {
|
|
368
|
+
if (marker === undefined) return content
|
|
369
|
+
return content.length === 0 ? marker : `${content}\n${marker}`
|
|
370
|
+
}
|
|
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
|
+
*/
|
|
379
|
+
export function renderShellExitStatus(content, exitCode, signal) {
|
|
380
|
+
return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Render the exited-session result, reset the owner's shell, and carry the
|
|
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.
|
|
395
|
+
*/
|
|
396
|
+
async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
|
|
397
|
+
const snapshot = retainedScrollback(session)
|
|
398
|
+
await shells.reset(owner, 'persistent fish shell exited')
|
|
399
|
+
return [renderShellExitStatus(renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), status.exitCode, status.signal), SHELL_RESET_MESSAGE].filter((part) => part.length > 0).join('\n')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Per-owner persistent fish PTY cache with lazy creation and reset, driving
|
|
404
|
+
* one shared `FishTerminalBackend` directly (self-managed: no `terminals`
|
|
405
|
+
* registry, which is invisible at the host/agent plane). The registry's
|
|
406
|
+
* kill is `session.close(reason)`, so close/reset map straight to the
|
|
407
|
+
* session; the sandbox-mode fence gets an injectable owner-activity check
|
|
408
|
+
* backed by this module's own owner→session tracking.
|
|
409
|
+
*
|
|
410
|
+
* Owner lifecycle mirrors the official `@deepseek-ai/dsh-terminal` registry:
|
|
411
|
+
* the owner-scoped cleanup effect is installed BEFORE the first spawn, each
|
|
412
|
+
* creation carries a reservation (AbortController + settlement) merged into
|
|
413
|
+
* the spawn/startup signal, and owner disposal aborts and awaits pending
|
|
414
|
+
* creations before closing live sessions — so a spawn racing an owner
|
|
415
|
+
* disposal cannot outlive its owner or register on a disposed context.
|
|
416
|
+
*
|
|
417
|
+
* A session being closed is tracked in a `closing` set until close actually
|
|
418
|
+
* settles: it still counts as activity (the sandbox-mode fence keeps
|
|
419
|
+
* refusing mode changes while the old PTY is being torn down), a failed
|
|
420
|
+
* close keeps the session retryable instead of losing the handle, and a
|
|
421
|
+
* re-entrant close awaits the in-flight one.
|
|
422
|
+
*
|
|
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.
|
|
427
|
+
*/
|
|
428
|
+
export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
|
|
429
|
+
backendType: config.backendType,
|
|
430
|
+
...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
|
|
431
|
+
...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
|
|
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>>} */
|
|
441
|
+
const pending = new WeakMap()
|
|
442
|
+
/** @type {Map<PersistentOwner, PersistentPtySession>} */
|
|
443
|
+
const live = new Map()
|
|
444
|
+
/** @type {Set<Promise<PersistentPtySession>>} */
|
|
445
|
+
const creating = new Set()
|
|
446
|
+
/** Owner → in-flight close promise. A session stays here until its close
|
|
447
|
+
* settles, so it still counts as activity while the PTY is being torn down
|
|
448
|
+
* and a failed close leaves the session retryable (the `live` entry is
|
|
449
|
+
* removed only on success). */
|
|
450
|
+
/** @type {Map<PersistentOwner, Promise<void>>} */
|
|
451
|
+
const closing = new Map()
|
|
452
|
+
/** Owner → Set of unpublished spawn reservations (official registry shape). @type {Map<PersistentOwner, Set<SpawnReservation>>} */
|
|
453
|
+
const pendingSpawns = new Map()
|
|
454
|
+
/** @type {WeakSet<PersistentOwner>} */
|
|
455
|
+
const ownerCleanupInstalled = new WeakSet()
|
|
456
|
+
/** @type {WeakSet<PersistentOwner>} */
|
|
457
|
+
const disposedOwners = new WeakSet()
|
|
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. */
|
|
460
|
+
const hasActivity = (owner) =>
|
|
461
|
+
pending.get(owner) !== undefined
|
|
462
|
+
|| (pendingSpawns.get(owner)?.size ?? 0) > 0
|
|
463
|
+
|| live.has(owner)
|
|
464
|
+
|| closing.has(owner)
|
|
465
|
+
|
|
466
|
+
/** Register the owner-scoped cleanup effect BEFORE the first spawn: on
|
|
467
|
+
* owner disposal it aborts and awaits pending creations, then closes the
|
|
468
|
+
* live session. Throws when the owner context is already disposed (no
|
|
469
|
+
* registration happens on a disposed context).
|
|
470
|
+
* @param {PersistentOwner} owner - the owner to install cleanup for. */
|
|
471
|
+
const ensureOwnerCleanup = (owner) => {
|
|
472
|
+
if (ownerCleanupInstalled.has(owner)) return
|
|
473
|
+
if (disposedOwners.has(owner)) {
|
|
474
|
+
throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
owner.ctx.effect(() => async () => {
|
|
478
|
+
disposedOwners.add(owner)
|
|
479
|
+
ownerCleanupInstalled.delete(owner)
|
|
480
|
+
await abortPendingSpawns(owner, 'persistent fish owner disposed')
|
|
481
|
+
await reset(owner, 'persistent fish owner disposed')
|
|
482
|
+
}, 'persistent fish owner cache cleanup')
|
|
483
|
+
} catch (error) {
|
|
484
|
+
// The owner fiber is already disposed (INACTIVE_EFFECT): fail the
|
|
485
|
+
// creation instead of leaving an untracked spawn behind.
|
|
486
|
+
disposedOwners.add(owner)
|
|
487
|
+
throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
|
|
488
|
+
}
|
|
489
|
+
ownerCleanupInstalled.add(owner)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** One unpublished spawn reservation: an AbortController plus a settlement
|
|
493
|
+
* the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
|
|
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. */
|
|
499
|
+
const reserveSpawn = (owner) => {
|
|
500
|
+
ensureOwnerCleanup(owner)
|
|
501
|
+
const controller = new AbortController()
|
|
502
|
+
/** @type {PromiseWithResolvers<void>} */
|
|
503
|
+
const settlement = Promise.withResolvers()
|
|
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). */
|
|
509
|
+
release() {
|
|
510
|
+
const owned = pendingSpawns.get(owner)
|
|
511
|
+
if (owned === undefined) return
|
|
512
|
+
owned.delete(pendingSpawn)
|
|
513
|
+
if (owned.size === 0) pendingSpawns.delete(owner)
|
|
514
|
+
settlement.resolve()
|
|
515
|
+
},
|
|
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() }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Abort every pending creation of one owner and await its settlement
|
|
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. */
|
|
531
|
+
const abortPendingSpawns = async (owner, reason) => {
|
|
532
|
+
const owned = pendingSpawns.get(owner)
|
|
533
|
+
if (owned === undefined) return
|
|
534
|
+
const pendingSpawnsSnapshot = [...owned]
|
|
535
|
+
for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.controller.abort(reason)
|
|
536
|
+
await Promise.allSettled(pendingSpawnsSnapshot.map((pendingSpawn) => pendingSpawn.settled))
|
|
537
|
+
for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.release()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Close one session, keeping it in `closing` (still counted as activity)
|
|
541
|
+
* until the close settles. On success the `live` entry is removed; on
|
|
542
|
+
* failure the session stays retryable and the failure is recorded, then
|
|
543
|
+
* rethrown so callers can retry. A second close of the same session awaits
|
|
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. */
|
|
550
|
+
const closeSession = async (owner, session, reason, onClosed) => {
|
|
551
|
+
if (closing.has(owner)) return closing.get(owner)
|
|
552
|
+
const promise = (async () => {
|
|
553
|
+
try {
|
|
554
|
+
await session.close(reason)
|
|
555
|
+
onClosed?.()
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.warn(`${MODULE_TAG}: could not close persistent fish session for owner ${owner?.id ?? '(unnamed)'}:`, String(error))
|
|
558
|
+
throw error
|
|
559
|
+
} finally {
|
|
560
|
+
closing.delete(owner)
|
|
561
|
+
}
|
|
562
|
+
})()
|
|
563
|
+
closing.set(owner, promise)
|
|
564
|
+
return promise
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const dispose = ctx.effect(() => async () => {
|
|
568
|
+
lifecycle.abort(new Error('persistent fish disposed during shell creation'))
|
|
569
|
+
await Promise.allSettled([...creating])
|
|
570
|
+
const owners = [...live.keys()]
|
|
571
|
+
await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
|
|
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. */
|
|
574
|
+
const reset = async (owner, reason) => {
|
|
575
|
+
pending.delete(owner)
|
|
576
|
+
const session = live.get(owner)
|
|
577
|
+
if (session === undefined) return
|
|
578
|
+
await closeSession(owner, session, reason, () => live.delete(owner))
|
|
579
|
+
}
|
|
580
|
+
/** @param {PersistentOwner} owner - the owner whose session is resolved. @param {AbortSignal} signal - caller cancellation. @returns {Promise<PersistentPtySession>} the live (lazily created) session. */
|
|
581
|
+
const get = (owner, signal) => {
|
|
582
|
+
const existing = pending.get(owner)
|
|
583
|
+
if (existing !== undefined) return existing
|
|
584
|
+
// Owner-scoped lifecycle BEFORE the first spawn: a reservation aborts
|
|
585
|
+
// this creation if the owner is disposed while it is in flight, and the
|
|
586
|
+
// owner-cleanup effect waits for it to settle (official registry
|
|
587
|
+
// semantics). A disposed owner fails fast instead of spawning.
|
|
588
|
+
let reservation
|
|
589
|
+
try {
|
|
590
|
+
reservation = reserveSpawn(owner)
|
|
591
|
+
} catch (error) {
|
|
592
|
+
return Promise.reject(error)
|
|
593
|
+
}
|
|
594
|
+
const combinedSignal = AbortSignal.any([signal, lifecycle.signal, reservation.signal])
|
|
595
|
+
const tracked = (async () => {
|
|
596
|
+
try {
|
|
597
|
+
const cwd = owner.session.header.cwd
|
|
598
|
+
// Self-managed activity check for the sandbox-mode fence (no
|
|
599
|
+
// terminals registry to ask); installed before the first spawn so
|
|
600
|
+
// the backend's own fence call reuses this entry.
|
|
601
|
+
ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
|
|
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,
|
|
606
|
+
owner,
|
|
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()),
|
|
611
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
612
|
+
signal: combinedSignal,
|
|
613
|
+
})
|
|
614
|
+
live.set(owner, spawned)
|
|
615
|
+
const result = await spawned.startSend({
|
|
616
|
+
text: 'stty -echo',
|
|
617
|
+
submit: true,
|
|
618
|
+
signal: combinedSignal,
|
|
619
|
+
}).done
|
|
620
|
+
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') throw new Error('persistent fish shell did not accept initialization')
|
|
621
|
+
return spawned
|
|
622
|
+
} catch (error) {
|
|
623
|
+
await reset(owner, 'persistent fish initialization failed')
|
|
624
|
+
throw error
|
|
625
|
+
} finally {
|
|
626
|
+
reservation.release()
|
|
627
|
+
}
|
|
628
|
+
})().finally(() => {
|
|
629
|
+
creating.delete(tracked)
|
|
630
|
+
})
|
|
631
|
+
creating.add(tracked)
|
|
632
|
+
pending.set(owner, tracked)
|
|
633
|
+
return tracked
|
|
634
|
+
}
|
|
635
|
+
return { get, reset, dispose }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Execute one wrapped command against the owner's persistent fish session,
|
|
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.
|
|
648
|
+
*/
|
|
649
|
+
async function executeCommand(ctx, shells, owner, command, config, upstream) {
|
|
650
|
+
const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
|
|
651
|
+
try {
|
|
652
|
+
const session = await shells.get(owner, commandDeadline.signal)
|
|
653
|
+
const marker = markers()
|
|
654
|
+
const wrapped = wrapCommand(command, marker)
|
|
655
|
+
let first = true
|
|
656
|
+
let fallback = ''
|
|
657
|
+
let fallbackTruncated = false
|
|
658
|
+
for (;;) {
|
|
659
|
+
const status = session.status()
|
|
660
|
+
if (status.kind === 'exited') return await respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config)
|
|
661
|
+
let operation
|
|
662
|
+
let result
|
|
663
|
+
try {
|
|
664
|
+
operation = session.startSend({
|
|
665
|
+
text: first ? wrapped : '',
|
|
666
|
+
submit: first,
|
|
667
|
+
signal: commandDeadline.signal,
|
|
668
|
+
})
|
|
669
|
+
first = false
|
|
670
|
+
result = await operation.done
|
|
671
|
+
} catch (error) {
|
|
672
|
+
await shells.reset(owner, 'persistent fish send failed')
|
|
673
|
+
throw error
|
|
674
|
+
}
|
|
675
|
+
const incremental = operation.readOutput()
|
|
676
|
+
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
|
|
677
|
+
fallbackTruncated ||= incremental.truncated || result.truncated
|
|
678
|
+
const latest = session.read({
|
|
679
|
+
offset: 0,
|
|
680
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
681
|
+
})
|
|
682
|
+
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
|
|
683
|
+
if (timedOut !== undefined) {
|
|
684
|
+
const partial = renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
|
|
685
|
+
await shells.reset(owner, 'persistent fish command timed out')
|
|
686
|
+
return [
|
|
687
|
+
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1e3)} seconds. Below is partial output:`,
|
|
688
|
+
appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
|
|
689
|
+
SHELL_RESET_MESSAGE,
|
|
690
|
+
].join('\n')
|
|
691
|
+
}
|
|
692
|
+
if (commandDeadline.signal.aborted) {
|
|
693
|
+
await shells.reset(owner, 'persistent fish command aborted')
|
|
694
|
+
commandDeadline.signal.throwIfAborted()
|
|
695
|
+
}
|
|
696
|
+
if (latest.text.includes(marker.end)) {
|
|
697
|
+
const complete = commandOutput(retainedScrollback(session, latest), marker)
|
|
698
|
+
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
|
699
|
+
}
|
|
700
|
+
if (result.sessionStatus.kind === 'exited') return await respondToSessionExit(session, shells, owner, result.sessionStatus, marker, fallback, fallbackTruncated, config)
|
|
701
|
+
if (result.waitReason === 'stdin_read') return renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
|
|
702
|
+
await pause()
|
|
703
|
+
}
|
|
704
|
+
} finally {
|
|
705
|
+
commandDeadline.dispose()
|
|
706
|
+
}
|
|
707
|
+
}
|
|
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
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Resolve the persistent tool configuration with defaults applied.
|
|
724
|
+
* @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
|
|
725
|
+
* fish executable).
|
|
726
|
+
* @returns {ResolvedPersistentConfig} the fully resolved configuration (validated).
|
|
727
|
+
*/
|
|
728
|
+
export function resolvePersistentConfig(config = {}) {
|
|
729
|
+
const resolved = {
|
|
730
|
+
backendType: config.backendType ?? 'fish',
|
|
731
|
+
timeoutMs: config.timeoutMs ?? 3e5,
|
|
732
|
+
maxOutputChars: config.maxOutputChars ?? 16e3,
|
|
733
|
+
description: config.description ?? DEFAULT_DESCRIPTION,
|
|
734
|
+
shellPath: config.shellPath,
|
|
735
|
+
shellArgs: config.shellArgs,
|
|
736
|
+
}
|
|
737
|
+
validatePersistentConfig(resolved)
|
|
738
|
+
return resolved
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Validate the resolved persistent configuration. `timeoutMs` is additionally
|
|
743
|
+
* bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
|
|
744
|
+
* the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
|
|
745
|
+
* @param {ResolvedPersistentConfig} resolved - the fully resolved configuration.
|
|
746
|
+
* @returns {void} throws on a value the tool cannot run with.
|
|
747
|
+
*/
|
|
748
|
+
export function validatePersistentConfig(resolved) {
|
|
749
|
+
if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
|
|
750
|
+
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) {
|
|
751
|
+
throw new Error(`persistent fish: timeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
752
|
+
}
|
|
753
|
+
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error('persistent fish: maxOutputChars must be a positive safe integer')
|
|
754
|
+
if (resolved.description.trim().length === 0) throw new Error('persistent fish: description must be non-empty')
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Register the model-facing persistent `fish` tool at the AGENT scope,
|
|
759
|
+
* shadowing the host-global one-shot `fish` tool. Intended to be called by
|
|
760
|
+
* `policy.js` from the agent boundary when the agent's standing bash tool is
|
|
761
|
+
* the persistent form (`minimal` preset).
|
|
762
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - the host context carrying `sandboxPolicy` and `subprocess`
|
|
763
|
+
* (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
|
|
764
|
+
* in the composition test profiles); `terminals` is deliberately NOT
|
|
765
|
+
* needed — sessions are self-managed through a `FishTerminalBackend`.
|
|
766
|
+
* @param {import('@deepseek-ai/cordis').Context} agentCtx - the agent's scoped context; the tool is registered here
|
|
767
|
+
* so the agent sees it (shadowing the host-global fish).
|
|
768
|
+
* @param {PersistentFishOverrides} [config] - optional overrides (backend type, deadline, output cap,
|
|
769
|
+
* fish executable).
|
|
770
|
+
* @returns {() => Promise<void> | undefined} the disposer; it unregisters the tool and returns a Promise that
|
|
771
|
+
* settles when the session cleanup has quiesced (policy.js awaits it
|
|
772
|
+
* out-of-band via `Promise.resolve(...).catch`).
|
|
773
|
+
*/
|
|
774
|
+
export function registerPersistentFish(ctx, agentCtx, config = {}) {
|
|
775
|
+
const resolved = resolvePersistentConfig(config)
|
|
776
|
+
|
|
777
|
+
const shells = persistentShells(ctx, resolved)
|
|
778
|
+
/** @type {WeakMap<PersistentOwner, Promise<void>>} */
|
|
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. */
|
|
781
|
+
const serialized = async (owner, operation) => {
|
|
782
|
+
const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
|
|
783
|
+
const tail = run.then(() => undefined, () => undefined)
|
|
784
|
+
queues.set(owner, tail)
|
|
785
|
+
try {
|
|
786
|
+
return await run
|
|
787
|
+
} finally {
|
|
788
|
+
if (queues.get(owner) === tail) queues.delete(owner)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const unregisterTool = agentCtx.tools.register(defineTool({
|
|
792
|
+
name: 'fish',
|
|
793
|
+
description: resolved.description,
|
|
794
|
+
parameters: {
|
|
795
|
+
command: {
|
|
796
|
+
type: 'string',
|
|
797
|
+
required: true,
|
|
798
|
+
description: 'The fish command to run. State persists across calls: the current directory, exported variables, and defined functions. Relative paths are preferred.',
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
output: {
|
|
802
|
+
schema: { type: 'string' },
|
|
803
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
804
|
+
},
|
|
805
|
+
async execute(args, exec) {
|
|
806
|
+
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
|
|
807
|
+
const owner = exec.agent
|
|
808
|
+
if (owner === undefined) throw new Error('fish requires an owning agent session')
|
|
809
|
+
return serialized(owner, async () => {
|
|
810
|
+
exec.signal.throwIfAborted()
|
|
811
|
+
return executeCommand(ctx, shells, owner, args.command, resolved, exec.signal)
|
|
812
|
+
})
|
|
813
|
+
},
|
|
814
|
+
presentCall: (args) => ({
|
|
815
|
+
card: 'terminal',
|
|
816
|
+
title: args.command,
|
|
817
|
+
}),
|
|
818
|
+
}))
|
|
819
|
+
let disposed = false
|
|
820
|
+
const dispose = () => {
|
|
821
|
+
if (disposed) return undefined
|
|
822
|
+
disposed = true
|
|
823
|
+
unregisterTool()
|
|
824
|
+
// Awaitable cleanup: the caller may wait for the PTY teardown to
|
|
825
|
+
// quiesce, and failures are recorded instead of becoming unhandled.
|
|
826
|
+
return Promise.resolve(shells.dispose()).catch((error) => {
|
|
827
|
+
console.warn('persistent fish cleanup failed:', String(error))
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
agentCtx.effect(() => dispose, 'persistent fish tool cleanup')
|
|
831
|
+
return dispose
|
|
832
|
+
}
|