@theokit/sdk-pty 0.2.0 → 0.3.1
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/dist/index.cjs +20 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -9
- package/dist/index.d.ts +32 -9
- package/dist/index.js +21 -4
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -21,12 +21,26 @@ function capTail(buf, max) {
|
|
|
21
21
|
return buf.length > max ? `\u2026(truncated)
|
|
22
22
|
${buf.slice(-max)}` : buf;
|
|
23
23
|
}
|
|
24
|
+
var MaxSessionsError = class extends interactive.InteractiveUnavailableError {
|
|
25
|
+
constructor(max, liveSessionIds) {
|
|
26
|
+
super(
|
|
27
|
+
`interactive session limit reached (${String(max)} live). Reuse one of the open sessions instead of starting another: ${liveSessionIds.join(", ")}`
|
|
28
|
+
);
|
|
29
|
+
this.max = max;
|
|
30
|
+
this.liveSessionIds = liveSessionIds;
|
|
31
|
+
}
|
|
32
|
+
max;
|
|
33
|
+
liveSessionIds;
|
|
34
|
+
};
|
|
24
35
|
var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
|
|
25
36
|
sessions = /* @__PURE__ */ new Map();
|
|
26
37
|
wrapCommand;
|
|
38
|
+
/** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */
|
|
39
|
+
maxSessions;
|
|
27
40
|
constructor(options = {}) {
|
|
28
41
|
super();
|
|
29
42
|
this.wrapCommand = options.wrapCommand;
|
|
43
|
+
this.maxSessions = options.maxSessions;
|
|
30
44
|
}
|
|
31
45
|
ptyModule;
|
|
32
46
|
exitReaperArmed = false;
|
|
@@ -76,10 +90,10 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
|
|
|
76
90
|
`interactive shell unavailable: cwd does not exist: ${cwd}`
|
|
77
91
|
);
|
|
78
92
|
}
|
|
79
|
-
const
|
|
93
|
+
const effective = this.wrapCommand?.(command, cwd) ?? command;
|
|
80
94
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
81
95
|
try {
|
|
82
|
-
return pty.spawn(shell, ["-c",
|
|
96
|
+
return pty.spawn(shell, ["-c", effective], {
|
|
83
97
|
name: "xterm-color",
|
|
84
98
|
cols: opts?.cols ?? 80,
|
|
85
99
|
rows: opts?.rows ?? 24,
|
|
@@ -94,6 +108,9 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
|
|
|
94
108
|
}
|
|
95
109
|
async startInteractive(command, opts) {
|
|
96
110
|
this.armExitReaper();
|
|
111
|
+
if (this.maxSessions !== void 0 && this.sessions.size >= this.maxSessions) {
|
|
112
|
+
throw new MaxSessionsError(this.maxSessions, [...this.sessions.keys()]);
|
|
113
|
+
}
|
|
97
114
|
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
98
115
|
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
99
116
|
const proc = this.spawnPty(command, opts);
|
|
@@ -161,6 +178,7 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
|
|
|
161
178
|
}
|
|
162
179
|
};
|
|
163
180
|
|
|
181
|
+
exports.MaxSessionsError = MaxSessionsError;
|
|
164
182
|
exports.PtyInteractiveBackend = PtyInteractiveBackend;
|
|
165
183
|
exports.YIELD_MAX_MS = YIELD_MAX_MS;
|
|
166
184
|
exports.YIELD_MIN_MS = YIELD_MIN_MS;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/pty-interactive-backend.ts"],"names":["createRequire","InteractiveBackend","InteractiveUnavailableError","existsSync","randomUUID","NoSuchSessionError"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * `PtyInteractiveBackend` — the LOCAL implementation of `@theokit/sdk`'s\n * `InteractiveBackend`, backed by `node-pty`. This is the terminal-surface\n * backend: it allocates a real pseudo-terminal so REPLs, `git rebase -i`, and\n * prompting commands can be driven to completion. Ported from the AgentBuilder\n * Codex clone (M11) into the ecosystem (M14) so any terminal agent injects it.\n *\n * `node-pty` is an OPTIONAL dependency of this package — the ONLY place in the\n * theokit ecosystem that touches it. Core / sdk-tools / cluster / desktop never\n * do. When the native module is unavailable (or a spawn fails), every method\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec.\n *\n * Safety (mirrors M11): graceful typed degradation; per-session write\n * serialization (concurrent writes never steal each other's output); idle TTL\n * reaper; process-GROUP kill so detached grandchildren die; a process-exit\n * reaper; tail-capped output (the live prompt, not the stale banner).\n */\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\n\nimport {\n InteractiveBackend,\n InteractiveUnavailableError,\n NoSuchSessionError,\n type StartInteractiveOptions,\n type StartInteractiveResult,\n type WriteStdinOptions,\n type WriteStdinResult,\n} from \"@theokit/sdk/interactive\";\n\nconst nodeRequire = createRequire(import.meta.url);\n\nexport const YIELD_MIN_MS = 250;\nexport const YIELD_MAX_MS = 30_000;\nconst DEFAULT_YIELD_MS = 500;\nconst DEFAULT_TTL_MS = 300_000; // 5 min idle → reap\nconst DEFAULT_MAX_BYTES = 100_000;\n\n/** The minimal `node-pty` surface driven here (kept structural so the import stays lazy). */\ninterface PtyProcess {\n readonly pid: number;\n write(data: string): void;\n kill(signal?: string): void;\n onData(cb: (data: string) => void): void;\n onExit(cb: (e: { exitCode: number }) => void): void;\n}\ninterface PtyModule {\n spawn(file: string, args: string[], opts: Record<string, unknown>): PtyProcess;\n}\n\ninterface PtySession {\n id: string;\n pty: PtyProcess;\n pending: string;\n alive: boolean;\n ttlTimer: ReturnType<typeof setTimeout>;\n writeChain: Promise<unknown>;\n}\n\n/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */\nexport function clampYield(ms: number | undefined): number {\n const v = ms ?? DEFAULT_YIELD_MS;\n return Math.max(YIELD_MIN_MS, Math.min(YIELD_MAX_MS, v));\n}\n\n/** Keep the TAIL of output — the newest bytes carry the live prompt. */\nfunction capTail(buf: string, max: number): string {\n return buf.length > max ? `…(truncated)\\n${buf.slice(-max)}` : buf;\n}\n\n/**\n * M75 T3.1 — como o chamador embrulha o comando antes do spawn.\n *\n * Existe para que confinamento (sandbox) componha com PTY sem herança: o backend continua dono do\n * spawn, o chamador continua dono da política, e nenhum dos dois conhece o tipo do outro.\n */\nexport interface PtyInteractiveBackendOptions {\n /**\n * Transforma o comando imediatamente antes do spawn. Recebe o `cwd` JÁ RESOLVIDO — o PTY spawna\n * nele, então um wrap que mirasse outro diretório produziria confinamento que não confina nada.\n *\n * Devolver `null` significa **não embrulhe** — decisão explícita, distinta de \"embrulhei e deu no\n * mesmo\". É o caso do modo sem confinamento.\n */\n readonly wrapCommand?: (command: string, cwd: string) => string | null;\n}\n\nexport class PtyInteractiveBackend extends InteractiveBackend {\n private readonly sessions = new Map<string, PtySession>();\n private readonly wrapCommand: ((command: string, cwd: string) => string | null) | undefined;\n\n constructor(options: PtyInteractiveBackendOptions = {}) {\n super();\n this.wrapCommand = options.wrapCommand;\n }\n\n private ptyModule: PtyModule | null | undefined;\n private exitReaperArmed = false;\n\n /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */\n private loadPty(): PtyModule | null {\n if (this.ptyModule !== undefined) return this.ptyModule;\n try {\n this.ptyModule = nodeRequire(\"node-pty\") as PtyModule;\n } catch {\n this.ptyModule = null;\n }\n return this.ptyModule;\n }\n\n /** Whether the interactive (PTY) path is usable in this environment. */\n available(): boolean {\n return this.loadPty() !== null;\n }\n\n /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM\n * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */\n private armExitReaper(): void {\n if (this.exitReaperArmed) return;\n this.exitReaperArmed = true;\n process.on(\"exit\", () => this.killAll());\n }\n\n private async collect(session: PtySession, yieldMs: number, maxBytes: number): Promise<string> {\n await new Promise((r) => setTimeout(r, clampYield(yieldMs)));\n const out = session.pending;\n session.pending = \"\";\n return capTail(out, maxBytes);\n }\n\n private armTtl(session: PtySession, ttlMs: number): void {\n clearTimeout(session.ttlTimer);\n session.ttlTimer = setTimeout(() => this.kill(session.id), ttlMs);\n }\n\n /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the\n * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */\n private spawnPty(command: string, opts?: StartInteractiveOptions): PtyProcess {\n const pty = this.loadPty();\n if (pty === null) {\n throw new InteractiveUnavailableError(\n \"interactive shell unavailable: node-pty native module failed to load; use non-interactive exec\",\n );\n }\n const cwd = opts?.cwd ?? process.cwd();\n if (!existsSync(cwd)) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: cwd does not exist: ${cwd}`,\n );\n }\n // M75 T3.1 — o wrap entra AQUI: depois do cwd resolvido e validado, antes do spawn. É o ponto\n // único por onde todo comando passa, então não há caminho que escape do confinamento.\n const efetivo = this.wrapCommand?.(command, cwd) ?? command;\n\n const shell = process.env.SHELL ?? \"/bin/bash\";\n try {\n return pty.spawn(shell, [\"-c\", efetivo], {\n name: \"xterm-color\",\n cols: opts?.cols ?? 80,\n rows: opts?.rows ?? 24,\n cwd,\n env: process.env,\n });\n } catch (err) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: failed to spawn a PTY (${err instanceof Error ? err.message : String(err)})`,\n );\n }\n }\n\n async startInteractive(\n command: string,\n opts?: StartInteractiveOptions,\n ): Promise<StartInteractiveResult> {\n this.armExitReaper();\n const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;\n const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;\n const proc = this.spawnPty(command, opts);\n const id = `pty-${randomUUID()}`;\n const session: PtySession = {\n id,\n pty: proc,\n pending: \"\",\n alive: true,\n ttlTimer: setTimeout(() => this.kill(id), ttlMs),\n writeChain: Promise.resolve(),\n };\n proc.onData((data) => {\n session.pending = capTail(session.pending + data, maxBytes);\n });\n proc.onExit(() => {\n session.alive = false;\n clearTimeout(session.ttlTimer);\n this.sessions.delete(id);\n });\n this.sessions.set(id, session);\n const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);\n return { sessionId: id, output };\n }\n\n async writeStdin(\n sessionId: string,\n chars: string,\n opts?: WriteStdinOptions,\n ): Promise<WriteStdinResult> {\n const session = this.sessions.get(sessionId);\n if (session === undefined || !session.alive) throw new NoSuchSessionError(sessionId);\n // Serialize per session: chain onto the previous write so two concurrent calls run strictly in\n // order and each reads only its own output window (no stolen output).\n const run = session.writeChain.then(async () => {\n if (!session.alive) throw new NoSuchSessionError(sessionId);\n this.armTtl(session, opts?.ttlMs ?? DEFAULT_TTL_MS);\n if (chars.length > 0) session.pty.write(chars);\n const output = await this.collect(\n session,\n opts?.yieldMs ?? DEFAULT_YIELD_MS,\n opts?.maxBytes ?? DEFAULT_MAX_BYTES,\n );\n return { output, alive: session.alive };\n });\n session.writeChain = run.catch(() => undefined);\n return run;\n }\n\n /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */\n kill(sessionId: string): void {\n const session = this.sessions.get(sessionId);\n if (session === undefined) return;\n clearTimeout(session.ttlTimer);\n session.alive = false;\n this.sessions.delete(sessionId);\n try {\n process.kill(-session.pty.pid, \"SIGKILL\");\n } catch {\n try {\n session.pty.kill();\n } catch {\n // already dead — nothing to do\n }\n }\n }\n\n /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /** Live session count — for observability / tests. */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/pty-interactive-backend.ts"],"names":["createRequire","InteractiveUnavailableError","InteractiveBackend","existsSync","randomUUID","NoSuchSessionError"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * `PtyInteractiveBackend` — the LOCAL implementation of `@theokit/sdk`'s\n * `InteractiveBackend`, backed by `node-pty`. This is the terminal-surface\n * backend: it allocates a real pseudo-terminal so REPLs, `git rebase -i`, and\n * prompting commands can be driven to completion. Ported from the AgentBuilder\n * Codex clone (M11) into the ecosystem (M14) so any terminal agent injects it.\n *\n * `node-pty` is an OPTIONAL dependency of this package — the ONLY place in the\n * theokit ecosystem that touches it. Core / sdk-tools / cluster / desktop never\n * do. When the native module is unavailable (or a spawn fails), every method\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec.\n *\n * Safety (mirrors M11): graceful typed degradation; per-session write\n * serialization (concurrent writes never steal each other's output); idle TTL\n * reaper; process-GROUP kill so detached grandchildren die; a process-exit\n * reaper; tail-capped output (the live prompt, not the stale banner).\n */\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\n\nimport {\n InteractiveBackend,\n InteractiveUnavailableError,\n NoSuchSessionError,\n type StartInteractiveOptions,\n type StartInteractiveResult,\n type WriteStdinOptions,\n type WriteStdinResult,\n} from \"@theokit/sdk/interactive\";\n\nconst nodeRequire = createRequire(import.meta.url);\n\nexport const YIELD_MIN_MS = 250;\nexport const YIELD_MAX_MS = 30_000;\nconst DEFAULT_YIELD_MS = 500;\nconst DEFAULT_TTL_MS = 300_000; // 5 min idle → reap\nconst DEFAULT_MAX_BYTES = 100_000;\n\n/** The minimal `node-pty` surface driven here (kept structural so the import stays lazy). */\ninterface PtyProcess {\n readonly pid: number;\n write(data: string): void;\n kill(signal?: string): void;\n onData(cb: (data: string) => void): void;\n onExit(cb: (e: { exitCode: number }) => void): void;\n}\ninterface PtyModule {\n spawn(file: string, args: string[], opts: Record<string, unknown>): PtyProcess;\n}\n\ninterface PtySession {\n id: string;\n pty: PtyProcess;\n pending: string;\n alive: boolean;\n ttlTimer: ReturnType<typeof setTimeout>;\n writeChain: Promise<unknown>;\n}\n\n/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */\nexport function clampYield(ms: number | undefined): number {\n const v = ms ?? DEFAULT_YIELD_MS;\n return Math.max(YIELD_MIN_MS, Math.min(YIELD_MAX_MS, v));\n}\n\n/** Keep the TAIL of output — the newest bytes carry the live prompt. */\nfunction capTail(buf: string, max: number): string {\n return buf.length > max ? `…(truncated)\\n${buf.slice(-max)}` : buf;\n}\n\n/**\n * M75 T3.1 — how the caller wraps the command before the spawn.\n *\n * It exists so confinement (sandbox) composes with the PTY without inheritance: the backend keeps owning the\n * spawn, the caller keeps owning the policy, and neither knows the other's type.\n */\nexport interface PtyInteractiveBackendOptions {\n /**\n * Transforms the command immediately before the spawn. It receives the ALREADY-RESOLVED `cwd` — the PTY spawns\n * in it, so a wrap targeting another directory would produce confinement that confines nothing.\n *\n * Returning `null` means **do not wrap** — an explicit decision, distinct from \"I wrapped and it made no\n * difference\". It is the case of the unconfined mode.\n */\n readonly wrapCommand?: (command: string, cwd: string) => string | null;\n\n /**\n * M77 — ceiling on simultaneously LIVE sessions. Absent => no ceiling (the long-standing behavior).\n *\n * Each session is a real process with a 5-minute TTL. A model that does not notice it already has a\n * shell open opens another, and the TTL only collects later — too late when the limit is the machine's\n * PID count. On overflow, {@link MaxSessionsError} lists the live sessions, because the correct\n * action is to **reuse** one of them, and an error that does not say so only teaches the model to retry.\n */\n readonly maxSessions?: number;\n}\n\n/**\n * M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.\n *\n * Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough context\n * enough context to act, and here the action is reusing an existing session. An error merely stating\n * \"limit reached\" would leave the model with no way out — it would retry, and fail again.\n */\nexport class MaxSessionsError extends InteractiveUnavailableError {\n constructor(\n readonly max: number,\n readonly liveSessionIds: readonly string[],\n ) {\n super(\n `interactive session limit reached (${String(max)} live). ` +\n `Reuse one of the open sessions instead of starting another: ${liveSessionIds.join(\", \")}`,\n );\n }\n}\n\nexport class PtyInteractiveBackend extends InteractiveBackend {\n private readonly sessions = new Map<string, PtySession>();\n private readonly wrapCommand: ((command: string, cwd: string) => string | null) | undefined;\n /** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */\n private readonly maxSessions: number | undefined;\n\n constructor(options: PtyInteractiveBackendOptions = {}) {\n super();\n this.wrapCommand = options.wrapCommand;\n this.maxSessions = options.maxSessions;\n }\n\n private ptyModule: PtyModule | null | undefined;\n private exitReaperArmed = false;\n\n /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */\n private loadPty(): PtyModule | null {\n if (this.ptyModule !== undefined) return this.ptyModule;\n try {\n this.ptyModule = nodeRequire(\"node-pty\") as PtyModule;\n } catch {\n this.ptyModule = null;\n }\n return this.ptyModule;\n }\n\n /** Whether the interactive (PTY) path is usable in this environment. */\n available(): boolean {\n return this.loadPty() !== null;\n }\n\n /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM\n * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */\n private armExitReaper(): void {\n if (this.exitReaperArmed) return;\n this.exitReaperArmed = true;\n process.on(\"exit\", () => this.killAll());\n }\n\n private async collect(session: PtySession, yieldMs: number, maxBytes: number): Promise<string> {\n await new Promise((r) => setTimeout(r, clampYield(yieldMs)));\n const out = session.pending;\n session.pending = \"\";\n return capTail(out, maxBytes);\n }\n\n private armTtl(session: PtySession, ttlMs: number): void {\n clearTimeout(session.ttlTimer);\n session.ttlTimer = setTimeout(() => this.kill(session.id), ttlMs);\n }\n\n /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the\n * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */\n private spawnPty(command: string, opts?: StartInteractiveOptions): PtyProcess {\n const pty = this.loadPty();\n if (pty === null) {\n throw new InteractiveUnavailableError(\n \"interactive shell unavailable: node-pty native module failed to load; use non-interactive exec\",\n );\n }\n const cwd = opts?.cwd ?? process.cwd();\n if (!existsSync(cwd)) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: cwd does not exist: ${cwd}`,\n );\n }\n // M75 T3.1 — the wrap goes HERE: after the cwd is resolved and validated, before the spawn. It is the\n // single point every command passes through, so no path escapes the confinement.\n const effective = this.wrapCommand?.(command, cwd) ?? command;\n\n const shell = process.env.SHELL ?? \"/bin/bash\";\n try {\n return pty.spawn(shell, [\"-c\", effective], {\n name: \"xterm-color\",\n cols: opts?.cols ?? 80,\n rows: opts?.rows ?? 24,\n cwd,\n env: process.env,\n });\n } catch (err) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: failed to spawn a PTY (${err instanceof Error ? err.message : String(err)})`,\n );\n }\n }\n\n async startInteractive(\n command: string,\n opts?: StartInteractiveOptions,\n ): Promise<StartInteractiveResult> {\n this.armExitReaper();\n // M77 — the ceiling, checked against LIVE sessions (`onExit` and `kill` both delete from the\n // Map), so killing one frees a slot.\n //\n // ATOMICITY, and why it is not an accident to preserve carelessly: everything from here down to\n // `this.sessions.set(id, session)` is SYNCHRONOUS — `spawnPty` does not await. Two concurrent\n // `startInteractive` calls therefore cannot interleave between this check and the insert, so the\n // ceiling holds without a lock. If a future refactor makes any step in that span asynchronous\n // (an `await this.loadPty()` would be the plausible one), both callers would observe the old\n // count and both would pass. `tests/max-sessions.test.ts` covers exactly that regression.\n if (this.maxSessions !== undefined && this.sessions.size >= this.maxSessions) {\n throw new MaxSessionsError(this.maxSessions, [...this.sessions.keys()]);\n }\n const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;\n const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;\n const proc = this.spawnPty(command, opts);\n const id = `pty-${randomUUID()}`;\n const session: PtySession = {\n id,\n pty: proc,\n pending: \"\",\n alive: true,\n ttlTimer: setTimeout(() => this.kill(id), ttlMs),\n writeChain: Promise.resolve(),\n };\n proc.onData((data) => {\n session.pending = capTail(session.pending + data, maxBytes);\n });\n proc.onExit(() => {\n session.alive = false;\n clearTimeout(session.ttlTimer);\n this.sessions.delete(id);\n });\n this.sessions.set(id, session);\n const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);\n return { sessionId: id, output };\n }\n\n async writeStdin(\n sessionId: string,\n chars: string,\n opts?: WriteStdinOptions,\n ): Promise<WriteStdinResult> {\n const session = this.sessions.get(sessionId);\n if (session === undefined || !session.alive) throw new NoSuchSessionError(sessionId);\n // Serialize per session: chain onto the previous write so two concurrent calls run strictly in\n // order and each reads only its own output window (no stolen output).\n const run = session.writeChain.then(async () => {\n if (!session.alive) throw new NoSuchSessionError(sessionId);\n this.armTtl(session, opts?.ttlMs ?? DEFAULT_TTL_MS);\n if (chars.length > 0) session.pty.write(chars);\n const output = await this.collect(\n session,\n opts?.yieldMs ?? DEFAULT_YIELD_MS,\n opts?.maxBytes ?? DEFAULT_MAX_BYTES,\n );\n return { output, alive: session.alive };\n });\n session.writeChain = run.catch(() => undefined);\n return run;\n }\n\n /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */\n kill(sessionId: string): void {\n const session = this.sessions.get(sessionId);\n if (session === undefined) return;\n clearTimeout(session.ttlTimer);\n session.alive = false;\n this.sessions.delete(sessionId);\n try {\n process.kill(-session.pty.pid, \"SIGKILL\");\n } catch {\n try {\n session.pty.kill();\n } catch {\n // already dead — nothing to do\n }\n }\n }\n\n /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /** Live session count — for observability / tests. */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,28 +1,51 @@
|
|
|
1
|
-
import { InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
|
|
1
|
+
import { InteractiveUnavailableError, InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
|
|
2
2
|
|
|
3
3
|
declare const YIELD_MIN_MS = 250;
|
|
4
4
|
declare const YIELD_MAX_MS = 30000;
|
|
5
5
|
/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */
|
|
6
6
|
declare function clampYield(ms: number | undefined): number;
|
|
7
7
|
/**
|
|
8
|
-
* M75 T3.1 —
|
|
8
|
+
* M75 T3.1 — how the caller wraps the command before the spawn.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* spawn,
|
|
10
|
+
* It exists so confinement (sandbox) composes with the PTY without inheritance: the backend keeps owning the
|
|
11
|
+
* spawn, the caller keeps owning the policy, and neither knows the other's type.
|
|
12
12
|
*/
|
|
13
13
|
interface PtyInteractiveBackendOptions {
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Transforms the command immediately before the spawn. It receives the ALREADY-RESOLVED `cwd` — the PTY spawns
|
|
16
|
+
* in it, so a wrap targeting another directory would produce confinement that confines nothing.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Returning `null` means **do not wrap** — an explicit decision, distinct from "I wrapped and it made no
|
|
19
|
+
* difference". It is the case of the unconfined mode.
|
|
20
20
|
*/
|
|
21
21
|
readonly wrapCommand?: (command: string, cwd: string) => string | null;
|
|
22
|
+
/**
|
|
23
|
+
* M77 — ceiling on simultaneously LIVE sessions. Absent => no ceiling (the long-standing behavior).
|
|
24
|
+
*
|
|
25
|
+
* Each session is a real process with a 5-minute TTL. A model that does not notice it already has a
|
|
26
|
+
* shell open opens another, and the TTL only collects later — too late when the limit is the machine's
|
|
27
|
+
* PID count. On overflow, {@link MaxSessionsError} lists the live sessions, because the correct
|
|
28
|
+
* action is to **reuse** one of them, and an error that does not say so only teaches the model to retry.
|
|
29
|
+
*/
|
|
30
|
+
readonly maxSessions?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.
|
|
34
|
+
*
|
|
35
|
+
* Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough context
|
|
36
|
+
* enough context to act, and here the action is reusing an existing session. An error merely stating
|
|
37
|
+
* "limit reached" would leave the model with no way out — it would retry, and fail again.
|
|
38
|
+
*/
|
|
39
|
+
declare class MaxSessionsError extends InteractiveUnavailableError {
|
|
40
|
+
readonly max: number;
|
|
41
|
+
readonly liveSessionIds: readonly string[];
|
|
42
|
+
constructor(max: number, liveSessionIds: readonly string[]);
|
|
22
43
|
}
|
|
23
44
|
declare class PtyInteractiveBackend extends InteractiveBackend {
|
|
24
45
|
private readonly sessions;
|
|
25
46
|
private readonly wrapCommand;
|
|
47
|
+
/** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */
|
|
48
|
+
private readonly maxSessions;
|
|
26
49
|
constructor(options?: PtyInteractiveBackendOptions);
|
|
27
50
|
private ptyModule;
|
|
28
51
|
private exitReaperArmed;
|
|
@@ -48,4 +71,4 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
|
|
|
48
71
|
activeSessionCount(): number;
|
|
49
72
|
}
|
|
50
73
|
|
|
51
|
-
export { PtyInteractiveBackend, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
|
74
|
+
export { MaxSessionsError, PtyInteractiveBackend, type PtyInteractiveBackendOptions, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,28 +1,51 @@
|
|
|
1
|
-
import { InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
|
|
1
|
+
import { InteractiveUnavailableError, InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
|
|
2
2
|
|
|
3
3
|
declare const YIELD_MIN_MS = 250;
|
|
4
4
|
declare const YIELD_MAX_MS = 30000;
|
|
5
5
|
/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */
|
|
6
6
|
declare function clampYield(ms: number | undefined): number;
|
|
7
7
|
/**
|
|
8
|
-
* M75 T3.1 —
|
|
8
|
+
* M75 T3.1 — how the caller wraps the command before the spawn.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* spawn,
|
|
10
|
+
* It exists so confinement (sandbox) composes with the PTY without inheritance: the backend keeps owning the
|
|
11
|
+
* spawn, the caller keeps owning the policy, and neither knows the other's type.
|
|
12
12
|
*/
|
|
13
13
|
interface PtyInteractiveBackendOptions {
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Transforms the command immediately before the spawn. It receives the ALREADY-RESOLVED `cwd` — the PTY spawns
|
|
16
|
+
* in it, so a wrap targeting another directory would produce confinement that confines nothing.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Returning `null` means **do not wrap** — an explicit decision, distinct from "I wrapped and it made no
|
|
19
|
+
* difference". It is the case of the unconfined mode.
|
|
20
20
|
*/
|
|
21
21
|
readonly wrapCommand?: (command: string, cwd: string) => string | null;
|
|
22
|
+
/**
|
|
23
|
+
* M77 — ceiling on simultaneously LIVE sessions. Absent => no ceiling (the long-standing behavior).
|
|
24
|
+
*
|
|
25
|
+
* Each session is a real process with a 5-minute TTL. A model that does not notice it already has a
|
|
26
|
+
* shell open opens another, and the TTL only collects later — too late when the limit is the machine's
|
|
27
|
+
* PID count. On overflow, {@link MaxSessionsError} lists the live sessions, because the correct
|
|
28
|
+
* action is to **reuse** one of them, and an error that does not say so only teaches the model to retry.
|
|
29
|
+
*/
|
|
30
|
+
readonly maxSessions?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.
|
|
34
|
+
*
|
|
35
|
+
* Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough context
|
|
36
|
+
* enough context to act, and here the action is reusing an existing session. An error merely stating
|
|
37
|
+
* "limit reached" would leave the model with no way out — it would retry, and fail again.
|
|
38
|
+
*/
|
|
39
|
+
declare class MaxSessionsError extends InteractiveUnavailableError {
|
|
40
|
+
readonly max: number;
|
|
41
|
+
readonly liveSessionIds: readonly string[];
|
|
42
|
+
constructor(max: number, liveSessionIds: readonly string[]);
|
|
22
43
|
}
|
|
23
44
|
declare class PtyInteractiveBackend extends InteractiveBackend {
|
|
24
45
|
private readonly sessions;
|
|
25
46
|
private readonly wrapCommand;
|
|
47
|
+
/** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */
|
|
48
|
+
private readonly maxSessions;
|
|
26
49
|
constructor(options?: PtyInteractiveBackendOptions);
|
|
27
50
|
private ptyModule;
|
|
28
51
|
private exitReaperArmed;
|
|
@@ -48,4 +71,4 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
|
|
|
48
71
|
activeSessionCount(): number;
|
|
49
72
|
}
|
|
50
73
|
|
|
51
|
-
export { PtyInteractiveBackend, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
|
74
|
+
export { MaxSessionsError, PtyInteractiveBackend, type PtyInteractiveBackendOptions, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
|
-
import {
|
|
4
|
+
import { InteractiveUnavailableError, InteractiveBackend, NoSuchSessionError } from '@theokit/sdk/interactive';
|
|
5
5
|
|
|
6
6
|
// src/pty-interactive-backend.ts
|
|
7
7
|
var nodeRequire = createRequire(import.meta.url);
|
|
@@ -18,12 +18,26 @@ function capTail(buf, max) {
|
|
|
18
18
|
return buf.length > max ? `\u2026(truncated)
|
|
19
19
|
${buf.slice(-max)}` : buf;
|
|
20
20
|
}
|
|
21
|
+
var MaxSessionsError = class extends InteractiveUnavailableError {
|
|
22
|
+
constructor(max, liveSessionIds) {
|
|
23
|
+
super(
|
|
24
|
+
`interactive session limit reached (${String(max)} live). Reuse one of the open sessions instead of starting another: ${liveSessionIds.join(", ")}`
|
|
25
|
+
);
|
|
26
|
+
this.max = max;
|
|
27
|
+
this.liveSessionIds = liveSessionIds;
|
|
28
|
+
}
|
|
29
|
+
max;
|
|
30
|
+
liveSessionIds;
|
|
31
|
+
};
|
|
21
32
|
var PtyInteractiveBackend = class extends InteractiveBackend {
|
|
22
33
|
sessions = /* @__PURE__ */ new Map();
|
|
23
34
|
wrapCommand;
|
|
35
|
+
/** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */
|
|
36
|
+
maxSessions;
|
|
24
37
|
constructor(options = {}) {
|
|
25
38
|
super();
|
|
26
39
|
this.wrapCommand = options.wrapCommand;
|
|
40
|
+
this.maxSessions = options.maxSessions;
|
|
27
41
|
}
|
|
28
42
|
ptyModule;
|
|
29
43
|
exitReaperArmed = false;
|
|
@@ -73,10 +87,10 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
|
|
|
73
87
|
`interactive shell unavailable: cwd does not exist: ${cwd}`
|
|
74
88
|
);
|
|
75
89
|
}
|
|
76
|
-
const
|
|
90
|
+
const effective = this.wrapCommand?.(command, cwd) ?? command;
|
|
77
91
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
78
92
|
try {
|
|
79
|
-
return pty.spawn(shell, ["-c",
|
|
93
|
+
return pty.spawn(shell, ["-c", effective], {
|
|
80
94
|
name: "xterm-color",
|
|
81
95
|
cols: opts?.cols ?? 80,
|
|
82
96
|
rows: opts?.rows ?? 24,
|
|
@@ -91,6 +105,9 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
|
|
|
91
105
|
}
|
|
92
106
|
async startInteractive(command, opts) {
|
|
93
107
|
this.armExitReaper();
|
|
108
|
+
if (this.maxSessions !== void 0 && this.sessions.size >= this.maxSessions) {
|
|
109
|
+
throw new MaxSessionsError(this.maxSessions, [...this.sessions.keys()]);
|
|
110
|
+
}
|
|
94
111
|
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
95
112
|
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
96
113
|
const proc = this.spawnPty(command, opts);
|
|
@@ -158,6 +175,6 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
|
|
|
158
175
|
}
|
|
159
176
|
};
|
|
160
177
|
|
|
161
|
-
export { PtyInteractiveBackend, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
|
178
|
+
export { MaxSessionsError, PtyInteractiveBackend, YIELD_MAX_MS, YIELD_MIN_MS, clampYield };
|
|
162
179
|
//# sourceMappingURL=index.js.map
|
|
163
180
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/pty-interactive-backend.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `PtyInteractiveBackend` — the LOCAL implementation of `@theokit/sdk`'s\n * `InteractiveBackend`, backed by `node-pty`. This is the terminal-surface\n * backend: it allocates a real pseudo-terminal so REPLs, `git rebase -i`, and\n * prompting commands can be driven to completion. Ported from the AgentBuilder\n * Codex clone (M11) into the ecosystem (M14) so any terminal agent injects it.\n *\n * `node-pty` is an OPTIONAL dependency of this package — the ONLY place in the\n * theokit ecosystem that touches it. Core / sdk-tools / cluster / desktop never\n * do. When the native module is unavailable (or a spawn fails), every method\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec.\n *\n * Safety (mirrors M11): graceful typed degradation; per-session write\n * serialization (concurrent writes never steal each other's output); idle TTL\n * reaper; process-GROUP kill so detached grandchildren die; a process-exit\n * reaper; tail-capped output (the live prompt, not the stale banner).\n */\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\n\nimport {\n InteractiveBackend,\n InteractiveUnavailableError,\n NoSuchSessionError,\n type StartInteractiveOptions,\n type StartInteractiveResult,\n type WriteStdinOptions,\n type WriteStdinResult,\n} from \"@theokit/sdk/interactive\";\n\nconst nodeRequire = createRequire(import.meta.url);\n\nexport const YIELD_MIN_MS = 250;\nexport const YIELD_MAX_MS = 30_000;\nconst DEFAULT_YIELD_MS = 500;\nconst DEFAULT_TTL_MS = 300_000; // 5 min idle → reap\nconst DEFAULT_MAX_BYTES = 100_000;\n\n/** The minimal `node-pty` surface driven here (kept structural so the import stays lazy). */\ninterface PtyProcess {\n readonly pid: number;\n write(data: string): void;\n kill(signal?: string): void;\n onData(cb: (data: string) => void): void;\n onExit(cb: (e: { exitCode: number }) => void): void;\n}\ninterface PtyModule {\n spawn(file: string, args: string[], opts: Record<string, unknown>): PtyProcess;\n}\n\ninterface PtySession {\n id: string;\n pty: PtyProcess;\n pending: string;\n alive: boolean;\n ttlTimer: ReturnType<typeof setTimeout>;\n writeChain: Promise<unknown>;\n}\n\n/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */\nexport function clampYield(ms: number | undefined): number {\n const v = ms ?? DEFAULT_YIELD_MS;\n return Math.max(YIELD_MIN_MS, Math.min(YIELD_MAX_MS, v));\n}\n\n/** Keep the TAIL of output — the newest bytes carry the live prompt. */\nfunction capTail(buf: string, max: number): string {\n return buf.length > max ? `…(truncated)\\n${buf.slice(-max)}` : buf;\n}\n\n/**\n * M75 T3.1 — como o chamador embrulha o comando antes do spawn.\n *\n * Existe para que confinamento (sandbox) componha com PTY sem herança: o backend continua dono do\n * spawn, o chamador continua dono da política, e nenhum dos dois conhece o tipo do outro.\n */\nexport interface PtyInteractiveBackendOptions {\n /**\n * Transforma o comando imediatamente antes do spawn. Recebe o `cwd` JÁ RESOLVIDO — o PTY spawna\n * nele, então um wrap que mirasse outro diretório produziria confinamento que não confina nada.\n *\n * Devolver `null` significa **não embrulhe** — decisão explícita, distinta de \"embrulhei e deu no\n * mesmo\". É o caso do modo sem confinamento.\n */\n readonly wrapCommand?: (command: string, cwd: string) => string | null;\n}\n\nexport class PtyInteractiveBackend extends InteractiveBackend {\n private readonly sessions = new Map<string, PtySession>();\n private readonly wrapCommand: ((command: string, cwd: string) => string | null) | undefined;\n\n constructor(options: PtyInteractiveBackendOptions = {}) {\n super();\n this.wrapCommand = options.wrapCommand;\n }\n\n private ptyModule: PtyModule | null | undefined;\n private exitReaperArmed = false;\n\n /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */\n private loadPty(): PtyModule | null {\n if (this.ptyModule !== undefined) return this.ptyModule;\n try {\n this.ptyModule = nodeRequire(\"node-pty\") as PtyModule;\n } catch {\n this.ptyModule = null;\n }\n return this.ptyModule;\n }\n\n /** Whether the interactive (PTY) path is usable in this environment. */\n available(): boolean {\n return this.loadPty() !== null;\n }\n\n /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM\n * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */\n private armExitReaper(): void {\n if (this.exitReaperArmed) return;\n this.exitReaperArmed = true;\n process.on(\"exit\", () => this.killAll());\n }\n\n private async collect(session: PtySession, yieldMs: number, maxBytes: number): Promise<string> {\n await new Promise((r) => setTimeout(r, clampYield(yieldMs)));\n const out = session.pending;\n session.pending = \"\";\n return capTail(out, maxBytes);\n }\n\n private armTtl(session: PtySession, ttlMs: number): void {\n clearTimeout(session.ttlTimer);\n session.ttlTimer = setTimeout(() => this.kill(session.id), ttlMs);\n }\n\n /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the\n * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */\n private spawnPty(command: string, opts?: StartInteractiveOptions): PtyProcess {\n const pty = this.loadPty();\n if (pty === null) {\n throw new InteractiveUnavailableError(\n \"interactive shell unavailable: node-pty native module failed to load; use non-interactive exec\",\n );\n }\n const cwd = opts?.cwd ?? process.cwd();\n if (!existsSync(cwd)) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: cwd does not exist: ${cwd}`,\n );\n }\n // M75 T3.1 — o wrap entra AQUI: depois do cwd resolvido e validado, antes do spawn. É o ponto\n // único por onde todo comando passa, então não há caminho que escape do confinamento.\n const efetivo = this.wrapCommand?.(command, cwd) ?? command;\n\n const shell = process.env.SHELL ?? \"/bin/bash\";\n try {\n return pty.spawn(shell, [\"-c\", efetivo], {\n name: \"xterm-color\",\n cols: opts?.cols ?? 80,\n rows: opts?.rows ?? 24,\n cwd,\n env: process.env,\n });\n } catch (err) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: failed to spawn a PTY (${err instanceof Error ? err.message : String(err)})`,\n );\n }\n }\n\n async startInteractive(\n command: string,\n opts?: StartInteractiveOptions,\n ): Promise<StartInteractiveResult> {\n this.armExitReaper();\n const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;\n const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;\n const proc = this.spawnPty(command, opts);\n const id = `pty-${randomUUID()}`;\n const session: PtySession = {\n id,\n pty: proc,\n pending: \"\",\n alive: true,\n ttlTimer: setTimeout(() => this.kill(id), ttlMs),\n writeChain: Promise.resolve(),\n };\n proc.onData((data) => {\n session.pending = capTail(session.pending + data, maxBytes);\n });\n proc.onExit(() => {\n session.alive = false;\n clearTimeout(session.ttlTimer);\n this.sessions.delete(id);\n });\n this.sessions.set(id, session);\n const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);\n return { sessionId: id, output };\n }\n\n async writeStdin(\n sessionId: string,\n chars: string,\n opts?: WriteStdinOptions,\n ): Promise<WriteStdinResult> {\n const session = this.sessions.get(sessionId);\n if (session === undefined || !session.alive) throw new NoSuchSessionError(sessionId);\n // Serialize per session: chain onto the previous write so two concurrent calls run strictly in\n // order and each reads only its own output window (no stolen output).\n const run = session.writeChain.then(async () => {\n if (!session.alive) throw new NoSuchSessionError(sessionId);\n this.armTtl(session, opts?.ttlMs ?? DEFAULT_TTL_MS);\n if (chars.length > 0) session.pty.write(chars);\n const output = await this.collect(\n session,\n opts?.yieldMs ?? DEFAULT_YIELD_MS,\n opts?.maxBytes ?? DEFAULT_MAX_BYTES,\n );\n return { output, alive: session.alive };\n });\n session.writeChain = run.catch(() => undefined);\n return run;\n }\n\n /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */\n kill(sessionId: string): void {\n const session = this.sessions.get(sessionId);\n if (session === undefined) return;\n clearTimeout(session.ttlTimer);\n session.alive = false;\n this.sessions.delete(sessionId);\n try {\n process.kill(-session.pty.pid, \"SIGKILL\");\n } catch {\n try {\n session.pty.kill();\n } catch {\n // already dead — nothing to do\n }\n }\n }\n\n /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /** Live session count — for observability / tests. */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/pty-interactive-backend.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `PtyInteractiveBackend` — the LOCAL implementation of `@theokit/sdk`'s\n * `InteractiveBackend`, backed by `node-pty`. This is the terminal-surface\n * backend: it allocates a real pseudo-terminal so REPLs, `git rebase -i`, and\n * prompting commands can be driven to completion. Ported from the AgentBuilder\n * Codex clone (M11) into the ecosystem (M14) so any terminal agent injects it.\n *\n * `node-pty` is an OPTIONAL dependency of this package — the ONLY place in the\n * theokit ecosystem that touches it. Core / sdk-tools / cluster / desktop never\n * do. When the native module is unavailable (or a spawn fails), every method\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec.\n *\n * Safety (mirrors M11): graceful typed degradation; per-session write\n * serialization (concurrent writes never steal each other's output); idle TTL\n * reaper; process-GROUP kill so detached grandchildren die; a process-exit\n * reaper; tail-capped output (the live prompt, not the stale banner).\n */\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\n\nimport {\n InteractiveBackend,\n InteractiveUnavailableError,\n NoSuchSessionError,\n type StartInteractiveOptions,\n type StartInteractiveResult,\n type WriteStdinOptions,\n type WriteStdinResult,\n} from \"@theokit/sdk/interactive\";\n\nconst nodeRequire = createRequire(import.meta.url);\n\nexport const YIELD_MIN_MS = 250;\nexport const YIELD_MAX_MS = 30_000;\nconst DEFAULT_YIELD_MS = 500;\nconst DEFAULT_TTL_MS = 300_000; // 5 min idle → reap\nconst DEFAULT_MAX_BYTES = 100_000;\n\n/** The minimal `node-pty` surface driven here (kept structural so the import stays lazy). */\ninterface PtyProcess {\n readonly pid: number;\n write(data: string): void;\n kill(signal?: string): void;\n onData(cb: (data: string) => void): void;\n onExit(cb: (e: { exitCode: number }) => void): void;\n}\ninterface PtyModule {\n spawn(file: string, args: string[], opts: Record<string, unknown>): PtyProcess;\n}\n\ninterface PtySession {\n id: string;\n pty: PtyProcess;\n pending: string;\n alive: boolean;\n ttlTimer: ReturnType<typeof setTimeout>;\n writeChain: Promise<unknown>;\n}\n\n/** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */\nexport function clampYield(ms: number | undefined): number {\n const v = ms ?? DEFAULT_YIELD_MS;\n return Math.max(YIELD_MIN_MS, Math.min(YIELD_MAX_MS, v));\n}\n\n/** Keep the TAIL of output — the newest bytes carry the live prompt. */\nfunction capTail(buf: string, max: number): string {\n return buf.length > max ? `…(truncated)\\n${buf.slice(-max)}` : buf;\n}\n\n/**\n * M75 T3.1 — how the caller wraps the command before the spawn.\n *\n * It exists so confinement (sandbox) composes with the PTY without inheritance: the backend keeps owning the\n * spawn, the caller keeps owning the policy, and neither knows the other's type.\n */\nexport interface PtyInteractiveBackendOptions {\n /**\n * Transforms the command immediately before the spawn. It receives the ALREADY-RESOLVED `cwd` — the PTY spawns\n * in it, so a wrap targeting another directory would produce confinement that confines nothing.\n *\n * Returning `null` means **do not wrap** — an explicit decision, distinct from \"I wrapped and it made no\n * difference\". It is the case of the unconfined mode.\n */\n readonly wrapCommand?: (command: string, cwd: string) => string | null;\n\n /**\n * M77 — ceiling on simultaneously LIVE sessions. Absent => no ceiling (the long-standing behavior).\n *\n * Each session is a real process with a 5-minute TTL. A model that does not notice it already has a\n * shell open opens another, and the TTL only collects later — too late when the limit is the machine's\n * PID count. On overflow, {@link MaxSessionsError} lists the live sessions, because the correct\n * action is to **reuse** one of them, and an error that does not say so only teaches the model to retry.\n */\n readonly maxSessions?: number;\n}\n\n/**\n * M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.\n *\n * Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough context\n * enough context to act, and here the action is reusing an existing session. An error merely stating\n * \"limit reached\" would leave the model with no way out — it would retry, and fail again.\n */\nexport class MaxSessionsError extends InteractiveUnavailableError {\n constructor(\n readonly max: number,\n readonly liveSessionIds: readonly string[],\n ) {\n super(\n `interactive session limit reached (${String(max)} live). ` +\n `Reuse one of the open sessions instead of starting another: ${liveSessionIds.join(\", \")}`,\n );\n }\n}\n\nexport class PtyInteractiveBackend extends InteractiveBackend {\n private readonly sessions = new Map<string, PtySession>();\n private readonly wrapCommand: ((command: string, cwd: string) => string | null) | undefined;\n /** M77 — live-session ceiling; `undefined` means unlimited (the historical behaviour). */\n private readonly maxSessions: number | undefined;\n\n constructor(options: PtyInteractiveBackendOptions = {}) {\n super();\n this.wrapCommand = options.wrapCommand;\n this.maxSessions = options.maxSessions;\n }\n\n private ptyModule: PtyModule | null | undefined;\n private exitReaperArmed = false;\n\n /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */\n private loadPty(): PtyModule | null {\n if (this.ptyModule !== undefined) return this.ptyModule;\n try {\n this.ptyModule = nodeRequire(\"node-pty\") as PtyModule;\n } catch {\n this.ptyModule = null;\n }\n return this.ptyModule;\n }\n\n /** Whether the interactive (PTY) path is usable in this environment. */\n available(): boolean {\n return this.loadPty() !== null;\n }\n\n /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM\n * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */\n private armExitReaper(): void {\n if (this.exitReaperArmed) return;\n this.exitReaperArmed = true;\n process.on(\"exit\", () => this.killAll());\n }\n\n private async collect(session: PtySession, yieldMs: number, maxBytes: number): Promise<string> {\n await new Promise((r) => setTimeout(r, clampYield(yieldMs)));\n const out = session.pending;\n session.pending = \"\";\n return capTail(out, maxBytes);\n }\n\n private armTtl(session: PtySession, ttlMs: number): void {\n clearTimeout(session.ttlTimer);\n session.ttlTimer = setTimeout(() => this.kill(session.id), ttlMs);\n }\n\n /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the\n * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */\n private spawnPty(command: string, opts?: StartInteractiveOptions): PtyProcess {\n const pty = this.loadPty();\n if (pty === null) {\n throw new InteractiveUnavailableError(\n \"interactive shell unavailable: node-pty native module failed to load; use non-interactive exec\",\n );\n }\n const cwd = opts?.cwd ?? process.cwd();\n if (!existsSync(cwd)) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: cwd does not exist: ${cwd}`,\n );\n }\n // M75 T3.1 — the wrap goes HERE: after the cwd is resolved and validated, before the spawn. It is the\n // single point every command passes through, so no path escapes the confinement.\n const effective = this.wrapCommand?.(command, cwd) ?? command;\n\n const shell = process.env.SHELL ?? \"/bin/bash\";\n try {\n return pty.spawn(shell, [\"-c\", effective], {\n name: \"xterm-color\",\n cols: opts?.cols ?? 80,\n rows: opts?.rows ?? 24,\n cwd,\n env: process.env,\n });\n } catch (err) {\n throw new InteractiveUnavailableError(\n `interactive shell unavailable: failed to spawn a PTY (${err instanceof Error ? err.message : String(err)})`,\n );\n }\n }\n\n async startInteractive(\n command: string,\n opts?: StartInteractiveOptions,\n ): Promise<StartInteractiveResult> {\n this.armExitReaper();\n // M77 — the ceiling, checked against LIVE sessions (`onExit` and `kill` both delete from the\n // Map), so killing one frees a slot.\n //\n // ATOMICITY, and why it is not an accident to preserve carelessly: everything from here down to\n // `this.sessions.set(id, session)` is SYNCHRONOUS — `spawnPty` does not await. Two concurrent\n // `startInteractive` calls therefore cannot interleave between this check and the insert, so the\n // ceiling holds without a lock. If a future refactor makes any step in that span asynchronous\n // (an `await this.loadPty()` would be the plausible one), both callers would observe the old\n // count and both would pass. `tests/max-sessions.test.ts` covers exactly that regression.\n if (this.maxSessions !== undefined && this.sessions.size >= this.maxSessions) {\n throw new MaxSessionsError(this.maxSessions, [...this.sessions.keys()]);\n }\n const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;\n const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;\n const proc = this.spawnPty(command, opts);\n const id = `pty-${randomUUID()}`;\n const session: PtySession = {\n id,\n pty: proc,\n pending: \"\",\n alive: true,\n ttlTimer: setTimeout(() => this.kill(id), ttlMs),\n writeChain: Promise.resolve(),\n };\n proc.onData((data) => {\n session.pending = capTail(session.pending + data, maxBytes);\n });\n proc.onExit(() => {\n session.alive = false;\n clearTimeout(session.ttlTimer);\n this.sessions.delete(id);\n });\n this.sessions.set(id, session);\n const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);\n return { sessionId: id, output };\n }\n\n async writeStdin(\n sessionId: string,\n chars: string,\n opts?: WriteStdinOptions,\n ): Promise<WriteStdinResult> {\n const session = this.sessions.get(sessionId);\n if (session === undefined || !session.alive) throw new NoSuchSessionError(sessionId);\n // Serialize per session: chain onto the previous write so two concurrent calls run strictly in\n // order and each reads only its own output window (no stolen output).\n const run = session.writeChain.then(async () => {\n if (!session.alive) throw new NoSuchSessionError(sessionId);\n this.armTtl(session, opts?.ttlMs ?? DEFAULT_TTL_MS);\n if (chars.length > 0) session.pty.write(chars);\n const output = await this.collect(\n session,\n opts?.yieldMs ?? DEFAULT_YIELD_MS,\n opts?.maxBytes ?? DEFAULT_MAX_BYTES,\n );\n return { output, alive: session.alive };\n });\n session.writeChain = run.catch(() => undefined);\n return run;\n }\n\n /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */\n kill(sessionId: string): void {\n const session = this.sessions.get(sessionId);\n if (session === undefined) return;\n clearTimeout(session.ttlTimer);\n session.alive = false;\n this.sessions.delete(sessionId);\n try {\n process.kill(-session.pty.pid, \"SIGKILL\");\n } catch {\n try {\n session.pty.kill();\n } catch {\n // already dead — nothing to do\n }\n }\n }\n\n /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /** Live session count — for observability / tests. */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theokit/sdk-pty",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local interactive-shell backend for @theokit/sdk
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Local interactive-shell backend for @theokit/sdk — a node-pty implementation of InteractiveBackend. Opt-in, terminal-surface only; node-pty is optional so install never fails on a native build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node-pty": "^1.1.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@theokit/sdk": "
|
|
34
|
+
"@theokit/sdk": "^4.40.0",
|
|
35
35
|
"tsup": "^8.3.5",
|
|
36
36
|
"typescript": "^5.7.2",
|
|
37
37
|
"vitest": "^4.1.8"
|