@banbolee/dsh-fish-shell 0.4.0 → 0.5.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 +151 -41
- package/cordis.patch.yml +39 -32
- package/package.json +40 -4
- package/persistent.js +639 -0
- package/policy.js +346 -0
- package/terminal-fish.js +317 -0
- package/tool.js +347 -111
- package/presets/fish/agent.cordis.yml +0 -241
package/persistent.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
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 { defineTool } from '@deepseek-ai/dsh-tools'
|
|
60
|
+
import { FishTerminalBackend, ensureSandboxModeFence, resolveConfig as resolveTerminalConfig } from './terminal-fish.js'
|
|
61
|
+
|
|
62
|
+
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
|
+
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
|
+
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.'
|
|
65
|
+
const TIMEOUT_STATUS_MARKER = '[Command timed out]'
|
|
66
|
+
export const TIMEOUT_CODE = 'PERSISTENT_FISH_TIMEOUT'
|
|
67
|
+
/** Largest delay Node schedules without clamping it to one millisecond
|
|
68
|
+
* (mirrors `@deepseek-ai/dsh-timeout`'s `MAX_TIMER_DELAY_MS`). */
|
|
69
|
+
export const MAX_TIMER_DELAY_MS = 2147483647
|
|
70
|
+
const MODULE_TAG = '@banbolee/dsh-fish-shell'
|
|
71
|
+
const SCROLLBACK_PAGE_LINES = 1e3
|
|
72
|
+
const POLL_INTERVAL_MS = 25
|
|
73
|
+
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.'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Abort reason carrying this module's timeout code and elapsed deadline.
|
|
77
|
+
* Mirrors `@deepseek-ai/dsh-timeout`'s `TimeoutReason` (the module is NOT a
|
|
78
|
+
* peer dependency here: the minimal deadline implementation below is
|
|
79
|
+
* self-contained).
|
|
80
|
+
*/
|
|
81
|
+
export class TimeoutReason extends Error {
|
|
82
|
+
code
|
|
83
|
+
timeoutMs
|
|
84
|
+
name = 'TimeoutReason'
|
|
85
|
+
constructor(code, timeoutMs) {
|
|
86
|
+
super(`${code} after ${timeoutMs}ms`)
|
|
87
|
+
this.code = code
|
|
88
|
+
this.timeoutMs = timeoutMs
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fuse upstream cancellation with an identifiable timeout, without pulling
|
|
94
|
+
* 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.
|
|
99
|
+
*/
|
|
100
|
+
export function deadline(upstream, timeoutMs, code) {
|
|
101
|
+
if (timeoutMs <= 0) {
|
|
102
|
+
return { signal: upstream ?? new AbortController().signal, dispose() {} }
|
|
103
|
+
}
|
|
104
|
+
// Node clamps delays above 2^31-1 to ~1ms with a TimeoutOverflowWarning;
|
|
105
|
+
// reject such deadlines instead of aborting immediately.
|
|
106
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs > MAX_TIMER_DELAY_MS) {
|
|
107
|
+
throw new Error(`deadline timeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
108
|
+
}
|
|
109
|
+
const timer = new AbortController()
|
|
110
|
+
const id = setTimeout(() => {
|
|
111
|
+
timer.abort(new TimeoutReason(code, timeoutMs))
|
|
112
|
+
}, timeoutMs)
|
|
113
|
+
return {
|
|
114
|
+
signal: upstream !== undefined ? AbortSignal.any([upstream, timer.signal]) : timer.signal,
|
|
115
|
+
dispose() {
|
|
116
|
+
clearTimeout(id)
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 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`.
|
|
126
|
+
*/
|
|
127
|
+
export function timeoutOf(x, code) {
|
|
128
|
+
const reason = x?.reason
|
|
129
|
+
if (!(reason instanceof TimeoutReason)) return undefined
|
|
130
|
+
return code === undefined || reason.code === code ? reason : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function maybeTruncate(content, maxOutputChars, incomplete = false) {
|
|
134
|
+
if (content.length <= maxOutputChars && !incomplete) return content
|
|
135
|
+
return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function markers() {
|
|
139
|
+
const nonce = randomUUID()
|
|
140
|
+
return {
|
|
141
|
+
start: `__DSH_PERSISTENT_FISH_START_${nonce}__`,
|
|
142
|
+
end: `__DSH_PERSISTENT_FISH_END_${nonce}:`,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Quote a value as one fish single-quoted token. fish single-quoted strings
|
|
148
|
+
* only escape `\\` and `\'`, so backslashes are doubled first, then quotes —
|
|
149
|
+
* 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.
|
|
152
|
+
*/
|
|
153
|
+
export function quoteForFish(value) {
|
|
154
|
+
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wrap a command so its output and exit status are delimited by unique
|
|
159
|
+
* markers. fish 4's `eval` evaluates one command string (no `--`), and
|
|
160
|
+
* `$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.
|
|
164
|
+
*/
|
|
165
|
+
export function wrapCommand(command, marker) {
|
|
166
|
+
return `printf '%s\\n' ${quoteForFish(marker.start)}; eval ${quoteForFish(command)}; set __dsh_status $status; printf '%s%s\\n' ${quoteForFish(marker.end)} "$__dsh_status"`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function trimTrailingNewline(text) {
|
|
170
|
+
return text.replace(/(?:\r?\n)+$/, '')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 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.
|
|
178
|
+
*/
|
|
179
|
+
export function commandOutput(snapshot, marker) {
|
|
180
|
+
const text = snapshot.text
|
|
181
|
+
const end = text.lastIndexOf(marker.end)
|
|
182
|
+
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
|
|
183
|
+
if (status === undefined) return undefined
|
|
184
|
+
const startMarker = text.lastIndexOf(marker.start, end)
|
|
185
|
+
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
|
|
186
|
+
return {
|
|
187
|
+
text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')),
|
|
188
|
+
incomplete: startMarker < 0,
|
|
189
|
+
exitCode: Number(status),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Render the not-yet-complete output after a start marker, falling back to
|
|
195
|
+
* the incremental send delta when the scrollback lost the start.
|
|
196
|
+
*/
|
|
197
|
+
export function partialOutput(snapshot, marker, fallback, fallbackTruncated = false) {
|
|
198
|
+
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
|
199
|
+
if (startMarker >= 0) {
|
|
200
|
+
return {
|
|
201
|
+
text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
|
202
|
+
incomplete: false,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const fallbackStart = fallback.lastIndexOf(marker.start)
|
|
206
|
+
const afterStart = fallbackStart < 0 ? fallback : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
|
|
207
|
+
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
|
208
|
+
return {
|
|
209
|
+
text: trimTrailingNewline(fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)),
|
|
210
|
+
incomplete: fallbackTruncated || fallbackStart < 0,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function pause() {
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function nextScrollbackOffset(page, offset) {
|
|
219
|
+
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
|
|
220
|
+
return page.lineEnd
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function retainedScrollback(session, latest = session.read({
|
|
224
|
+
offset: 0,
|
|
225
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
226
|
+
})) {
|
|
227
|
+
const pages = latest.text.length === 0 ? [] : [latest.text]
|
|
228
|
+
let offset = latest.lineEnd
|
|
229
|
+
let truncated = latest.truncated
|
|
230
|
+
for (;;) {
|
|
231
|
+
if (offset >= latest.totalLines) break
|
|
232
|
+
const page = session.read({
|
|
233
|
+
offset,
|
|
234
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
235
|
+
})
|
|
236
|
+
truncated ||= page.truncated
|
|
237
|
+
if (page.text.length > 0) pages.unshift(page.text)
|
|
238
|
+
const next = nextScrollbackOffset(page, offset)
|
|
239
|
+
if (next === undefined || next >= page.totalLines) break
|
|
240
|
+
offset = next
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
text: pages.join('\n'),
|
|
244
|
+
truncated,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function renderCaptured(output, maxOutputChars) {
|
|
249
|
+
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
|
|
250
|
+
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
|
+
}
|
|
252
|
+
|
|
253
|
+
export function appendStatusMarker(content, marker) {
|
|
254
|
+
if (marker === undefined) return content
|
|
255
|
+
return content.length === 0 ? marker : `${content}\n${marker}`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function renderShellExitStatus(content, exitCode, signal) {
|
|
259
|
+
return appendStatusMarker(content, signal !== null ? `[shell killed by signal: ${signal}]` : exitCode !== null ? `[shell exited: code ${exitCode}]` : '[shell exited]')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the exited-session result, reset the owner's shell, and carry the
|
|
264
|
+
* notice that the next call starts fresh.
|
|
265
|
+
*/
|
|
266
|
+
async function respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config) {
|
|
267
|
+
const snapshot = retainedScrollback(session)
|
|
268
|
+
await shells.reset(owner, 'persistent fish shell exited')
|
|
269
|
+
return [renderShellExitStatus(renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), status.exitCode, status.signal), SHELL_RESET_MESSAGE].filter((part) => part.length > 0).join('\n')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Per-owner persistent fish PTY cache with lazy creation and reset, driving
|
|
274
|
+
* one shared `FishTerminalBackend` directly (self-managed: no `terminals`
|
|
275
|
+
* registry, which is invisible at the host/agent plane). The registry's
|
|
276
|
+
* kill is `session.close(reason)`, so close/reset map straight to the
|
|
277
|
+
* session; the sandbox-mode fence gets an injectable owner-activity check
|
|
278
|
+
* backed by this module's own owner→session tracking.
|
|
279
|
+
*
|
|
280
|
+
* Owner lifecycle mirrors the official `@deepseek-ai/dsh-terminal` registry:
|
|
281
|
+
* the owner-scoped cleanup effect is installed BEFORE the first spawn, each
|
|
282
|
+
* creation carries a reservation (AbortController + settlement) merged into
|
|
283
|
+
* the spawn/startup signal, and owner disposal aborts and awaits pending
|
|
284
|
+
* creations before closing live sessions — so a spawn racing an owner
|
|
285
|
+
* disposal cannot outlive its owner or register on a disposed context.
|
|
286
|
+
*
|
|
287
|
+
* A session being closed is tracked in a `closing` set until close actually
|
|
288
|
+
* settles: it still counts as activity (the sandbox-mode fence keeps
|
|
289
|
+
* refusing mode changes while the old PTY is being torn down), a failed
|
|
290
|
+
* close keeps the session retryable instead of losing the handle, and a
|
|
291
|
+
* re-entrant close awaits the in-flight one.
|
|
292
|
+
*
|
|
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
|
+
* defaults to a real `FishTerminalBackend` constructed against `ctx`
|
|
297
|
+
* (injectable so tests can fake spawn/close).
|
|
298
|
+
*/
|
|
299
|
+
export function persistentShells(ctx, config, backend = new FishTerminalBackend(ctx, resolveTerminalConfig({
|
|
300
|
+
backendType: config.backendType,
|
|
301
|
+
...(config.shellPath !== undefined ? { shellPath: config.shellPath } : {}),
|
|
302
|
+
...(config.shellArgs !== undefined ? { shellArgs: config.shellArgs } : {}),
|
|
303
|
+
}), (spec) => ctx.get('subprocess').spawnTerminal(spec))) {
|
|
304
|
+
const pending = new WeakMap()
|
|
305
|
+
const live = new Map()
|
|
306
|
+
const creating = new Set()
|
|
307
|
+
/** Owner → in-flight close promise. A session stays here until its close
|
|
308
|
+
* settles, so it still counts as activity while the PTY is being torn down
|
|
309
|
+
* and a failed close leaves the session retryable (the `live` entry is
|
|
310
|
+
* removed only on success). */
|
|
311
|
+
const closing = new Map()
|
|
312
|
+
/** Owner → Set of unpublished spawn reservations (official registry shape). */
|
|
313
|
+
const pendingSpawns = new Map()
|
|
314
|
+
const ownerCleanupInstalled = new WeakSet()
|
|
315
|
+
const disposedOwners = new WeakSet()
|
|
316
|
+
const lifecycle = new AbortController()
|
|
317
|
+
const hasActivity = (owner) =>
|
|
318
|
+
pending.get(owner) !== undefined
|
|
319
|
+
|| (pendingSpawns.get(owner)?.size ?? 0) > 0
|
|
320
|
+
|| live.has(owner)
|
|
321
|
+
|| closing.has(owner)
|
|
322
|
+
|
|
323
|
+
/** Register the owner-scoped cleanup effect BEFORE the first spawn: on
|
|
324
|
+
* owner disposal it aborts and awaits pending creations, then closes the
|
|
325
|
+
* live session. Throws when the owner context is already disposed (no
|
|
326
|
+
* registration happens on a disposed context). */
|
|
327
|
+
const ensureOwnerCleanup = (owner) => {
|
|
328
|
+
if (ownerCleanupInstalled.has(owner)) return
|
|
329
|
+
if (disposedOwners.has(owner)) {
|
|
330
|
+
throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
owner.ctx.effect(() => async () => {
|
|
334
|
+
disposedOwners.add(owner)
|
|
335
|
+
ownerCleanupInstalled.delete(owner)
|
|
336
|
+
await abortPendingSpawns(owner, 'persistent fish owner disposed')
|
|
337
|
+
await reset(owner, 'persistent fish owner disposed')
|
|
338
|
+
}, 'persistent fish owner cache cleanup')
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// The owner fiber is already disposed (INACTIVE_EFFECT): fail the
|
|
341
|
+
// creation instead of leaving an untracked spawn behind.
|
|
342
|
+
disposedOwners.add(owner)
|
|
343
|
+
throw new Error(`persistent fish owner ${owner.id ?? '(unnamed)'} is no longer live`)
|
|
344
|
+
}
|
|
345
|
+
ownerCleanupInstalled.add(owner)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** One unpublished spawn reservation: an AbortController plus a settlement
|
|
349
|
+
* the owner-cleanup path awaits, tracked owner→Set so `hasActivity` sees
|
|
350
|
+
* creations before they are published. */
|
|
351
|
+
const reserveSpawn = (owner) => {
|
|
352
|
+
ensureOwnerCleanup(owner)
|
|
353
|
+
const controller = new AbortController()
|
|
354
|
+
const settlement = Promise.withResolvers()
|
|
355
|
+
const pendingSpawn = { owner, controller, settled: settlement.promise }
|
|
356
|
+
let owned = pendingSpawns.get(owner)
|
|
357
|
+
if (owned === undefined) {
|
|
358
|
+
owned = new Set()
|
|
359
|
+
pendingSpawns.set(owner, owned)
|
|
360
|
+
}
|
|
361
|
+
owned.add(pendingSpawn)
|
|
362
|
+
return {
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
release() {
|
|
365
|
+
owned.delete(pendingSpawn)
|
|
366
|
+
if (owned.size === 0) pendingSpawns.delete(owner)
|
|
367
|
+
settlement.resolve()
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Abort every pending creation of one owner and await its settlement
|
|
373
|
+
* (idempotent with each creation's own `release`). */
|
|
374
|
+
const abortPendingSpawns = async (owner, reason) => {
|
|
375
|
+
const owned = pendingSpawns.get(owner)
|
|
376
|
+
if (owned === undefined) return
|
|
377
|
+
const pendingSpawnsSnapshot = [...owned]
|
|
378
|
+
for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.controller.abort(reason)
|
|
379
|
+
await Promise.allSettled(pendingSpawnsSnapshot.map((pendingSpawn) => pendingSpawn.settled))
|
|
380
|
+
for (const pendingSpawn of pendingSpawnsSnapshot) pendingSpawn.release()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Close one session, keeping it in `closing` (still counted as activity)
|
|
384
|
+
* until the close settles. On success the `live` entry is removed; on
|
|
385
|
+
* failure the session stays retryable and the failure is recorded, then
|
|
386
|
+
* rethrown so callers can retry. A second close of the same session awaits
|
|
387
|
+
* the in-flight one instead of stacking another close. */
|
|
388
|
+
const closeSession = async (owner, session, reason, onClosed) => {
|
|
389
|
+
if (closing.has(owner)) return closing.get(owner)
|
|
390
|
+
const promise = (async () => {
|
|
391
|
+
try {
|
|
392
|
+
await session.close(reason)
|
|
393
|
+
onClosed?.()
|
|
394
|
+
} catch (error) {
|
|
395
|
+
console.warn(`${MODULE_TAG}: could not close persistent fish session for owner ${owner?.id ?? '(unnamed)'}:`, String(error))
|
|
396
|
+
throw error
|
|
397
|
+
} finally {
|
|
398
|
+
closing.delete(owner)
|
|
399
|
+
}
|
|
400
|
+
})()
|
|
401
|
+
closing.set(owner, promise)
|
|
402
|
+
return promise
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const dispose = ctx.effect(() => async () => {
|
|
406
|
+
lifecycle.abort(new Error('persistent fish disposed during shell creation'))
|
|
407
|
+
await Promise.allSettled([...creating])
|
|
408
|
+
const owners = [...live.keys()]
|
|
409
|
+
await Promise.allSettled(owners.map((owner) => reset(owner, 'persistent fish disposed')))
|
|
410
|
+
}, 'persistent fish shell cleanup')
|
|
411
|
+
const reset = async (owner, reason) => {
|
|
412
|
+
pending.delete(owner)
|
|
413
|
+
const session = live.get(owner)
|
|
414
|
+
if (session === undefined) return
|
|
415
|
+
await closeSession(owner, session, reason, () => live.delete(owner))
|
|
416
|
+
}
|
|
417
|
+
const get = (owner, signal) => {
|
|
418
|
+
const existing = pending.get(owner)
|
|
419
|
+
if (existing !== undefined) return existing
|
|
420
|
+
// Owner-scoped lifecycle BEFORE the first spawn: a reservation aborts
|
|
421
|
+
// this creation if the owner is disposed while it is in flight, and the
|
|
422
|
+
// owner-cleanup effect waits for it to settle (official registry
|
|
423
|
+
// semantics). A disposed owner fails fast instead of spawning.
|
|
424
|
+
let reservation
|
|
425
|
+
try {
|
|
426
|
+
reservation = reserveSpawn(owner)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return Promise.reject(error)
|
|
429
|
+
}
|
|
430
|
+
const combinedSignal = AbortSignal.any([signal, lifecycle.signal, reservation.signal])
|
|
431
|
+
const tracked = (async () => {
|
|
432
|
+
try {
|
|
433
|
+
const cwd = owner.session.header.cwd
|
|
434
|
+
// Self-managed activity check for the sandbox-mode fence (no
|
|
435
|
+
// terminals registry to ask); installed before the first spawn so
|
|
436
|
+
// the backend's own fence call reuses this entry.
|
|
437
|
+
ensureSandboxModeFence(ctx, owner, () => hasActivity(owner))
|
|
438
|
+
const spawned = await backend.spawn({
|
|
439
|
+
owner,
|
|
440
|
+
sessionId: randomUUID(),
|
|
441
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
442
|
+
signal: combinedSignal,
|
|
443
|
+
})
|
|
444
|
+
live.set(owner, spawned)
|
|
445
|
+
const result = await spawned.startSend({
|
|
446
|
+
text: 'stty -echo',
|
|
447
|
+
submit: true,
|
|
448
|
+
signal: combinedSignal,
|
|
449
|
+
}).done
|
|
450
|
+
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') throw new Error('persistent fish shell did not accept initialization')
|
|
451
|
+
return spawned
|
|
452
|
+
} catch (error) {
|
|
453
|
+
await reset(owner, 'persistent fish initialization failed')
|
|
454
|
+
throw error
|
|
455
|
+
} finally {
|
|
456
|
+
reservation.release()
|
|
457
|
+
}
|
|
458
|
+
})().finally(() => {
|
|
459
|
+
creating.delete(tracked)
|
|
460
|
+
})
|
|
461
|
+
creating.add(tracked)
|
|
462
|
+
pending.set(owner, tracked)
|
|
463
|
+
return tracked
|
|
464
|
+
}
|
|
465
|
+
return { get, reset, dispose }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Execute one wrapped command against the owner's persistent fish session,
|
|
470
|
+
* reading the marker-delimited result from the scrollback.
|
|
471
|
+
*/
|
|
472
|
+
async function executeCommand(ctx, shells, owner, command, config, upstream) {
|
|
473
|
+
const commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
|
|
474
|
+
try {
|
|
475
|
+
const session = await shells.get(owner, commandDeadline.signal)
|
|
476
|
+
const marker = markers()
|
|
477
|
+
const wrapped = wrapCommand(command, marker)
|
|
478
|
+
let first = true
|
|
479
|
+
let fallback = ''
|
|
480
|
+
let fallbackTruncated = false
|
|
481
|
+
for (;;) {
|
|
482
|
+
const status = session.status()
|
|
483
|
+
if (status.kind === 'exited') return await respondToSessionExit(session, shells, owner, status, marker, fallback, fallbackTruncated, config)
|
|
484
|
+
let operation
|
|
485
|
+
let result
|
|
486
|
+
try {
|
|
487
|
+
operation = session.startSend({
|
|
488
|
+
text: first ? wrapped : '',
|
|
489
|
+
submit: first,
|
|
490
|
+
signal: commandDeadline.signal,
|
|
491
|
+
})
|
|
492
|
+
first = false
|
|
493
|
+
result = await operation.done
|
|
494
|
+
} catch (error) {
|
|
495
|
+
await shells.reset(owner, 'persistent fish send failed')
|
|
496
|
+
throw error
|
|
497
|
+
}
|
|
498
|
+
const incremental = operation.readOutput()
|
|
499
|
+
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
|
|
500
|
+
fallbackTruncated ||= incremental.truncated || result.truncated
|
|
501
|
+
const latest = session.read({
|
|
502
|
+
offset: 0,
|
|
503
|
+
count: SCROLLBACK_PAGE_LINES,
|
|
504
|
+
})
|
|
505
|
+
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
|
|
506
|
+
if (timedOut !== undefined) {
|
|
507
|
+
const partial = renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
|
|
508
|
+
await shells.reset(owner, 'persistent fish command timed out')
|
|
509
|
+
return [
|
|
510
|
+
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1e3)} seconds. Below is partial output:`,
|
|
511
|
+
appendStatusMarker(partial, TIMEOUT_STATUS_MARKER),
|
|
512
|
+
SHELL_RESET_MESSAGE,
|
|
513
|
+
].join('\n')
|
|
514
|
+
}
|
|
515
|
+
if (commandDeadline.signal.aborted) {
|
|
516
|
+
await shells.reset(owner, 'persistent fish command aborted')
|
|
517
|
+
commandDeadline.signal.throwIfAborted()
|
|
518
|
+
}
|
|
519
|
+
if (latest.text.includes(marker.end)) {
|
|
520
|
+
const complete = commandOutput(retainedScrollback(session, latest), marker)
|
|
521
|
+
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
|
522
|
+
}
|
|
523
|
+
if (result.sessionStatus.kind === 'exited') return await respondToSessionExit(session, shells, owner, result.sessionStatus, marker, fallback, fallbackTruncated, config)
|
|
524
|
+
if (result.waitReason === 'stdin_read') return renderCaptured(partialOutput(retainedScrollback(session, latest), marker, fallback, fallbackTruncated), config.maxOutputChars)
|
|
525
|
+
await pause()
|
|
526
|
+
}
|
|
527
|
+
} finally {
|
|
528
|
+
commandDeadline.dispose()
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Resolve the persistent tool configuration with defaults applied.
|
|
534
|
+
* @param config - optional overrides (backend type, deadline, output cap,
|
|
535
|
+
* fish executable).
|
|
536
|
+
* @returns the fully resolved configuration (validated).
|
|
537
|
+
*/
|
|
538
|
+
export function resolvePersistentConfig(config = {}) {
|
|
539
|
+
const resolved = {
|
|
540
|
+
backendType: config.backendType ?? 'fish',
|
|
541
|
+
timeoutMs: config.timeoutMs ?? 3e5,
|
|
542
|
+
maxOutputChars: config.maxOutputChars ?? 16e3,
|
|
543
|
+
description: config.description ?? DEFAULT_DESCRIPTION,
|
|
544
|
+
shellPath: config.shellPath,
|
|
545
|
+
shellArgs: config.shellArgs,
|
|
546
|
+
}
|
|
547
|
+
validatePersistentConfig(resolved)
|
|
548
|
+
return resolved
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Validate the resolved persistent configuration. `timeoutMs` is additionally
|
|
553
|
+
* bounded by {@link MAX_TIMER_DELAY_MS}: a larger value would make Node clamp
|
|
554
|
+
* the timer to ~1ms (TimeoutOverflowWarning) and abort almost immediately.
|
|
555
|
+
* @param resolved - the fully resolved configuration.
|
|
556
|
+
*/
|
|
557
|
+
export function validatePersistentConfig(resolved) {
|
|
558
|
+
if (resolved.backendType.trim().length === 0) throw new Error('persistent fish: backendType must be non-empty')
|
|
559
|
+
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0 || resolved.timeoutMs > MAX_TIMER_DELAY_MS) {
|
|
560
|
+
throw new Error(`persistent fish: timeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
561
|
+
}
|
|
562
|
+
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error('persistent fish: maxOutputChars must be a positive safe integer')
|
|
563
|
+
if (resolved.description.trim().length === 0) throw new Error('persistent fish: description must be non-empty')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Register the model-facing persistent `fish` tool at the AGENT scope,
|
|
568
|
+
* shadowing the host-global one-shot `fish` tool. Intended to be called by
|
|
569
|
+
* `policy.js` from the agent boundary when the agent's standing bash tool is
|
|
570
|
+
* the persistent form (`minimal` preset).
|
|
571
|
+
* @param ctx - the host context carrying `sandboxPolicy` and `subprocess`
|
|
572
|
+
* (resolvable from the policy plugin ctx in dsh-base/TUI host planes and
|
|
573
|
+
* in the composition test profiles); `terminals` is deliberately NOT
|
|
574
|
+
* needed — sessions are self-managed through a `FishTerminalBackend`.
|
|
575
|
+
* @param agentCtx - the agent's scoped context; the tool is registered here
|
|
576
|
+
* so the agent sees it (shadowing the host-global fish).
|
|
577
|
+
* @param config - optional overrides (backend type, deadline, output cap,
|
|
578
|
+
* fish executable).
|
|
579
|
+
* @returns the disposer; it unregisters the tool and returns a Promise that
|
|
580
|
+
* settles when the session cleanup has quiesced (policy.js awaits it
|
|
581
|
+
* out-of-band via `Promise.resolve(...).catch`).
|
|
582
|
+
*/
|
|
583
|
+
export function registerPersistentFish(ctx, agentCtx, config = {}) {
|
|
584
|
+
const resolved = resolvePersistentConfig(config)
|
|
585
|
+
|
|
586
|
+
const shells = persistentShells(ctx, resolved)
|
|
587
|
+
const queues = new WeakMap()
|
|
588
|
+
const serialized = async (owner, operation) => {
|
|
589
|
+
const run = (queues.get(owner) ?? Promise.resolve()).then(operation, operation)
|
|
590
|
+
const tail = run.then(() => undefined, () => undefined)
|
|
591
|
+
queues.set(owner, tail)
|
|
592
|
+
try {
|
|
593
|
+
return await run
|
|
594
|
+
} finally {
|
|
595
|
+
if (queues.get(owner) === tail) queues.delete(owner)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const unregisterTool = agentCtx.tools.register(defineTool({
|
|
599
|
+
name: 'fish',
|
|
600
|
+
description: resolved.description,
|
|
601
|
+
parameters: {
|
|
602
|
+
command: {
|
|
603
|
+
type: 'string',
|
|
604
|
+
required: true,
|
|
605
|
+
description: 'The fish command to run. State persists across calls: the current directory, exported variables, and defined functions. Relative paths are preferred.',
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
output: {
|
|
609
|
+
schema: { type: 'string' },
|
|
610
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
611
|
+
},
|
|
612
|
+
async execute(args, exec) {
|
|
613
|
+
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
|
|
614
|
+
const owner = exec.agent
|
|
615
|
+
if (owner === undefined) throw new Error('fish requires an owning agent session')
|
|
616
|
+
return serialized(owner, async () => {
|
|
617
|
+
exec.signal.throwIfAborted()
|
|
618
|
+
return executeCommand(ctx, shells, owner, args.command, resolved, exec.signal)
|
|
619
|
+
})
|
|
620
|
+
},
|
|
621
|
+
presentCall: (args) => ({
|
|
622
|
+
card: 'terminal',
|
|
623
|
+
title: args.command,
|
|
624
|
+
}),
|
|
625
|
+
}))
|
|
626
|
+
let disposed = false
|
|
627
|
+
const dispose = () => {
|
|
628
|
+
if (disposed) return undefined
|
|
629
|
+
disposed = true
|
|
630
|
+
unregisterTool()
|
|
631
|
+
// Awaitable cleanup: the caller may wait for the PTY teardown to
|
|
632
|
+
// quiesce, and failures are recorded instead of becoming unhandled.
|
|
633
|
+
return Promise.resolve(shells.dispose()).catch((error) => {
|
|
634
|
+
console.warn('persistent fish cleanup failed:', String(error))
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
agentCtx.effect(() => dispose, 'persistent fish tool cleanup')
|
|
638
|
+
return dispose
|
|
639
|
+
}
|