@lkyhfx/dsh-bb-repl 0.0.0-stage → 0.1.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.
@@ -0,0 +1,103 @@
1
+ (ns domain.runtime
2
+ "Session-scoped runtime observability, loaded automatically by bb-repl.
3
+
4
+ This namespace answers the questions a persistent-REPL agent actually has:
5
+ where does the workspace live, which definitions already exist, what does
6
+ each mounted domain report, and how do I deliberately drop a broken session.
7
+ It is a thin layer over babashka.fs / babashka.process: no policy of its own."
8
+ (:require [babashka.fs :as fs]
9
+ [babashka.process :as p]
10
+ [clojure.string :as str])
11
+ (:import [java.io File]))
12
+
13
+ (def ^:private workspace-root
14
+ (let [root (System/getenv "DSH_BB_WORKSPACE_ROOT")]
15
+ (when (str/blank? root)
16
+ (throw (ex-info "Workspace root not set by DSH" {:code :missing-root})))
17
+ (.toPath (.getCanonicalFile (File. root)))))
18
+
19
+ (defonce ^:private registry (atom {}))
20
+
21
+ (defn register!
22
+ "Register one mounted domain. Called by the bb-repl host, not by the agent."
23
+ [{:keys [id version state-version instructions status]}]
24
+ (swap! registry assoc id (cond-> {:id id
25
+ :version (str version)
26
+ :state-version (str state-version)
27
+ :instructions (str instructions)}
28
+ status (assoc :status status)))
29
+ id)
30
+
31
+ (defn workspace
32
+ "Absolute path of the workspace this session is scoped to.
33
+ Raw Babashka process/fs calls start in a private scratch directory;
34
+ pass (workspace) explicitly as :dir / fs/path when you need the project."
35
+ []
36
+ (str workspace-root))
37
+
38
+ (defn path
39
+ "Join workspace-relative segments into an absolute path without touching disk."
40
+ [& segments]
41
+ (str (apply fs/path (str workspace-root) (map str segments))))
42
+
43
+ (defn shell
44
+ "Run a command with the workspace as working directory.
45
+ (shell {:out :string} \"git\" \"status\" \"--short\")"
46
+ [opts & args]
47
+ (apply p/shell (merge {:dir (str workspace-root)} opts) args))
48
+
49
+ (defn domains
50
+ "Ids of the domain namespaces mounted into this session."
51
+ []
52
+ (vec (sort (keys @registry))))
53
+
54
+ (defn- user-var?
55
+ [n]
56
+ (let [s (name n)]
57
+ (and (not (str/starts-with? s "*")) (not (str/starts-with? s "->")))))
58
+
59
+ (defn vars
60
+ "Agent-defined public vars in the user namespace, with arglists and docstrings.
61
+ Use this before redefining something that may already exist."
62
+ []
63
+ (->> (ns-publics 'user)
64
+ (filter (fn [[n _]] (user-var? n)))
65
+ (map (fn [[n v]]
66
+ (let [m (meta v)]
67
+ (cond-> {:name n}
68
+ (:arglists m) (assoc :arglists (:arglists m))
69
+ (:doc m) (assoc :doc (:doc m))))))
70
+ (sort-by :name)
71
+ vec))
72
+
73
+ (defn status
74
+ "One map with the workspace, the mounted domains, each domain's own counters,
75
+ and how many definitions this session currently holds. Read-only."
76
+ []
77
+ {:workspace (str workspace-root)
78
+ :domains (into (sorted-map)
79
+ (map (fn [[id d]] [id (if-let [f (:status d)] (f) {:version (:version d)})])
80
+ @registry))
81
+ :defs (count (vars))})
82
+
83
+ (defn help
84
+ "Domain API guide. No argument lists every mounted domain; a domain id
85
+ (:code / \"code\") prints that domain's guide."
86
+ ([] (into (sorted-map)
87
+ (map (fn [[id d]] [id {:version (:version d)
88
+ :state-version (:state-version d)
89
+ :instructions (:instructions d)}]))
90
+ @registry))
91
+ ([id]
92
+ (let [d (get @registry (if (keyword? id) (name id) (str id)))]
93
+ (when-not d (throw (ex-info (str "Unknown domain " id) {:domains (domains)})))
94
+ (:instructions d))))
95
+
96
+ (defn reset!
97
+ "Deliberately discard this session: definitions, atoms and domain counters.
98
+ The host starts a fresh interpreter on the next call. Use it when the REPL
99
+ holds state you know to be wrong, instead of working around it."
100
+ ([] (reset! "requested by the agent"))
101
+ ([reason]
102
+ (throw (ex-info (str "DSH_BB_RESET: " (or reason "requested by the agent"))
103
+ {:code :dsh.bb/reset-requested :reason (str reason)}))))
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@lkyhfx/dsh-bb-repl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DSH Cordis plugin: owner-scoped persistent Babashka nREPL tool (bb_eval)",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {".": "./src/index.js"},
8
+ "files": ["src", "bb-src"],
9
+ "keywords": ["dsh", "cordis", "babashka", "nrepl", "clojure", "repl"],
10
+ "repository": {"type": "git", "url": "git+https://github.com/lkyhfx/dsh-bb-repl.git", "directory": "packages/repl"},
11
+ "publishConfig": {"access": "public"},
12
+ "peerDependencies": {
13
+ "@deepseek-ai/cordis": "*",
14
+ "@deepseek-ai/dsh-tools": "*"
15
+ },
16
+ "dependencies": {"@deepseek-ai/schemastery": "*"}
17
+ }
package/src/env.js ADDED
@@ -0,0 +1,37 @@
1
+ /** Child environment policy for the Babashka runtime. */
2
+
3
+ // Same credential-shaped scrub the DSH subprocess seam applies to every child
4
+ // (`SENSITIVE_ENV_PATTERN` in @deepseek-ai/dsh-subprocess): the interpreter must
5
+ // not expose the host's API keys to agent-authored Clojure.
6
+ const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
7
+ const DSH_ENV_PREFIX = 'DSH_'
8
+
9
+ /**
10
+ * The parent environment minus credential-shaped names and minus every `DSH_*`
11
+ * name. PATH, HOME, locale and proxy variables survive so `bb`, `rg`, `git` and
12
+ * `diff` keep working; Harness identity and credentials never leak implicitly.
13
+ * Harness-owned facts are re-added explicitly by the caller.
14
+ */
15
+ export function scrubbedChildEnv(parent = process.env) {
16
+ const env = {}
17
+ for (const [key, value] of Object.entries(parent)) {
18
+ if (value === undefined) continue
19
+ if (SENSITIVE_ENV_PATTERN.test(key)) continue
20
+ if (key.toUpperCase().startsWith(DSH_ENV_PREFIX)) continue
21
+ env[key] = value
22
+ }
23
+ return env
24
+ }
25
+
26
+ /** Explicit, non-secret facts this runtime hands to its Babashka child. */
27
+ export function runtimeChildEnv({ workspace, scratch, port }) {
28
+ return {
29
+ ...scrubbedChildEnv(),
30
+ DSH_BB_WORKSPACE_ROOT: workspace,
31
+ DSH_BB_SCRATCH: scratch,
32
+ DSH_BB_NREPL_PORT: String(port),
33
+ PAGER: 'cat',
34
+ GIT_PAGER: 'cat',
35
+ NO_COLOR: '1',
36
+ }
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,221 @@
1
+ /** DSH Cordis plugin: one owner-scoped persistent Babashka nREPL tool. */
2
+ import { resolve } from 'node:path'
3
+ import z from '@deepseek-ai/schemastery'
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import { DomainSessions } from './sessions.js'
6
+
7
+ export const name = 'bb-repl'
8
+ // `sandbox` and `sandboxPolicy` are resolved through ctx.get at each session
9
+ // start rather than injected: they are optional (config `sandbox: 'off'` runs
10
+ // unconfined for tests and for deployments that accept that), and Cordis'
11
+ // array `inject` can only express required services.
12
+ export const inject = ['tools', 'workingDirectory']
13
+
14
+ const GUIDE = `Execute Clojure programs in a persistent Babashka REPL isolated to this Agent.
15
+
16
+ PERSISTENCE:
17
+ - Functions defined with defn and values defined with def persist across calls
18
+ in the same Agent session.
19
+ - let bindings are local to one expression; they do not survive a call.
20
+ - Use defonce and atoms when mutable persistent state is actually required.
21
+ - Inspect existing definitions before recreating them: (domain/vars).
22
+ - Ordinary Clojure evaluation errors do NOT reset the REPL: a var defined
23
+ before the error is still there, so fix the form and keep going.
24
+ - Timeouts, cancellations, transport failures and incompatible domain updates
25
+ DO reset the session. The call result says "session reset" or "session
26
+ rebuilt" when that happened, and then nothing is left but domain state.
27
+ - Inspect (domain/status) or (domain/vars) whenever state is uncertain.
28
+
29
+ COMPOSITION:
30
+ - Compose domain primitives with let, do, ->, ->>, map, filter, reduce and the
31
+ rest of clojure.core.
32
+ - Prefer one evaluation for a predictable multi-step task; return a compact
33
+ final value, not bulk file content.
34
+ - Reuse functions and intermediate results across calls instead of repeating
35
+ searches and reads.
36
+ - Use domain primitives for workspace file operations; they carry the guards.
37
+
38
+ DISCOVERY:
39
+ - (domain/status) -> workspace, mounted domains, their counters, def count.
40
+ - (domain/help) or (domain/help :code) -> the domain API guides.
41
+ - (domain/vars) -> agent-defined vars with arglists and docstrings.
42
+ - (code/status) -> code-domain counters and workspace.
43
+ - (meta #'code/search) -> signature and docstring of one primitive.
44
+
45
+ STATE RESET:
46
+ - (domain/reset! "why") deliberately discards this session's definitions and
47
+ starts a fresh interpreter on the next call. Prefer it over working around
48
+ known-bad state.
49
+
50
+ SAFETY:
51
+ - Clojure is dynamically typed; type errors surface at runtime.
52
+ - Code runs with the configured runtime permissions. Never perform
53
+ unauthorized destructive operations.`
54
+
55
+ export const Config = z.object({
56
+ bbExecutable: z.string().default('bb'),
57
+ timeoutMs: z.number().default(120000),
58
+ startupMs: z.number().default(12000),
59
+ maxOutputChars: z.number().default(16000),
60
+ sandbox: z.union([z.const('inherit'), z.const('off')]).default('inherit'),
61
+ description: z.string().default(GUIDE),
62
+ })
63
+
64
+ const clip = (s, n) => s.length <= n ? s : `${s.slice(0, n)}\n[Output truncated; narrow your query]`
65
+
66
+ export function apply(ctx, config = {}) {
67
+ const cfg = {
68
+ bbExecutable: config.bbExecutable ?? 'bb',
69
+ timeoutMs: config.timeoutMs ?? 120000,
70
+ startupMs: config.startupMs ?? 12000,
71
+ maxOutputChars: config.maxOutputChars ?? 16000,
72
+ sandbox: config.sandbox ?? 'inherit',
73
+ description: config.description ?? GUIDE,
74
+ }
75
+ for (const field of ['timeoutMs', 'startupMs', 'maxOutputChars']) {
76
+ if (!Number.isSafeInteger(cfg[field]) || cfg[field] < 1) throw new Error(`bb-repl: invalid ${field}`)
77
+ }
78
+ if (cfg.sandbox !== 'inherit' && cfg.sandbox !== 'off') throw new Error('bb-repl: sandbox must be "inherit" or "off"')
79
+
80
+ const modules = new Map()
81
+
82
+ const policyService = () => ctx.get?.('sandboxPolicy') ?? ctx.sandboxPolicy
83
+ const sandboxService = () => ctx.get?.('sandbox') ?? ctx.sandbox
84
+
85
+ /**
86
+ * The session's file-effect policy, or a synthetic one when confinement is off.
87
+ *
88
+ * `key` is the reuse identity of a live interpreter: it covers every input
89
+ * that changes what the *running* process is allowed to touch, so a revoked or
90
+ * widened grant can never be served by a process started under the old one.
91
+ * DSH's `SandboxExecutionPolicy` carries `additionalWritableRoots` and
92
+ * `sessionId` besides mode/root, so both are part of the key.
93
+ */
94
+ const resolvePolicy = (owner, workspace) => {
95
+ if (cfg.sandbox === 'off') return { mode: 'danger-full-access', key: 'unconfined' }
96
+ const service = policyService()
97
+ if (!service) {
98
+ throw new Error('bb-repl: sandbox mode "inherit" requires ctx.sandboxPolicy; mount @deepseek-ai/dsh-sandbox-policy or set config sandbox: "off"')
99
+ }
100
+ const resolved = service.resolve({ session: owner.session })
101
+ // The Agent's working directory IS the workspace-write boundary; the policy
102
+ // root is only the agentless fallback, matching ctx.sandboxPolicy semantics.
103
+ const root = resolve(workspace ?? resolved.workspaceRoot)
104
+ const extraRoots = [...new Set((resolved.additionalWritableRoots ?? []).map(r => resolve(r)))].sort()
105
+ const sessionId = resolved.sessionId === undefined ? '' : String(resolved.sessionId)
106
+ const key = [resolved.mode, root, ...extraRoots, sessionId].join('\u0000')
107
+ return { ...resolved, workspaceRoot: root, additionalWritableRoots: extraRoots, key }
108
+ }
109
+
110
+ /**
111
+ * Wrap the interpreter argv under the session's policy, mirroring the DSH
112
+ * sandbox contract: `danger-full-access` spawns the original argv, any other
113
+ * mode must be enforced or the start fails closed.
114
+ */
115
+ const confineArgv = async ({ argv, workspaceRoot, owner, policy, signal }) => {
116
+ const resolved = policy ?? resolvePolicy(owner, workspaceRoot)
117
+ if (resolved.mode === 'danger-full-access') return { argv, policy: resolved }
118
+ const sandbox = sandboxService()
119
+ if (!sandbox) {
120
+ throw new Error(`bb-repl: sandbox mode "${resolved.mode}" is requested but no sandbox backend is usable in this composition; refusing to run Babashka unconfined. Set config sandbox: "off" only if you accept unconfined code execution.`)
121
+ }
122
+ const confined = await sandbox.confine(argv, { ...resolved, mode: resolved.mode }, signal)
123
+ if (!confined?.argv?.length) throw new Error('bb-repl: sandbox returned an empty argv')
124
+ return { argv: confined.argv, policy: resolved, enforcement: confined.enforcement }
125
+ }
126
+
127
+ const sessions = new DomainSessions({
128
+ bbExecutable: cfg.bbExecutable,
129
+ timeoutMs: cfg.timeoutMs,
130
+ startupMs: cfg.startupMs,
131
+ modules: () => [...modules.values()],
132
+ onLog: message => ctx.logger.warn(message),
133
+ confine: confineArgv,
134
+ policyFor: resolvePolicy,
135
+ })
136
+
137
+ let disposeTool
138
+ const buildDescription = () => [
139
+ cfg.description,
140
+ ...[...modules.values()].map(m => `DOMAIN ${m.id}:\n${m.instructions}`),
141
+ ].join('\n\n')
142
+
143
+ const bindTool = () => {
144
+ disposeTool?.()
145
+ disposeTool = ctx.tools.register(defineTool({
146
+ name: 'bb_eval',
147
+ description: buildDescription(),
148
+ parameters: {
149
+ code: { type: 'string', required: true, description: 'Clojure forms to execute in the persistent Babashka session.' },
150
+ },
151
+ output: {
152
+ schema: { type: 'string' },
153
+ render: (_args, value) => [{ type: 'text', text: value }],
154
+ },
155
+ async execute(args, exec) {
156
+ if (typeof args.code !== 'string' || !args.code.trim()) throw new Error('code is required')
157
+ if (!exec.agent) throw new Error('bb_eval requires an owning Agent')
158
+ const workspace = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
159
+ try {
160
+ const result = await sessions.evaluate(exec.agent, workspace, args.code, exec.signal)
161
+ const notes = result.notes ?? []
162
+ const body = [
163
+ notes.length ? `runtime:\n${notes.join('\n')}` : '',
164
+ result.out ? `stdout:\n${result.out}` : '',
165
+ result.value ? `value:\n${result.value}` : '',
166
+ result.err ? `stderr:\n${result.err}` : '',
167
+ result.exception ? `exception: ${result.exception}` : '',
168
+ result.error ? `error: ${result.error.code}` : '',
169
+ `status: ${result.ok ? 'ok' : 'error'}`,
170
+ ].filter(Boolean).join('\n')
171
+ return clip(body, cfg.maxOutputChars)
172
+ } catch (error) {
173
+ if (exec.signal.aborted) return '' // Host handles ABORTED.
174
+ const notes = error.notes?.length ? ` (${error.notes.join('; ')})` : ''
175
+ throw new Error(`Babashka runtime: ${error.message}${notes}. The session was reset; re-run with a fresh interpreter.`)
176
+ }
177
+ },
178
+ presentCall: args => ({ card: 'terminal', title: args.code.slice(0, 160) }),
179
+ }))
180
+ }
181
+
182
+ const registry = {
183
+ /**
184
+ * Register one domain manifest. A changed `version`/`stateVersion` for an
185
+ * already mounted id replaces it: live sessions rebuild before the next
186
+ * evaluation and the tool description is rebound.
187
+ */
188
+ register(manifest) {
189
+ const required = ['id', 'instructions']
190
+ for (const field of required) {
191
+ if (!manifest?.[field]) throw new Error(`Domain manifest requires ${required.join(', ')}`)
192
+ }
193
+ if (!manifest.classpath && !manifest.init) throw new Error(`Domain ${manifest.id} requires classpath or init`)
194
+ const next = {
195
+ ...manifest,
196
+ version: manifest.version ?? '0.0.0',
197
+ stateVersion: manifest.stateVersion ?? 0,
198
+ }
199
+ const previous = modules.get(next.id)
200
+ if (previous && previous.version === next.version && previous.stateVersion === next.stateVersion
201
+ && previous.classpath === next.classpath && previous.init === next.init
202
+ && previous.instructions === next.instructions) {
203
+ throw new Error(`Duplicate Babashka domain: ${next.id}@${next.version}`)
204
+ }
205
+ modules.set(next.id, next)
206
+ bindTool() // Tool description doubles as the SKILL.md-equivalent guide.
207
+ return () => {
208
+ if (modules.get(next.id) === next) modules.delete(next.id)
209
+ bindTool()
210
+ }
211
+ },
212
+ list: () => [...modules.values()].map(({ id, version, stateVersion, instructions }) => ({ id, version, stateVersion, instructions })),
213
+ }
214
+
215
+ ctx.provide('bbDomain', registry)
216
+ bindTool()
217
+ ctx.effect(() => async () => {
218
+ disposeTool?.()
219
+ await sessions.close()
220
+ }, 'bb-repl owner session cleanup')
221
+ }
package/src/nrepl.js ADDED
@@ -0,0 +1,221 @@
1
+ import net from 'node:net'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ // Minimal bencode implementation for the nREPL wire protocol. No runtime deps.
5
+ export function encode(value) {
6
+ if (typeof value === 'number') return Buffer.from(`i${value}e`)
7
+ if (typeof value === 'string') {
8
+ const b = Buffer.from(value, 'utf8')
9
+ return Buffer.concat([Buffer.from(`${b.length}:`), b])
10
+ }
11
+ if (Array.isArray(value)) return Buffer.concat([Buffer.from('l'), ...value.map(encode), Buffer.from('e')])
12
+ if (value && typeof value === 'object') {
13
+ const chunks = [Buffer.from('d')]
14
+ for (const key of Object.keys(value).sort()) {
15
+ if (value[key] === undefined || value[key] === null) continue
16
+ chunks.push(encode(key), encode(value[key]))
17
+ }
18
+ return Buffer.concat([...chunks, Buffer.from('e')])
19
+ }
20
+ throw new TypeError('Unsupported bencode value')
21
+ }
22
+
23
+ const MORE = Symbol('incomplete bencode frame')
24
+
25
+ /** Text bytes one nREPL response contributes to a request's accumulated output. */
26
+ function responseBytes(item) {
27
+ let total = 0
28
+ for (const field of ['out', 'err', 'value', 'ex']) {
29
+ const text = item[field]
30
+ if (typeof text === 'string') total += Buffer.byteLength(text)
31
+ }
32
+ return total
33
+ }
34
+
35
+ function parse(input, from = 0) {
36
+ if (from >= input.length) throw MORE
37
+ const marker = input[from]
38
+ if (marker === 105) { // i
39
+ const end = input.indexOf(101, from + 1)
40
+ if (end < 0) throw MORE
41
+ const raw = input.toString('ascii', from + 1, end)
42
+ if (!/^-?(0|[1-9][0-9]*)$/.test(raw)) throw new Error('Invalid bencode integer')
43
+ return [Number(raw), end + 1]
44
+ }
45
+ if (marker === 108 || marker === 100) { // l or d
46
+ const entries = []
47
+ let next = from + 1
48
+ while (true) {
49
+ if (next >= input.length) throw MORE
50
+ if (input[next] === 101) break
51
+ const [item, end] = parse(input, next)
52
+ entries.push(item)
53
+ next = end
54
+ }
55
+ if (marker === 108) return [entries, next + 1]
56
+ if (entries.length % 2 !== 0) throw new Error('Odd number of bencode dictionary entries')
57
+ const dict = {}
58
+ for (let i = 0; i < entries.length; i += 2) {
59
+ if (typeof entries[i] !== 'string' || ['__proto__', 'prototype', 'constructor'].includes(entries[i])) {
60
+ throw new Error('Invalid bencode dictionary key')
61
+ }
62
+ dict[entries[i]] = entries[i + 1]
63
+ }
64
+ return [dict, next + 1]
65
+ }
66
+ if (marker >= 48 && marker <= 57) {
67
+ const colon = input.indexOf(58, from)
68
+ if (colon < 0) throw MORE
69
+ const raw = input.toString('ascii', from, colon)
70
+ if (!/^(0|[1-9][0-9]*)$/.test(raw)) throw new Error('Invalid bencode string length')
71
+ const count = Number(raw)
72
+ if (!Number.isSafeInteger(count) || count > 32 * 1024 * 1024) throw new Error('nREPL string exceeds limit')
73
+ const end = colon + 1 + count
74
+ if (end > input.length) throw MORE
75
+ return [input.toString('utf8', colon + 1, end), end]
76
+ }
77
+ throw new Error('Invalid bencode marker')
78
+ }
79
+
80
+ export function decodeFrames(buffer) {
81
+ const results = []
82
+ let pos = 0
83
+ while (pos < buffer.length) {
84
+ try {
85
+ const [message, end] = parse(buffer, pos)
86
+ results.push(message)
87
+ pos = end
88
+ } catch (error) {
89
+ if (error === MORE) break
90
+ throw error
91
+ }
92
+ }
93
+ return { messages: results, rest: buffer.subarray(pos) }
94
+ }
95
+
96
+ // nREPL status values that mean the evaluation did not complete.
97
+ const FAILURES = ['eval-error', 'namespace-not-found', 'error', 'unknown-op', 'session-missing']
98
+
99
+ export class NReplClient {
100
+ /**
101
+ * @param socket - the connected nREPL socket.
102
+ * @param maxBufferBytes - cap for undecoded bytes waiting in the frame buffer.
103
+ * @param maxResponseBytes - cap for the text accumulated across the responses
104
+ * of ONE request. Without it a program that keeps printing never finishes
105
+ * collecting, because the caps on the frame buffer and on the final tool
106
+ * output both apply only after the whole reply has been buffered.
107
+ */
108
+ constructor(socket, maxBufferBytes = 16 * 1024 * 1024, maxResponseBytes = 4 * 1024 * 1024) {
109
+ this.socket = socket
110
+ this.maxBufferBytes = maxBufferBytes
111
+ this.maxResponseBytes = maxResponseBytes
112
+ this.buffer = Buffer.alloc(0)
113
+ this.pending = new Map()
114
+ socket.on('data', bytes => this.onData(bytes))
115
+ socket.on('error', err => this.failAll(err))
116
+ socket.on('close', () => this.failAll(new Error('nREPL socket closed')))
117
+ }
118
+
119
+ /**
120
+ * Connect to an nREPL endpoint.
121
+ * @param port - loopback port the server listens on.
122
+ * @param signal - optional cancellation while connecting.
123
+ * @param options - optional `maxBufferBytes` / `maxResponseBytes` overrides.
124
+ */
125
+ static async connect(port, signal, options = {}) {
126
+ return await new Promise((resolve, reject) => {
127
+ const socket = net.createConnection({ host: '127.0.0.1', port })
128
+ let settled = false
129
+ const cleanup = () => signal?.removeEventListener('abort', onAbort)
130
+ const done = (fn, value) => {
131
+ if (settled) return
132
+ settled = true
133
+ cleanup()
134
+ fn(value)
135
+ }
136
+ const onAbort = () => {
137
+ socket.destroy()
138
+ done(reject, signal.reason ?? new Error('Aborted'))
139
+ }
140
+ signal?.addEventListener('abort', onAbort, { once: true })
141
+ socket.once('connect', () => done(resolve, new NReplClient(socket, options.maxBufferBytes, options.maxResponseBytes)))
142
+ socket.once('error', e => done(reject, e))
143
+ if (signal?.aborted) onAbort()
144
+ })
145
+ }
146
+
147
+ onData(data) {
148
+ try {
149
+ this.buffer = Buffer.concat([this.buffer, data])
150
+ if (this.buffer.length > this.maxBufferBytes) throw new Error('nREPL response exceeds buffer limit')
151
+ const { messages, rest } = decodeFrames(this.buffer)
152
+ this.buffer = Buffer.from(rest)
153
+ for (const item of messages) {
154
+ const wait = this.pending.get(item.id)
155
+ if (!wait) continue
156
+ wait.parts.push(item)
157
+ wait.bytes += responseBytes(item)
158
+ if (wait.bytes > this.maxResponseBytes) {
159
+ // Stop the flood for good: this request cannot be salvaged and the
160
+ // interpreter's state is unknown, so the caller resets the session.
161
+ this.pending.delete(item.id)
162
+ const error = new Error(`nREPL response exceeded ${this.maxResponseBytes} bytes; evaluation aborted`)
163
+ error.code = 'NREPL_OUTPUT_LIMIT'
164
+ error.truncated = true
165
+ wait.reject(error)
166
+ this.socket.destroy(error)
167
+ this.failAll(error)
168
+ return
169
+ }
170
+ if (Array.isArray(item.status) && item.status.includes('done')) {
171
+ this.pending.delete(item.id)
172
+ wait.resolve(wait.parts)
173
+ }
174
+ }
175
+ } catch (err) {
176
+ this.socket.destroy(err)
177
+ this.failAll(err)
178
+ }
179
+ }
180
+
181
+ failAll(error) {
182
+ for (const item of this.pending.values()) item.reject(error)
183
+ this.pending.clear()
184
+ }
185
+
186
+ request(fields) {
187
+ const id = randomUUID()
188
+ return new Promise((resolve, reject) => {
189
+ if (this.socket.destroyed) return reject(new Error('nREPL connection is closed'))
190
+ this.pending.set(id, { resolve, reject, parts: [], bytes: 0 })
191
+ try { this.socket.write(encode({ ...fields, id })) }
192
+ catch (e) { this.pending.delete(id); reject(e) }
193
+ })
194
+ }
195
+
196
+ async cloneSession() {
197
+ const responses = await this.request({ op: 'clone' })
198
+ const session = responses.find(x => x['new-session'])?.['new-session']
199
+ if (!session) throw new Error('nREPL server did not return a session id')
200
+ return session
201
+ }
202
+
203
+ async eval(session, code) {
204
+ const responses = await this.request({ op: 'eval', session, code })
205
+ const statuses = responses.flatMap(x => Array.isArray(x.status) ? x.status : [])
206
+ const failure = statuses.find(x => FAILURES.includes(x))
207
+ const err = responses.map(x => x.err ?? '').join('')
208
+ return {
209
+ ok: failure === undefined,
210
+ value: responses.filter(x => typeof x.value === 'string').map(x => x.value).join('\n'),
211
+ out: responses.map(x => x.out ?? '').join(''),
212
+ err,
213
+ exception: responses.find(x => x.ex)?.ex,
214
+ status: [...new Set(statuses)],
215
+ // Machine-readable failure identity, so callers do not have to parse text.
216
+ error: failure === undefined ? undefined : { code: failure, message: err.trim().split('\n')[0] ?? '' },
217
+ }
218
+ }
219
+
220
+ close() { this.socket.destroy() }
221
+ }
@@ -0,0 +1,475 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createServer } from 'node:net'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, delimiter, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
7
+ import { NReplClient } from './nrepl.js'
8
+ import { runtimeChildEnv } from './env.js'
9
+
10
+ // domain.runtime ships with this package; a caller may add more, never less.
11
+ const runtimeClasspath = resolve(dirname(fileURLToPath(import.meta.url)), '../bb-src')
12
+
13
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
14
+
15
+ /** How long a session may take to die after SIGTERM before SIGKILL. */
16
+ const TERM_GRACE_MS = 1500
17
+ const GROUP_POLL_MS = 50
18
+
19
+ /**
20
+ * Check whether the interpreter's process group still contains a runnable
21
+ * process. Linux /proc lets us ignore zombies (they cannot execute or write).
22
+ * Other POSIX systems use kill(0) against the group; Windows can only observe
23
+ * the direct child here.
24
+ */
25
+ async function processGroupAlive(proc) {
26
+ if (!proc.pid) return false
27
+ if (process.platform === 'win32') return proc.exitCode === null && proc.signalCode === null
28
+ if (proc.exitCode === null && proc.signalCode === null) return true
29
+ if (process.platform === 'linux') {
30
+ try {
31
+ const pids = (await readdir('/proc')).filter(pid => /^\d+$/.test(pid))
32
+ const stats = await Promise.all(pids.map(pid =>
33
+ readFile(`/proc/${pid}/stat`, 'utf8').catch(() => null)))
34
+ let inspected = false
35
+ for (const stat of stats) {
36
+ if (!stat) continue
37
+ inspected = true
38
+ const close = stat.lastIndexOf(')')
39
+ if (close < 0) continue
40
+ const fields = stat.slice(close + 2).trim().split(/\s+/)
41
+ if (Number(fields[2]) === proc.pid && fields[0] !== 'Z' && fields[0] !== 'X') return true
42
+ }
43
+ if (inspected) return false
44
+ } catch {
45
+ // /proc may be unavailable in restricted containers; use kill(0) below.
46
+ }
47
+ }
48
+ try {
49
+ process.kill(-proc.pid, 0)
50
+ return true
51
+ } catch (error) {
52
+ if (error.code === 'ESRCH') return false
53
+ if (error.code === 'EPERM') return true
54
+ throw error
55
+ }
56
+ }
57
+
58
+ /** Signal the whole POSIX process group, even if its original leader has exited. */
59
+ function signalTree(proc, signal) {
60
+ if (process.platform !== 'win32' && proc.pid) {
61
+ try { process.kill(-proc.pid, signal) }
62
+ catch (error) { if (error.code !== 'ESRCH') throw error }
63
+ return
64
+ }
65
+ if (proc.exitCode === null && proc.signalCode === null) {
66
+ try { proc.kill(signal) } catch (error) {
67
+ if (error.code !== 'ESRCH') throw error
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Wait for the process group (not merely its leader) to stop running. */
73
+ async function waitForTreeExit(proc, graceMs) {
74
+ const deadline = Date.now() + graceMs
75
+ while (await processGroupAlive(proc)) {
76
+ if (Date.now() >= deadline) return false
77
+ await sleep(GROUP_POLL_MS)
78
+ }
79
+ return true
80
+ }
81
+
82
+ /**
83
+ * Terminate one interpreter's process group: SIGTERM, wait for every runnable
84
+ * member to exit, then SIGKILL any survivors. The main child exiting is not
85
+ * sufficient: background children may outlive it.
86
+ */
87
+ async function terminateTree(proc, onLog) {
88
+ if (!await processGroupAlive(proc)) return
89
+ signalTree(proc, 'SIGTERM')
90
+ if (await waitForTreeExit(proc, TERM_GRACE_MS)) return
91
+ onLog?.('Babashka process group still had live members after SIGTERM; sending SIGKILL')
92
+ signalTree(proc, 'SIGKILL')
93
+ if (!await waitForTreeExit(proc, TERM_GRACE_MS)) {
94
+ throw new Error('Babashka process group could not be terminated; refusing to start a new session')
95
+ }
96
+ }
97
+ const q = value => JSON.stringify(value) // strings use Clojure-compatible JSON escaping
98
+
99
+ /** Stable identity of a mounted domain implementation, used to detect hot swaps. */
100
+ const signature = m => [m.version ?? '', m.stateVersion ?? '', m.classpath ?? '', m.init ?? ''].join('\u0000')
101
+ const label = m => `${m.id}@${m.version ?? '0.0.0'}`
102
+
103
+ async function freeLocalPort() {
104
+ const server = createServer()
105
+ try {
106
+ await new Promise((resolve, reject) => {
107
+ server.once('error', reject)
108
+ server.listen(0, '127.0.0.1', resolve)
109
+ })
110
+ return server.address().port
111
+ } finally { await new Promise(r => server.close(r)) }
112
+ }
113
+
114
+ /**
115
+ * Per-Agent Babashka + nREPL sessions.
116
+ *
117
+ * Everything about *how* the child process is launched (sandbox confinement,
118
+ * environment policy) is supplied by the plugin: this class only decides when a
119
+ * session starts, which domains it carries, and when it must be rebuilt.
120
+ */
121
+ export class DomainSessions {
122
+ constructor({
123
+ bbExecutable = 'bb', startupMs = 12000, timeoutMs = 120000, moduleSyncMs,
124
+ runtimeClasspath: extraRuntimeClasspath = '', modules, onLog = () => {}, confine, policyFor,
125
+ }) {
126
+ this.bbExecutable = bbExecutable
127
+ this.startupMs = startupMs
128
+ this.timeoutMs = timeoutMs
129
+ // Hot-loading a domain runs real Clojure before the user code deadline
130
+ // starts; bound it so a domain that hangs cannot block the Agent forever.
131
+ this.moduleSyncMs = moduleSyncMs ?? Math.min(startupMs, 30000)
132
+ this.runtimeClasspath = [runtimeClasspath, extraRuntimeClasspath].filter(Boolean).join(delimiter)
133
+ this.modules = modules
134
+ this.onLog = onLog
135
+ this.confine = confine
136
+ // Resolved per evaluation so a session-mode change rebuilds the process.
137
+ this.policyFor = policyFor
138
+ this.live = new Map()
139
+ this.pending = new WeakMap()
140
+ this.queues = new WeakMap()
141
+ this.creating = new Set()
142
+ // Owners already wired to a lifecycle cleanup; a rebuilt session must not
143
+ // register a second disposer for the same Agent.
144
+ this.owned = new WeakSet()
145
+ this.disposed = false
146
+ }
147
+
148
+ spawnArgv(manifests, port) {
149
+ const classpath = [this.runtimeClasspath, ...manifests.map(m => m.classpath)].filter(Boolean).join(delimiter)
150
+ return [this.bbExecutable, ...(classpath ? ['--classpath', classpath] : []), 'nrepl-server', `127.0.0.1:${port}`]
151
+ }
152
+
153
+ /** Register a mounted domain with domain.runtime so the Agent can inspect it. */
154
+ domainRegistration(m) {
155
+ const fields = [
156
+ `:id ${q(m.id)}`,
157
+ `:version ${q(String(m.version ?? '0.0.0'))}`,
158
+ `:state-version ${q(String(m.stateVersion ?? 0))}`,
159
+ `:instructions ${q(m.instructions)}`,
160
+ ]
161
+ if (m.statusVar) fields.push(`:status (requiring-resolve '${m.statusVar})`)
162
+ return `(domain.runtime/register! {${fields.join(' ')}})`
163
+ }
164
+
165
+ async start(owner, workspace, signal, policy) {
166
+ if (this.disposed) throw new Error('Babashka runtime disposed')
167
+ signal?.throwIfAborted()
168
+ const scratch = await mkdtemp(join(tmpdir(), 'dsh-bb-'))
169
+ const port = await freeLocalPort()
170
+ const manifests = this.modules()
171
+ const argv = this.spawnArgv(manifests, port)
172
+ let launched = { argv, policy: policy ?? { mode: 'danger-full-access' } }
173
+ try {
174
+ if (this.confine) launched = await this.confine({ argv, workspaceRoot: resolve(workspace), owner, policy, signal })
175
+ signal?.throwIfAborted()
176
+ } catch (error) {
177
+ await rm(scratch, { recursive: true, force: true })
178
+ throw error
179
+ }
180
+ const proc = spawn(launched.argv[0], launched.argv.slice(1), {
181
+ cwd: scratch,
182
+ env: runtimeChildEnv({ workspace: resolve(workspace), scratch, port }),
183
+ stdio: ['ignore', 'pipe', 'pipe'],
184
+ windowsHide: true,
185
+ // Own process group, so termination reaches the JVM and every child it
186
+ // forked (rg, git, diff, shells) instead of just the JVM.
187
+ detached: process.platform !== 'win32',
188
+ })
189
+ let startupLog = ''
190
+ let exitError
191
+ const append = data => { startupLog = (startupLog + String(data)).slice(-4000) }
192
+ proc.stdout.on('data', append)
193
+ proc.stderr.on('data', append)
194
+ proc.on('error', err => { exitError = err })
195
+ proc.on('exit', (code, sig) => { exitError = new Error(`Babashka exited (code ${code}, signal ${sig})`) })
196
+ const session = {
197
+ proc, client: undefined, sessionId: '', workspace: resolve(workspace), scratch,
198
+ modules: new Map(), policyKey: policy?.key ?? 'unconfined', sandbox: launched.policy ?? null,
199
+ enforcement: launched.enforcement,
200
+ }
201
+ // An unexpected leader exit does not prove its children are gone. Run the
202
+ // same process-group shutdown path used by reset() before releasing scratch.
203
+ proc.once('exit', () => {
204
+ void this.stopSession(owner, session).catch(error => this.onLog(String(error?.message ?? error)))
205
+ })
206
+ let client
207
+ try {
208
+ const deadline = Date.now() + this.startupMs
209
+ while (Date.now() < deadline) {
210
+ if (signal?.aborted) throw signal.reason ?? new Error('Cancelled')
211
+ if (exitError) throw exitError
212
+ try { client = await NReplClient.connect(port, signal); break }
213
+ catch (e) { if (signal?.aborted) throw e; await sleep(80) }
214
+ }
215
+ if (!client) throw new Error(`nREPL startup timeout: ${startupLog}`)
216
+ session.client = client
217
+ await this.bounded(async () => {
218
+ session.sessionId = await client.cloneSession()
219
+ const init = [
220
+ // `domain` is the session runtime facade; the alias lives in the
221
+ // session's own namespace so the Agent can call (domain/status).
222
+ `(require '[domain.runtime :as domain])`,
223
+ ...manifests.map(m => m.init).filter(Boolean),
224
+ ...manifests.map(m => this.domainRegistration(m)),
225
+ ':ready',
226
+ ]
227
+ const res = await client.eval(session.sessionId, `(do ${init.join(' ')})`)
228
+ if (!res.ok) throw new Error(`Domain namespace initialization failed: ${res.err || res.exception || res.status.join(', ')}`)
229
+ for (const m of manifests) session.modules.set(m.id, { signature: signature(m), version: m.version })
230
+ }, this.startupMs, 'nREPL session initialization', signal)
231
+ if (proc.exitCode !== null || proc.signalCode !== null || session.reaped) {
232
+ throw exitError ?? new Error('Babashka exited during session initialization')
233
+ }
234
+ this.live.set(owner, session)
235
+ if (!this.owned.has(owner)) {
236
+ this.owned.add(owner)
237
+ owner.ctx?.effect?.(() => () => { void this.reset(owner).catch(error => this.onLog(String(error?.message ?? error))) }, 'dsh-bb owner session cleanup')
238
+ }
239
+ return session
240
+ } catch (error) {
241
+ client?.close()
242
+ const message = `${error.message}${startupLog ? `\n${startupLog}` : ''}`
243
+ try {
244
+ await this.stopSession(owner, session)
245
+ } catch (cleanupError) {
246
+ // Keep an unkillable session registered: ensure() will retry cleanup and
247
+ // must never start another interpreter beside this unknown process group.
248
+ this.live.set(owner, session)
249
+ throw new Error(`${message}; cleanup failed: ${cleanupError.message}`)
250
+ }
251
+ throw new Error(message)
252
+ }
253
+ }
254
+
255
+ /** Stop a session once; concurrent exit/reset paths share the same promise. */
256
+ async stopSession(owner, session) {
257
+ if (session.cleanupPromise) return session.cleanupPromise
258
+ const cleanup = (async () => {
259
+ session.client?.close()
260
+ await terminateTree(session.proc, this.onLog)
261
+ await this.reap(owner, session)
262
+ })()
263
+ session.cleanupPromise = cleanup
264
+ try {
265
+ await cleanup
266
+ } catch (error) {
267
+ session.cleanupPromise = undefined
268
+ // Quarantine the owner until a later call can confirm the group is gone.
269
+ if (!this.live.has(owner) || this.live.get(owner) === session) this.live.set(owner, session)
270
+ throw error
271
+ }
272
+ }
273
+ /** Release one session's resources exactly once, whatever killed the process. */
274
+ async reap(owner, session) {
275
+ if (session.reaped) return
276
+ session.reaped = true
277
+ if (this.live.get(owner) === session) this.live.delete(owner)
278
+ session.client?.close()
279
+ await rm(session.scratch, { recursive: true, force: true }).catch(e => this.onLog(String(e)))
280
+ }
281
+
282
+ /**
283
+ * Run `work` under a deadline and an abort signal.
284
+ * Used for module synchronization, which must not outlive a tool call.
285
+ */
286
+ async bounded(work, ms, label, signal) {
287
+ signal?.throwIfAborted()
288
+ let timer
289
+ let aborted
290
+ const deadline = new Promise((_, reject) => {
291
+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms)
292
+ aborted = () => reject(signal?.reason ?? new Error(`${label} aborted`))
293
+ signal?.addEventListener('abort', aborted, { once: true })
294
+ if (signal?.aborted) aborted()
295
+ })
296
+ try {
297
+ // Defer work until after the abort listener is installed.
298
+ return await Promise.race([Promise.resolve().then(work), deadline])
299
+ } finally {
300
+ clearTimeout(timer)
301
+ signal?.removeEventListener('abort', aborted)
302
+ }
303
+ }
304
+
305
+ async ensure(owner, workspace, signal, policy) {
306
+ const existing = this.live.get(owner)
307
+ if (existing) {
308
+ const mustStop = existing.cleanupPromise || existing.proc.killed
309
+ || existing.proc.exitCode !== null || existing.proc.signalCode !== null
310
+ || existing.workspace !== resolve(workspace)
311
+ || existing.policyKey !== (policy?.key ?? 'unconfined')
312
+ if (mustStop) await this.stopSession(owner, existing)
313
+ else return existing
314
+ }
315
+ const pending = this.pending.get(owner)
316
+ if (pending) return pending
317
+ const creation = this.start(owner, workspace, signal, policy).finally(() => {
318
+ this.pending.delete(owner)
319
+ this.creating.delete(creation)
320
+ })
321
+ this.creating.add(creation)
322
+ this.pending.set(owner, creation)
323
+ return creation
324
+ }
325
+
326
+ /**
327
+ * Reconcile the live session with the currently registered domains.
328
+ * - new domain: add classpath and load it, keeping session state;
329
+ * - changed or removed domain: report a rebuild so the caller resets before
330
+ * evaluating, instead of failing the call and making the Agent retry.
331
+ */
332
+ async syncModules(session) {
333
+ const active = new Map(this.modules().map(m => [m.id, m]))
334
+ const removed = [...session.modules.keys()].filter(id => !active.has(id))
335
+ const changed = [...active.values()].filter(m => {
336
+ const known = session.modules.get(m.id)
337
+ return known && known.signature !== signature(m)
338
+ })
339
+ if (removed.length || changed.length) {
340
+ const parts = []
341
+ if (removed.length) parts.push(`removed ${removed.join(', ')}`)
342
+ if (changed.length) {
343
+ parts.push(changed.map(m => `${m.id} changed to ${m.version ?? '0.0.0'} (state version ${m.stateVersion ?? 0})`).join(', '))
344
+ }
345
+ return { reset: parts.join('; '), added: [] }
346
+ }
347
+ const added = [...active.values()].filter(m => !session.modules.has(m.id))
348
+ for (const m of added) {
349
+ if (m.classpath) {
350
+ const res = await session.client.eval(session.sessionId,
351
+ `(do (require 'babashka.classpath) (babashka.classpath/add-classpath ${q(m.classpath)}) :classpath-added)`)
352
+ if (!res.ok) throw new Error(`Cannot hot-load classpath ${m.id}: ${res.err}`)
353
+ }
354
+ if (m.init) {
355
+ const res = await session.client.eval(session.sessionId, m.init)
356
+ if (!res.ok) throw new Error(`Cannot hot-load domain ${m.id}: ${res.err}`)
357
+ }
358
+ const reg = await session.client.eval(session.sessionId, this.domainRegistration(m))
359
+ if (!reg.ok) throw new Error(`Cannot register domain ${m.id}: ${reg.err}`)
360
+ session.modules.set(m.id, { signature: signature(m), version: m.version })
361
+ }
362
+ return { reset: null, added: added.map(m => label(m)) }
363
+ }
364
+
365
+ /** Reset detection for a session the Agent deliberately discarded (domain.runtime/reset!). */
366
+ static isResetRequest(result) {
367
+ const text = [result?.err, result?.exception, result?.out].filter(Boolean).join('\n')
368
+ return /DSH_BB_RESET:/.test(text)
369
+ }
370
+
371
+ async evaluate(owner, workspace, code, signal, explicitPolicy) {
372
+ const prior = this.queues.get(owner) ?? Promise.resolve()
373
+ const work = prior.then(async () => {
374
+ if (signal?.aborted) throw signal.reason ?? new Error('Cancelled')
375
+ const policy = explicitPolicy ?? this.policyFor?.(owner, workspace)
376
+ const notes = []
377
+ const syncOnce = async session => this.bounded(
378
+ () => this.syncModules(session), this.moduleSyncMs, 'Domain synchronization', signal)
379
+ let session
380
+ let sync
381
+ try {
382
+ session = await this.ensure(owner, workspace, signal, policy)
383
+ sync = await syncOnce(session)
384
+ if (sync.reset) {
385
+ notes.push(`session rebuilt: ${sync.reset}`)
386
+ await this.reset(owner)
387
+ session = await this.ensure(owner, workspace, signal, policy)
388
+ sync = await syncOnce(session)
389
+ }
390
+ } catch (error) {
391
+ // Promise.race does not cancel a timed-out module init. Closing its
392
+ // nREPL socket and killing the process group is the cancellation.
393
+ let reset = false
394
+ try {
395
+ await this.reset(owner)
396
+ reset = true
397
+ } catch (resetError) {
398
+ error.message = `${error.message} (${resetError.message})`
399
+ }
400
+ notes.push(reset
401
+ ? 'session reset: domain synchronization failed'
402
+ : 'session cleanup failed; owner quarantined')
403
+ error.notes = notes
404
+ throw error
405
+ }
406
+ if (sync.added.length) notes.push(`domain hot-loaded: ${sync.added.join(', ')}`)
407
+ let timeoutId
408
+ let aborted
409
+ try {
410
+ const result = await Promise.race([
411
+ session.client.eval(session.sessionId, code),
412
+ new Promise((_, reject) => {
413
+ timeoutId = setTimeout(() => reject(new Error('Babashka evaluation timed out')), this.timeoutMs)
414
+ aborted = () => reject(signal?.reason ?? new Error('Evaluation aborted'))
415
+ signal?.addEventListener('abort', aborted, { once: true })
416
+ if (signal?.aborted) aborted()
417
+ }),
418
+ ])
419
+ if (!result.ok && DomainSessions.isResetRequest(result)) {
420
+ await this.reset(owner)
421
+ notes.push('session reset: requested by domain.runtime/reset!')
422
+ return { ...result, notes, sessionReset: true }
423
+ }
424
+ return { ...result, notes: [...notes, ...(result.notes ?? [])], sessionReset: false }
425
+ } catch (error) {
426
+ // Timeout, abort or any transport failure leaves unknown interpreter state.
427
+ let reset = false
428
+ try {
429
+ await this.reset(owner)
430
+ reset = true
431
+ } catch (resetError) {
432
+ error.message = `${error.message} (${resetError.message})`
433
+ }
434
+ notes.push(reset
435
+ ? 'session reset: previous interpreter state discarded'
436
+ : 'session cleanup failed; owner quarantined')
437
+ error.notes = notes
438
+ throw error
439
+ } finally {
440
+ clearTimeout(timeoutId)
441
+ signal?.removeEventListener('abort', aborted)
442
+ }
443
+ })
444
+ const tail = work.then(() => {}, () => {})
445
+ this.queues.set(owner, tail)
446
+ try { return await work } finally { if (this.queues.get(owner) === tail) this.queues.delete(owner) }
447
+ }
448
+
449
+ /**
450
+ * Stop one Agent's interpreter and wait until it is really gone.
451
+ *
452
+ * The nREPL socket is closed first so an in-flight evaluation cannot deliver
453
+ * more output, then the whole process group is terminated and awaited. Only
454
+ * after the exit is confirmed do we delete the scratch directory and let the
455
+ * next call start a fresh process: overlapping writers are the failure this
456
+ * ordering exists to prevent.
457
+ */
458
+ async reset(owner) {
459
+ const session = this.live.get(owner)
460
+ if (!session) return
461
+ try {
462
+ await this.stopSession(owner, session)
463
+ } catch (error) {
464
+ // Keep the session quarantined when any process-group member survives.
465
+ this.onLog(String(error?.message ?? error))
466
+ throw error
467
+ }
468
+ }
469
+
470
+ async close() {
471
+ this.disposed = true
472
+ await Promise.allSettled([...this.creating])
473
+ await Promise.allSettled([...new Set([...this.live.keys()])].map(owner => this.reset(owner)))
474
+ }
475
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.