@theokit/sdk-pty 0.3.1 → 0.3.4

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/CHANGELOG.md ADDED
@@ -0,0 +1,138 @@
1
+ # @theokit/sdk-pty
2
+
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - e3f2a82: Public-API documentation reviewed file by file, and corrected wherever it disagreed
8
+ with the code. The docblocks ship in the `.d.ts`, so these read as behaviour changes
9
+ in an editor even though no behaviour changed.
10
+
11
+ The corrections that change what a caller would do:
12
+
13
+ - **`sdk-cache` documented its own premise backwards.** The header example labelled a
14
+ semantic hit as if it avoided the provider call. `asPlugin()` returns the cached
15
+ answer as `recalledContext`, which the agent loop injects as a `<memory-context>`
16
+ block _before_ the prompt — the request still goes to the provider. The two modes
17
+ are now labelled separately, with a table saying which one short-circuits and which
18
+ one seeds.
19
+ - **`sdk-handoff`'s five error classes said "throw".** Under the plugin wiring the
20
+ handler never throws; every failure becomes a tool result `{"ok":false,…}` handed
21
+ back to the model. Each class now says where it is actually observable. The header
22
+ also told readers to `import { Handoff } from "@theokit/sdk"`, from which it was
23
+ extracted.
24
+ - **`sdk-budget`'s `charge()` claimed idempotency across concurrent calls.** The mutex
25
+ serialises, it does not deduplicate: two identical calls record twice. Related, and
26
+ newly documented: with `maxUsd` set, a model missing from the pricing table denies
27
+ every request rather than passing it — and the table matches by exact string, so
28
+ `"openai/gpt-4o"` does not match `"gpt-4o"`.
29
+ - **The three `memory-*` adapters advertised an env-var fallback they do not read**,
30
+ and their peer dependencies are required rather than optional. Their behavioural
31
+ differences are now stated where they break the "interchangeable adapter"
32
+ assumption — honcho ignores `k` and always throws on `delete`; mem0 recalls across
33
+ sessions by design; supermemory ignores `sessionId` entirely.
34
+ - **`sdk-memory`'s `truncated` flag was documented as its own inverse**, and its
35
+ dreaming sweep claimed a mutex it never takes against the writer it names.
36
+ - **`sdk-tools`** corrected `run_vitest`'s unreachable `no_vitest` code, `truncation`'s
37
+ replacement-character claim, and two return shapes missing a live error code.
38
+ - **`acp`/`cli`** corrected sixteen statements including a named error class that is
39
+ not the one raised, a handler documented as calling `fork()` that refuses
40
+ unconditionally, handlers described as pure that mint ids and mutate a store, a
41
+ config loader credited to Zod in a package that does not import it, and a `--force`
42
+ scaffold described as atomic that deletes the destination before the rename.
43
+
44
+ Undocumented public symbols were documented across every package, with each claim
45
+ checked against the implementation rather than inferred from the name.
46
+
47
+ - e368fc1: Every published declaration file now compiles without `skipLibCheck` (#345). The
48
+ DTS rollup emitted symbols as a re-export from a chunk while omitting them from
49
+ that chunk's `import`, and dropped type-only imports from external packages —
50
+ leaving 51 unresolved references across ten of the twelve packages. Nothing broke
51
+ at runtime, and `tsc` stayed green for anyone with `skipLibCheck` on, but a
52
+ consumer running type-aware lint saw every type reached through one degrade to
53
+ `error`.
54
+
55
+ The declarations are repaired at build time from the compiler's own diagnostics.
56
+ No source or API change.
57
+
58
+ - 1ac974f: **`@theokit/sdk-pty` declares its licence.** Every published version up to now shipped with no `license` field in the manifest. npm reads the field, not the directory, so the tarball was all-rights-reserved to whoever installed it — the terms were sitting in the `LICENSE` file it already shipped, saying nothing. The field now says `Apache-2.0`, which is what that file has always been and what all eleven sibling packages declare.
59
+
60
+ **Four packages now ship the licence they declare.** `@theokit/cli`, `@theokit/memory-honcho`, `@theokit/memory-mem0` and `@theokit/memory-supermemory` declared `Apache-2.0` and listed `LICENSE` in `files`, and no such file existed. npm omits a declared-but-absent path in silence, so every published tarball asserted the licence while carrying none of its terms — and §4(a) requires a copy to travel with the distribution. The file is there now, byte-identical to the one the other packages ship.
61
+
62
+ **Six packages complete the rest of their published metadata.** Each field is here for what its absence costs a consumer:
63
+
64
+ - `homepage` and `bugs` — the npm page renders both; without them someone who hits a defect has no route back to the project.
65
+ - `engines.node` — npm warns on an unsupported runtime only when the range is declared. `@theokit/sdk-pty` declared none, so a Node 18 install failed later and somewhere unrelated.
66
+ - `sideEffects` — a bundler keeps every module of a package that stays silent. Declared only after checking: a clean scan of each built ESM entry found zero top-level statements, the residual hits being closing tokens of declarations. `@theokit/sdk` keeps its path-array form, which is the honest shape for a package whose agent entry registers on import.
67
+ - `publishConfig.access` — a scoped package defaults to `restricted`. Three declared none and reached npm public only because the release flow supplied the flag; the manifest states it now instead of depending on how it is invoked.
68
+ - `@theokit/sdk-pty` also ships its `CHANGELOG.md`, which existed on disk and was absent from `files`.
69
+
70
+ The gate that should have caught any of this covered three packages out of twelve, by way of a hand-written list. It now derives the list from `packages/`, asserts the whole contract, and fails when the sweep discovers nothing rather than passing by having nothing to check.
71
+
72
+ - e699569: **The repository moved to the official `usetheokit` organization.** Every `repository`, `bugs` and `homepage` field now points there, along with the README, `CONTRIBUTING.md`, `SECURITY.md` and the issue templates. Existing clones and any URL already published keep working — GitHub redirects a transferred repository permanently — so this is a correctness fix for the metadata npm renders, not a break.
73
+
74
+ **The Apache-2.0 text every package ships was replaced with the official one.** The copy distributed until now had paragraph 4(d) truncated: it read "except as required for describing the origin of the Work and reproducing the content of the NOTICE file", dropping "reasonable and customary use" from the licensed clause. §4(d) governs what a redistributor must do with attribution notices, and the omission narrowed it.
75
+
76
+ That matters more than a typo would. The manifests declare the SPDX identifier `Apache-2.0`, which is an assertion that the terms are _the_ Apache-2.0 terms — a licence scanner resolves the identifier and never reads the file. A consumer's compliance review, which does read the file, would find a body that no longer matches the identifier and has no name of its own. Every `LICENSE` in this repository is now byte-identical to the canonical text, with the appendix filled in.
77
+
78
+ Nothing else about the terms changed: the licence is the same licence it has always been meant to be, and no package changes what it grants.
79
+
80
+ - c7385d2: Test runs no longer claim every core on the host.
81
+
82
+ None of the package configs capped `maxWorkers`, so vitest's default applied: `os.availableParallelism()`,
83
+ one fork per core, each booting a full test environment. The repo's `test` script is
84
+ `turbo run test --filter='./packages/*'`, so that default is paid once per package _concurrently_ —
85
+ nproc forks times turbo's concurrency, on nproc cores. Measured on a 12-thread machine during an
86
+ unrelated investigation, two vitest pools alone were enough to reach load average 33.89 with the
87
+ desktop unusable; a full fan-out is several times that.
88
+
89
+ `@theokit/sdk` is the interesting case. B-104 recorded on 2026-08-19 that the `poolOptions.forks.*`
90
+ block was 100% dead in Vitest 4, deleted it, and noted that `fileParallelism: false` was forcing
91
+ `maxWorkers` to 1 unconditionally, so a fork-count knob could not act. B-059 then flipped
92
+ `fileParallelism` to `true` on 2026-08-20, which made the knob able to act again — and nothing
93
+ reintroduced one, so the package silently went back to the uncapped default. That comment has been
94
+ corrected along with the config; it claimed no knob existed, which is no longer true.
95
+
96
+ The cap leaves 4 cores free (`Math.max(2, cpus().length - 4)`), scaling with the runner rather than
97
+ hard-coding one machine's core count. It costs no wall-clock: measured in `theokit-ui`, the full
98
+ suite ran 73.96s at 4 workers against 74.36s at 12, so the parallelism above the cap was already
99
+ noise. Verified as resolved config rather than as file contents — `createVitest` reports
100
+ `maxWorkers: 8` on a 12-thread host, which is the formula, not the default.
101
+
102
+ This changes no published behaviour; it is test tooling only. Refs usetheokit/theokit-ui#51.
103
+
104
+ ## 0.3.3
105
+
106
+ ### Patch Changes
107
+
108
+ - a3ae640: Declare `repository` so these packages can publish with provenance.
109
+
110
+ npm cross-checks a manifest's `repository.url` against the repository recorded in the signed
111
+ provenance statement, and an empty value cannot match — the PUT is refused with E422 after the
112
+ statement has been signed and written to the public transparency log. Six of the twelve publishable
113
+ packages carried an empty field; it went unnoticed because nothing needed it until provenance was
114
+ enabled, and because each package publishes independently, so the release run went red while the
115
+ package everyone was watching succeeded.
116
+
117
+ `directory` is set alongside the URL, so the registry links to each package rather than to the
118
+ repository root.
119
+
120
+ ## 0.3.2
121
+
122
+ ### Patch Changes
123
+
124
+ - 8790f70: Refuse a `workspace:` range before it can reach npm.
125
+
126
+ Five of this repo's twelve publishable packages declare internal dependencies as `workspace:^`, which
127
+ is correct on disk and becomes an unrecoverable defect if the publish goes out through a tool that
128
+ does not rewrite it: `pnpm` resolves the protocol while packing, `npm` ships the manifest verbatim.
129
+ A version published that way fails to install for everyone and cannot be corrected — only
130
+ deprecated.
131
+
132
+ Every publishable package now runs the guard in `prepublishOnly`, so it fires whichever way the
133
+ publish is invoked, and `pnpm release` runs it once across the repo before `changeset publish`.
134
+
135
+ Note for anyone reading a published manifest: the `prepublishOnly` entry points at a path inside
136
+ this repository. It never runs for a consumer — the hook only fires when the package itself is
137
+ published — and guarding the entry point that a hand-run `npm publish` actually uses was worth the
138
+ cosmetic wart of shipping the line.
package/LICENSE CHANGED
@@ -137,8 +137,8 @@
137
137
 
138
138
  6. Trademarks. This License does not grant permission to use the trade
139
139
  names, trademarks, service marks, or product names of the Licensor,
140
- except as required for describing the origin of the Work and
141
- reproducing the content of the NOTICE file.
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
142
 
143
143
  7. Disclaimer of Warranty. Unless required by applicable law or
144
144
  agreed to in writing, Licensor provides the Work (and each
package/README.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  Local, `node-pty`-backed implementation of `@theokit/sdk`'s `InteractiveBackend`.
4
4
 
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @theokit/sdk-pty
9
+ ```
10
+
11
+ `@theokit/sdk` (>=4.4.1) is a peer dependency. `node-pty` is an OPTIONAL dependency of this package,
12
+ so an environment that cannot build the native module still installs — and every method then throws
13
+ the SDK's typed `InteractiveUnavailableError`, which is the signal to fall back to non-interactive
14
+ exec rather than a crash at import time.
15
+
5
16
  Opt-in and **terminal-surface only** — this is the ONLY package in the theokit
6
17
  ecosystem that depends on `node-pty` (optionally, so install never fails on a
7
18
  native build). Core / `@theokit/sdk-tools` / cluster / desktop backends never
@@ -15,3 +26,16 @@ const interactive = new PtyInteractiveBackend();
15
26
 
16
27
  When `node-pty` is unavailable (or a spawn fails), every method throws the SDK's
17
28
  typed `InteractiveUnavailableError` so the caller falls back to non-interactive exec.
29
+
30
+ ## API reference
31
+
32
+ Every symbol this package exports, with the exact specifier to import it from, is in the generated
33
+ capability map that ships inside `@theokit/sdk`:
34
+
35
+ ```
36
+ node_modules/@theokit/sdk/docs/harness-capability-map.md # symbol -> import specifier
37
+ node_modules/@theokit/sdk/docs/error-codes.md # every `code` an error can carry
38
+ ```
39
+
40
+ Both are generated from the built type declarations, so they describe the version you installed
41
+ rather than the version someone wrote a page about.
package/dist/index.cjs CHANGED
@@ -54,7 +54,17 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
54
54
  }
55
55
  return this.ptyModule;
56
56
  }
57
- /** Whether the interactive (PTY) path is usable in this environment. */
57
+ /**
58
+ * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.
59
+ *
60
+ * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch
61
+ * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and
62
+ * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a
63
+ * module installed afterwards.
64
+ *
65
+ * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —
66
+ * both surface as {@link InteractiveUnavailableError} from `startInteractive`.
67
+ */
58
68
  available() {
59
69
  return this.loadPty() !== null;
60
70
  }
@@ -106,6 +116,37 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
106
116
  );
107
117
  }
108
118
  }
119
+ /**
120
+ * Spawn `command` on a real pty and return its id plus whatever it printed during the yield
121
+ * window.
122
+ *
123
+ * ```ts
124
+ * const { sessionId, output } = await backend.startInteractive("python3 -i");
125
+ * await backend.writeStdin(sessionId, "print(1+1)\n");
126
+ * backend.kill(sessionId);
127
+ * ```
128
+ *
129
+ * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —
130
+ * so shell syntax works, and so does the user's rc-file behaviour. The child inherits
131
+ * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is
132
+ * visible to it.
133
+ *
134
+ * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},
135
+ * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,
136
+ * `rows` 24, `cwd` `process.cwd()`.
137
+ *
138
+ * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is
139
+ * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow
140
+ * starter returns an empty string that reads exactly like "the program said nothing". Poll with
141
+ * `writeStdin(id, "")` rather than raising `yieldMs`.
142
+ *
143
+ * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist
144
+ * (checked here because node-pty spawns a broken session instead of failing), or when the spawn
145
+ * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent
146
+ * swallows the distinction — when `maxSessions` is set and reached.
147
+ *
148
+ * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.
149
+ */
109
150
  async startInteractive(command, opts) {
110
151
  this.armExitReaper();
111
152
  if (this.maxSessions !== void 0 && this.sessions.size >= this.maxSessions) {
@@ -135,6 +176,25 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
135
176
  const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);
136
177
  return { sessionId: id, output };
137
178
  }
179
+ /**
180
+ * Write `chars` to a live session's stdin and return the output produced during the yield window,
181
+ * plus whether the process is still alive.
182
+ *
183
+ * YOU SUPPLY THE NEWLINE. `"print(1+1)"` leaves a REPL waiting; `"print(1+1)\n"` runs it. An
184
+ * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still
185
+ * producing output.
186
+ *
187
+ * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match
188
+ * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.
189
+ *
190
+ * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —
191
+ * two writes in flight cannot steal each other's output. They are not, however, interleaved with
192
+ * anything the program prints unprompted, which is drained by whichever call is waiting.
193
+ *
194
+ * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for
195
+ * idleness. That is a different fact from a failing command: the correct response is to start a
196
+ * new session, not to report an error from the program.
197
+ */
138
198
  async writeStdin(sessionId, chars, opts) {
139
199
  const session = this.sessions.get(sessionId);
140
200
  if (session === void 0 || !session.alive) throw new interactive.NoSuchSessionError(sessionId);
@@ -152,7 +212,14 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
152
212
  session.writeChain = run.catch(() => void 0);
153
213
  return run;
154
214
  }
155
- /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */
215
+ /**
216
+ * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a
217
+ * silent no-op, never a throw.
218
+ *
219
+ * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful
220
+ * shutdown and no chance for the program to flush or save. Falls back to killing just the pty
221
+ * process if the group kill fails. Any output buffered but not yet collected is discarded.
222
+ */
156
223
  kill(sessionId) {
157
224
  const session = this.sessions.get(sessionId);
158
225
  if (session === void 0) return;
@@ -168,11 +235,24 @@ var PtyInteractiveBackend = class extends interactive.InteractiveBackend {
168
235
  }
169
236
  }
170
237
  }
171
- /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */
238
+ /**
239
+ * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`
240
+ * contract.
241
+ *
242
+ * Also runs automatically on the host process's `exit` event, armed lazily on the first
243
+ * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's
244
+ * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.
245
+ */
172
246
  killAll() {
173
247
  for (const id of [...this.sessions.keys()]) this.kill(id);
174
248
  }
175
- /** Live session count — for observability / tests. */
249
+ /**
250
+ * How many sessions this instance currently holds — the number `maxSessions` is compared against.
251
+ * Extra to the `InteractiveBackend` contract.
252
+ *
253
+ * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a
254
+ * counter, and it never counts sessions belonging to another backend instance.
255
+ */
176
256
  activeSessionCount() {
177
257
  return this.sessions.size;
178
258
  }
@@ -1 +1 @@
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"]}
1
+ {"version":3,"sources":["../src/pty-interactive-backend.ts"],"names":["createRequire","InteractiveUnavailableError","InteractiveBackend","existsSync","randomUUID","NoSuchSessionError"],"mappings":";;;;;;;;;AAiCA,IAAM,WAAA,GAAcA,sBAAA,CAAc,2PAAe,CAAA;AAW1C,IAAM,YAAA,GAAe;AASrB,IAAM,YAAA,GAAe;AAC5B,IAAM,gBAAA,GAAmB,GAAA;AACzB,IAAM,cAAA,GAAiB,GAAA;AACvB,IAAM,iBAAA,GAAoB,GAAA;AAwBnB,SAAS,WAAW,EAAA,EAAgC;AACzD,EAAA,MAAM,IAAI,EAAA,IAAM,gBAAA;AAChB,EAAA,OAAO,KAAK,GAAA,CAAI,YAAA,EAAc,KAAK,GAAA,CAAI,YAAA,EAAc,CAAC,CAAC,CAAA;AACzD;AAGA,SAAS,OAAA,CAAQ,KAAa,GAAA,EAAqB;AACjD,EAAA,OAAO,GAAA,CAAI,SAAS,GAAA,GAAM,CAAA;AAAA,EAAiB,GAAA,CAAI,KAAA,CAAM,CAAC,GAAG,CAAC,CAAA,CAAA,GAAK,GAAA;AACjE;AA4CO,IAAM,gBAAA,GAAN,cAA+BC,uCAAA,CAA4B;AAAA,EAChE,WAAA,CACW,KACA,cAAA,EACT;AACA,IAAA,KAAA;AAAA,MACE,CAAA,mCAAA,EAAsC,OAAO,GAAG,CAAC,uEACgB,cAAA,CAAe,IAAA,CAAK,IAAI,CAAC,CAAA;AAAA,KAC5F;AANS,IAAA,IAAA,CAAA,GAAA,GAAA,GAAA;AACA,IAAA,IAAA,CAAA,cAAA,GAAA,cAAA;AAAA,EAMX;AAAA,EAPW,GAAA;AAAA,EACA,cAAA;AAOb;AAoBO,IAAM,qBAAA,GAAN,cAAoCC,8BAAA,CAAmB;AAAA,EAC3C,QAAA,uBAAe,GAAA,EAAwB;AAAA,EACvC,WAAA;AAAA;AAAA,EAEA,WAAA;AAAA,EAEjB,WAAA,CAAY,OAAA,GAAwC,EAAC,EAAG;AACtD,IAAA,KAAA,EAAM;AACN,IAAA,IAAA,CAAK,cAAc,OAAA,CAAQ,WAAA;AAC3B,IAAA,IAAA,CAAK,cAAc,OAAA,CAAQ,WAAA;AAAA,EAC7B;AAAA,EAEQ,SAAA;AAAA,EACA,eAAA,GAAkB,KAAA;AAAA;AAAA,EAGlB,OAAA,GAA4B;AAClC,IAAA,IAAI,IAAA,CAAK,SAAA,KAAc,MAAA,EAAW,OAAO,IAAA,CAAK,SAAA;AAC9C,IAAA,IAAI;AACF,MAAA,IAAA,CAAK,SAAA,GAAY,YAAY,UAAU,CAAA;AAAA,IACzC,CAAA,CAAA,MAAQ;AACN,MAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AAAA,IACnB;AACA,IAAA,OAAO,IAAA,CAAK,SAAA;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,SAAA,GAAqB;AACnB,IAAA,OAAO,IAAA,CAAK,SAAQ,KAAM,IAAA;AAAA,EAC5B;AAAA;AAAA;AAAA,EAIQ,aAAA,GAAsB;AAC5B,IAAA,IAAI,KAAK,eAAA,EAAiB;AAC1B,IAAA,IAAA,CAAK,eAAA,GAAkB,IAAA;AACvB,IAAA,OAAA,CAAQ,EAAA,CAAG,MAAA,EAAQ,MAAM,IAAA,CAAK,SAAS,CAAA;AAAA,EACzC;AAAA,EAEA,MAAc,OAAA,CAAQ,OAAA,EAAqB,OAAA,EAAiB,QAAA,EAAmC;AAC7F,IAAA,MAAM,IAAI,QAAQ,CAAC,CAAA,KAAM,WAAW,CAAA,EAAG,UAAA,CAAW,OAAO,CAAC,CAAC,CAAA;AAC3D,IAAA,MAAM,MAAM,OAAA,CAAQ,OAAA;AACpB,IAAA,OAAA,CAAQ,OAAA,GAAU,EAAA;AAClB,IAAA,OAAO,OAAA,CAAQ,KAAK,QAAQ,CAAA;AAAA,EAC9B;AAAA,EAEQ,MAAA,CAAO,SAAqB,KAAA,EAAqB;AACvD,IAAA,YAAA,CAAa,QAAQ,QAAQ,CAAA;AAC7B,IAAA,OAAA,CAAQ,QAAA,GAAW,WAAW,MAAM,IAAA,CAAK,KAAK,OAAA,CAAQ,EAAE,GAAG,KAAK,CAAA;AAAA,EAClE;AAAA;AAAA;AAAA,EAIQ,QAAA,CAAS,SAAiB,IAAA,EAA4C;AAC5E,IAAA,MAAM,GAAA,GAAM,KAAK,OAAA,EAAQ;AACzB,IAAA,IAAI,QAAQ,IAAA,EAAM;AAChB,MAAA,MAAM,IAAID,uCAAA;AAAA,QACR;AAAA,OACF;AAAA,IACF;AACA,IAAA,MAAM,GAAA,GAAM,IAAA,EAAM,GAAA,IAAO,OAAA,CAAQ,GAAA,EAAI;AACrC,IAAA,IAAI,CAACE,aAAA,CAAW,GAAG,CAAA,EAAG;AACpB,MAAA,MAAM,IAAIF,uCAAA;AAAA,QACR,sDAAsD,GAAG,CAAA;AAAA,OAC3D;AAAA,IACF;AAGA,IAAA,MAAM,SAAA,GAAY,IAAA,CAAK,WAAA,GAAc,OAAA,EAAS,GAAG,CAAA,IAAK,OAAA;AAEtD,IAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,GAAA,CAAI,KAAA,IAAS,WAAA;AACnC,IAAA,IAAI;AACF,MAAA,OAAO,IAAI,KAAA,CAAM,KAAA,EAAO,CAAC,IAAA,EAAM,SAAS,CAAA,EAAG;AAAA,QACzC,IAAA,EAAM,aAAA;AAAA,QACN,IAAA,EAAM,MAAM,IAAA,IAAQ,EAAA;AAAA,QACpB,IAAA,EAAM,MAAM,IAAA,IAAQ,EAAA;AAAA,QACpB,GAAA;AAAA,QACA,KAAK,OAAA,CAAQ;AAAA,OACd,CAAA;AAAA,IACH,SAAS,GAAA,EAAK;AACZ,MAAA,MAAM,IAAIA,uCAAA;AAAA,QACR,yDAAyD,GAAA,YAAe,KAAA,GAAQ,IAAI,OAAA,GAAU,MAAA,CAAO,GAAG,CAAC,CAAA,CAAA;AAAA,OAC3G;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiCA,MAAM,gBAAA,CACJ,OAAA,EACA,IAAA,EACiC;AACjC,IAAA,IAAA,CAAK,aAAA,EAAc;AAUnB,IAAA,IAAI,KAAK,WAAA,KAAgB,MAAA,IAAa,KAAK,QAAA,CAAS,IAAA,IAAQ,KAAK,WAAA,EAAa;AAC5E,MAAA,MAAM,IAAI,gBAAA,CAAiB,IAAA,CAAK,WAAA,EAAa,CAAC,GAAG,IAAA,CAAK,QAAA,CAAS,IAAA,EAAM,CAAC,CAAA;AAAA,IACxE;AACA,IAAA,MAAM,KAAA,GAAQ,MAAM,KAAA,IAAS,cAAA;AAC7B,IAAA,MAAM,QAAA,GAAW,MAAM,QAAA,IAAY,iBAAA;AACnC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,QAAA,CAAS,OAAA,EAAS,IAAI,CAAA;AACxC,IAAA,MAAM,EAAA,GAAK,CAAA,IAAA,EAAOG,iBAAA,EAAY,CAAA,CAAA;AAC9B,IAAA,MAAM,OAAA,GAAsB;AAAA,MAC1B,EAAA;AAAA,MACA,GAAA,EAAK,IAAA;AAAA,MACL,OAAA,EAAS,EAAA;AAAA,MACT,KAAA,EAAO,IAAA;AAAA,MACP,UAAU,UAAA,CAAW,MAAM,KAAK,IAAA,CAAK,EAAE,GAAG,KAAK,CAAA;AAAA,MAC/C,UAAA,EAAY,QAAQ,OAAA;AAAQ,KAC9B;AACA,IAAA,IAAA,CAAK,MAAA,CAAO,CAAC,IAAA,KAAS;AACpB,MAAA,OAAA,CAAQ,OAAA,GAAU,OAAA,CAAQ,OAAA,CAAQ,OAAA,GAAU,MAAM,QAAQ,CAAA;AAAA,IAC5D,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,OAAO,MAAM;AAChB,MAAA,OAAA,CAAQ,KAAA,GAAQ,KAAA;AAChB,MAAA,YAAA,CAAa,QAAQ,QAAQ,CAAA;AAC7B,MAAA,IAAA,CAAK,QAAA,CAAS,OAAO,EAAE,CAAA;AAAA,IACzB,CAAC,CAAA;AACD,IAAA,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,EAAA,EAAI,OAAO,CAAA;AAC7B,IAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,OAAA,CAAQ,SAAS,IAAA,EAAM,OAAA,IAAW,kBAAkB,QAAQ,CAAA;AACtF,IAAA,OAAO,EAAE,SAAA,EAAW,EAAA,EAAI,MAAA,EAAO;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqBA,MAAM,UAAA,CACJ,SAAA,EACA,KAAA,EACA,IAAA,EAC2B;AAC3B,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA;AAC3C,IAAA,IAAI,OAAA,KAAY,UAAa,CAAC,OAAA,CAAQ,OAAO,MAAM,IAAIC,+BAAmB,SAAS,CAAA;AAGnF,IAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,UAAA,CAAW,IAAA,CAAK,YAAY;AAC9C,MAAA,IAAI,CAAC,OAAA,CAAQ,KAAA,EAAO,MAAM,IAAIA,+BAAmB,SAAS,CAAA;AAC1D,MAAA,IAAA,CAAK,MAAA,CAAO,OAAA,EAAS,IAAA,EAAM,KAAA,IAAS,cAAc,CAAA;AAClD,MAAA,IAAI,MAAM,MAAA,GAAS,CAAA,EAAG,OAAA,CAAQ,GAAA,CAAI,MAAM,KAAK,CAAA;AAC7C,MAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,OAAA;AAAA,QACxB,OAAA;AAAA,QACA,MAAM,OAAA,IAAW,gBAAA;AAAA,QACjB,MAAM,QAAA,IAAY;AAAA,OACpB;AACA,MAAA,OAAO,EAAE,MAAA,EAAQ,KAAA,EAAO,OAAA,CAAQ,KAAA,EAAM;AAAA,IACxC,CAAC,CAAA;AACD,IAAA,OAAA,CAAQ,UAAA,GAAa,GAAA,CAAI,KAAA,CAAM,MAAM,MAAS,CAAA;AAC9C,IAAA,OAAO,GAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,KAAK,SAAA,EAAyB;AAC5B,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,SAAS,CAAA;AAC3C,IAAA,IAAI,YAAY,MAAA,EAAW;AAC3B,IAAA,YAAA,CAAa,QAAQ,QAAQ,CAAA;AAC7B,IAAA,OAAA,CAAQ,KAAA,GAAQ,KAAA;AAChB,IAAA,IAAA,CAAK,QAAA,CAAS,OAAO,SAAS,CAAA;AAC9B,IAAA,IAAI;AACF,MAAA,OAAA,CAAQ,IAAA,CAAK,CAAC,OAAA,CAAQ,GAAA,CAAI,KAAK,SAAS,CAAA;AAAA,IAC1C,CAAA,CAAA,MAAQ;AACN,MAAA,IAAI;AACF,QAAA,OAAA,CAAQ,IAAI,IAAA,EAAK;AAAA,MACnB,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,OAAA,GAAgB;AACd,IAAA,KAAA,MAAW,EAAA,IAAM,CAAC,GAAG,IAAA,CAAK,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,CAAK,IAAA,CAAK,EAAE,CAAA;AAAA,EAC1D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,kBAAA,GAA6B;AAC3B,IAAA,OAAO,KAAK,QAAA,CAAS,IAAA;AAAA,EACvB;AACF","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), `startInteractive`\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec; `available()` reports `false` up front, and\n * `writeStdin` / `kill` are unreachable because no session was ever created.\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\n/**\n * Floor for `StartInteractiveOptions.yieldMs` — how long a call waits for the process to produce\n * output before returning what it has.\n *\n * A shorter wait does not make the call faster, it makes it return an empty or truncated buffer that\n * the model then reasons about as if the program had said nothing. Anything below this is clamped\n * up, silently and on purpose: the alternative is an agent concluding a command produced no output\n * when it simply had not flushed yet.\n */\nexport const YIELD_MIN_MS = 250;\n\n/**\n * Ceiling for `StartInteractiveOptions.yieldMs`, clamped down.\n *\n * A pty session is interactive; a caller asking to block for longer than this wants a batch command,\n * which `shell` already does without holding a session open. The cap is what keeps a mistyped\n * `yieldMs` from parking an agent turn indefinitely.\n */\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 * Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new\n * PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.\n *\n * @public\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\n * 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 *\n * It EXTENDS {@link InteractiveUnavailableError} and inherits its\n * `code === \"interactive_unavailable\"`, so a caller catching the parent — or branching on `code` —\n * cannot tell \"no pty on this machine\" from \"you already have N open\". Use `instanceof\n * MaxSessionsError` when the two need different handling; only this one is retryable after a\n * `kill()`.\n *\n * @public\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\n/**\n * `InteractiveBackend` backed by a real pty, for programs that only behave correctly on a terminal.\n *\n * Use it when the target REQUIRES a tty — a REPL that prints a prompt, an installer that asks a\n * question, `ssh` asking for a passphrase, anything that checks `isatty` and changes behaviour. For\n * a command that reads stdin and exits, the plain `shell` tool is simpler and does not leave a\n * session to reap.\n *\n * `node-pty` is an OPTIONAL dependency (`optionalDependencies`, not a peer), imported lazily on\n * first use: an install that cannot build the native module still succeeds, and a caller that never\n * starts a session never needs one. When it is absent,\n * starting a session raises `InteractiveUnavailableError` rather than failing at import time — so an\n * agent that merely has the backend registered still runs.\n *\n * Sessions are stateful and idle-reaped (5 minutes by default). A session id that has been reaped\n * raises `NoSuchSessionError`, which is a different fact from \"the command failed\" and should be\n * handled as \"start again\", not as an error from the program.\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 /**\n * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.\n *\n * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch\n * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and\n * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a\n * module installed afterwards.\n *\n * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —\n * both surface as {@link InteractiveUnavailableError} from `startInteractive`.\n */\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 /**\n * Spawn `command` on a real pty and return its id plus whatever it printed during the yield\n * window.\n *\n * ```ts\n * const { sessionId, output } = await backend.startInteractive(\"python3 -i\");\n * await backend.writeStdin(sessionId, \"print(1+1)\\n\");\n * backend.kill(sessionId);\n * ```\n *\n * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —\n * so shell syntax works, and so does the user's rc-file behaviour. The child inherits\n * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is\n * visible to it.\n *\n * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},\n * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,\n * `rows` 24, `cwd` `process.cwd()`.\n *\n * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is\n * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow\n * starter returns an empty string that reads exactly like \"the program said nothing\". Poll with\n * `writeStdin(id, \"\")` rather than raising `yieldMs`.\n *\n * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist\n * (checked here because node-pty spawns a broken session instead of failing), or when the spawn\n * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent\n * swallows the distinction — when `maxSessions` is set and reached.\n *\n * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.\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 /**\n * Write `chars` to a live session's stdin and return the output produced during the yield window,\n * plus whether the process is still alive.\n *\n * YOU SUPPLY THE NEWLINE. `\"print(1+1)\"` leaves a REPL waiting; `\"print(1+1)\\n\"` runs it. An\n * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still\n * producing output.\n *\n * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match\n * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.\n *\n * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —\n * two writes in flight cannot steal each other's output. They are not, however, interleaved with\n * anything the program prints unprompted, which is drained by whichever call is waiting.\n *\n * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for\n * idleness. That is a different fact from a failing command: the correct response is to start a\n * new session, not to report an error from the program.\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 /**\n * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a\n * silent no-op, never a throw.\n *\n * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful\n * shutdown and no chance for the program to flush or save. Falls back to killing just the pty\n * process if the group kill fails. Any output buffered but not yet collected is discarded.\n */\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 /**\n * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`\n * contract.\n *\n * Also runs automatically on the host process's `exit` event, armed lazily on the first\n * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's\n * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.\n */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /**\n * How many sessions this instance currently holds — the number `maxSessions` is compared against.\n * Extra to the `InteractiveBackend` contract.\n *\n * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a\n * counter, and it never counts sessions belonging to another backend instance.\n */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -1,14 +1,30 @@
1
1
  import { InteractiveUnavailableError, InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
2
2
 
3
+ /**
4
+ * Floor for `StartInteractiveOptions.yieldMs` — how long a call waits for the process to produce
5
+ * output before returning what it has.
6
+ *
7
+ * A shorter wait does not make the call faster, it makes it return an empty or truncated buffer that
8
+ * the model then reasons about as if the program had said nothing. Anything below this is clamped
9
+ * up, silently and on purpose: the alternative is an agent concluding a command produced no output
10
+ * when it simply had not flushed yet.
11
+ */
3
12
  declare const YIELD_MIN_MS = 250;
13
+ /**
14
+ * Ceiling for `StartInteractiveOptions.yieldMs`, clamped down.
15
+ *
16
+ * A pty session is interactive; a caller asking to block for longer than this wants a batch command,
17
+ * which `shell` already does without holding a session open. The cap is what keeps a mistyped
18
+ * `yieldMs` from parking an agent turn indefinitely.
19
+ */
4
20
  declare const YIELD_MAX_MS = 30000;
5
21
  /** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */
6
22
  declare function clampYield(ms: number | undefined): number;
7
23
  /**
8
- * M75 T3.1 how the caller wraps the command before the spawn.
24
+ * Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new
25
+ * PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.
9
26
  *
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.
27
+ * @public
12
28
  */
13
29
  interface PtyInteractiveBackendOptions {
14
30
  /**
@@ -32,15 +48,41 @@ interface PtyInteractiveBackendOptions {
32
48
  /**
33
49
  * M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.
34
50
  *
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
51
+ * Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough
52
+ * context to act, and here the action is reusing an existing session. An error merely stating
37
53
  * "limit reached" would leave the model with no way out — it would retry, and fail again.
54
+ *
55
+ * It EXTENDS {@link InteractiveUnavailableError} and inherits its
56
+ * `code === "interactive_unavailable"`, so a caller catching the parent — or branching on `code` —
57
+ * cannot tell "no pty on this machine" from "you already have N open". Use `instanceof
58
+ * MaxSessionsError` when the two need different handling; only this one is retryable after a
59
+ * `kill()`.
60
+ *
61
+ * @public
38
62
  */
39
63
  declare class MaxSessionsError extends InteractiveUnavailableError {
40
64
  readonly max: number;
41
65
  readonly liveSessionIds: readonly string[];
42
66
  constructor(max: number, liveSessionIds: readonly string[]);
43
67
  }
68
+ /**
69
+ * `InteractiveBackend` backed by a real pty, for programs that only behave correctly on a terminal.
70
+ *
71
+ * Use it when the target REQUIRES a tty — a REPL that prints a prompt, an installer that asks a
72
+ * question, `ssh` asking for a passphrase, anything that checks `isatty` and changes behaviour. For
73
+ * a command that reads stdin and exits, the plain `shell` tool is simpler and does not leave a
74
+ * session to reap.
75
+ *
76
+ * `node-pty` is an OPTIONAL dependency (`optionalDependencies`, not a peer), imported lazily on
77
+ * first use: an install that cannot build the native module still succeeds, and a caller that never
78
+ * starts a session never needs one. When it is absent,
79
+ * starting a session raises `InteractiveUnavailableError` rather than failing at import time — so an
80
+ * agent that merely has the backend registered still runs.
81
+ *
82
+ * Sessions are stateful and idle-reaped (5 minutes by default). A session id that has been reaped
83
+ * raises `NoSuchSessionError`, which is a different fact from "the command failed" and should be
84
+ * handled as "start again", not as an error from the program.
85
+ */
44
86
  declare class PtyInteractiveBackend extends InteractiveBackend {
45
87
  private readonly sessions;
46
88
  private readonly wrapCommand;
@@ -51,7 +93,17 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
51
93
  private exitReaperArmed;
52
94
  /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */
53
95
  private loadPty;
54
- /** Whether the interactive (PTY) path is usable in this environment. */
96
+ /**
97
+ * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.
98
+ *
99
+ * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch
100
+ * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and
101
+ * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a
102
+ * module installed afterwards.
103
+ *
104
+ * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —
105
+ * both surface as {@link InteractiveUnavailableError} from `startInteractive`.
106
+ */
55
107
  available(): boolean;
56
108
  /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM
57
109
  * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */
@@ -61,13 +113,83 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
61
113
  /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the
62
114
  * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */
63
115
  private spawnPty;
116
+ /**
117
+ * Spawn `command` on a real pty and return its id plus whatever it printed during the yield
118
+ * window.
119
+ *
120
+ * ```ts
121
+ * const { sessionId, output } = await backend.startInteractive("python3 -i");
122
+ * await backend.writeStdin(sessionId, "print(1+1)\n");
123
+ * backend.kill(sessionId);
124
+ * ```
125
+ *
126
+ * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —
127
+ * so shell syntax works, and so does the user's rc-file behaviour. The child inherits
128
+ * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is
129
+ * visible to it.
130
+ *
131
+ * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},
132
+ * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,
133
+ * `rows` 24, `cwd` `process.cwd()`.
134
+ *
135
+ * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is
136
+ * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow
137
+ * starter returns an empty string that reads exactly like "the program said nothing". Poll with
138
+ * `writeStdin(id, "")` rather than raising `yieldMs`.
139
+ *
140
+ * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist
141
+ * (checked here because node-pty spawns a broken session instead of failing), or when the spawn
142
+ * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent
143
+ * swallows the distinction — when `maxSessions` is set and reached.
144
+ *
145
+ * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.
146
+ */
64
147
  startInteractive(command: string, opts?: StartInteractiveOptions): Promise<StartInteractiveResult>;
148
+ /**
149
+ * Write `chars` to a live session's stdin and return the output produced during the yield window,
150
+ * plus whether the process is still alive.
151
+ *
152
+ * YOU SUPPLY THE NEWLINE. `"print(1+1)"` leaves a REPL waiting; `"print(1+1)\n"` runs it. An
153
+ * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still
154
+ * producing output.
155
+ *
156
+ * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match
157
+ * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.
158
+ *
159
+ * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —
160
+ * two writes in flight cannot steal each other's output. They are not, however, interleaved with
161
+ * anything the program prints unprompted, which is drained by whichever call is waiting.
162
+ *
163
+ * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for
164
+ * idleness. That is a different fact from a failing command: the correct response is to start a
165
+ * new session, not to report an error from the program.
166
+ */
65
167
  writeStdin(sessionId: string, chars: string, opts?: WriteStdinOptions): Promise<WriteStdinResult>;
66
- /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */
168
+ /**
169
+ * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a
170
+ * silent no-op, never a throw.
171
+ *
172
+ * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful
173
+ * shutdown and no chance for the program to flush or save. Falls back to killing just the pty
174
+ * process if the group kill fails. Any output buffered but not yet collected is discarded.
175
+ */
67
176
  kill(sessionId: string): void;
68
- /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */
177
+ /**
178
+ * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`
179
+ * contract.
180
+ *
181
+ * Also runs automatically on the host process's `exit` event, armed lazily on the first
182
+ * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's
183
+ * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.
184
+ */
69
185
  killAll(): void;
70
- /** Live session count — for observability / tests. */
186
+ /**
187
+ * How many sessions this instance currently holds — the number `maxSessions` is compared against.
188
+ * Extra to the `InteractiveBackend` contract.
189
+ *
190
+ * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a
191
+ * counter, and it never counts sessions belonging to another backend instance.
192
+ */
71
193
  activeSessionCount(): number;
72
194
  }
73
195
 
package/dist/index.d.ts CHANGED
@@ -1,14 +1,30 @@
1
1
  import { InteractiveUnavailableError, InteractiveBackend, StartInteractiveOptions, StartInteractiveResult, WriteStdinOptions, WriteStdinResult } from '@theokit/sdk/interactive';
2
2
 
3
+ /**
4
+ * Floor for `StartInteractiveOptions.yieldMs` — how long a call waits for the process to produce
5
+ * output before returning what it has.
6
+ *
7
+ * A shorter wait does not make the call faster, it makes it return an empty or truncated buffer that
8
+ * the model then reasons about as if the program had said nothing. Anything below this is clamped
9
+ * up, silently and on purpose: the alternative is an agent concluding a command produced no output
10
+ * when it simply had not flushed yet.
11
+ */
3
12
  declare const YIELD_MIN_MS = 250;
13
+ /**
14
+ * Ceiling for `StartInteractiveOptions.yieldMs`, clamped down.
15
+ *
16
+ * A pty session is interactive; a caller asking to block for longer than this wants a batch command,
17
+ * which `shell` already does without holding a session open. The cap is what keeps a mistyped
18
+ * `yieldMs` from parking an agent turn indefinitely.
19
+ */
4
20
  declare const YIELD_MAX_MS = 30000;
5
21
  /** Bound the yield window to [{@link YIELD_MIN_MS}, {@link YIELD_MAX_MS}]. */
6
22
  declare function clampYield(ms: number | undefined): number;
7
23
  /**
8
- * M75 T3.1 how the caller wraps the command before the spawn.
24
+ * Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new
25
+ * PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.
9
26
  *
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.
27
+ * @public
12
28
  */
13
29
  interface PtyInteractiveBackendOptions {
14
30
  /**
@@ -32,15 +48,41 @@ interface PtyInteractiveBackendOptions {
32
48
  /**
33
49
  * M77 — the {@link PtyInteractiveBackendOptions.maxSessions} ceiling was reached.
34
50
  *
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
51
+ * Carries `liveSessionIds` by design: `rules/error-handling.md § 2` asks for a message with enough
52
+ * context to act, and here the action is reusing an existing session. An error merely stating
37
53
  * "limit reached" would leave the model with no way out — it would retry, and fail again.
54
+ *
55
+ * It EXTENDS {@link InteractiveUnavailableError} and inherits its
56
+ * `code === "interactive_unavailable"`, so a caller catching the parent — or branching on `code` —
57
+ * cannot tell "no pty on this machine" from "you already have N open". Use `instanceof
58
+ * MaxSessionsError` when the two need different handling; only this one is retryable after a
59
+ * `kill()`.
60
+ *
61
+ * @public
38
62
  */
39
63
  declare class MaxSessionsError extends InteractiveUnavailableError {
40
64
  readonly max: number;
41
65
  readonly liveSessionIds: readonly string[];
42
66
  constructor(max: number, liveSessionIds: readonly string[]);
43
67
  }
68
+ /**
69
+ * `InteractiveBackend` backed by a real pty, for programs that only behave correctly on a terminal.
70
+ *
71
+ * Use it when the target REQUIRES a tty — a REPL that prints a prompt, an installer that asks a
72
+ * question, `ssh` asking for a passphrase, anything that checks `isatty` and changes behaviour. For
73
+ * a command that reads stdin and exits, the plain `shell` tool is simpler and does not leave a
74
+ * session to reap.
75
+ *
76
+ * `node-pty` is an OPTIONAL dependency (`optionalDependencies`, not a peer), imported lazily on
77
+ * first use: an install that cannot build the native module still succeeds, and a caller that never
78
+ * starts a session never needs one. When it is absent,
79
+ * starting a session raises `InteractiveUnavailableError` rather than failing at import time — so an
80
+ * agent that merely has the backend registered still runs.
81
+ *
82
+ * Sessions are stateful and idle-reaped (5 minutes by default). A session id that has been reaped
83
+ * raises `NoSuchSessionError`, which is a different fact from "the command failed" and should be
84
+ * handled as "start again", not as an error from the program.
85
+ */
44
86
  declare class PtyInteractiveBackend extends InteractiveBackend {
45
87
  private readonly sessions;
46
88
  private readonly wrapCommand;
@@ -51,7 +93,17 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
51
93
  private exitReaperArmed;
52
94
  /** Lazy, cached node-pty load — a failed native build must degrade, not crash at import time. */
53
95
  private loadPty;
54
- /** Whether the interactive (PTY) path is usable in this environment. */
96
+ /**
97
+ * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.
98
+ *
99
+ * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch
100
+ * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and
101
+ * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a
102
+ * module installed afterwards.
103
+ *
104
+ * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —
105
+ * both surface as {@link InteractiveUnavailableError} from `startInteractive`.
106
+ */
55
107
  available(): boolean;
56
108
  /** Reap orphaned PTYs when the host process exits. ONLY the `exit` event — installing SIGINT/SIGTERM
57
109
  * handlers would remove Node's default terminate-on-signal behavior. Armed once, lazily. */
@@ -61,13 +113,83 @@ declare class PtyInteractiveBackend extends InteractiveBackend {
61
113
  /** Allocate a PTY for `command` or throw a typed {@link InteractiveUnavailableError}. Validates the
62
114
  * cwd at the boundary (node-pty does NOT throw on a non-existent cwd — it spawns a broken session). */
63
115
  private spawnPty;
116
+ /**
117
+ * Spawn `command` on a real pty and return its id plus whatever it printed during the yield
118
+ * window.
119
+ *
120
+ * ```ts
121
+ * const { sessionId, output } = await backend.startInteractive("python3 -i");
122
+ * await backend.writeStdin(sessionId, "print(1+1)\n");
123
+ * backend.kill(sessionId);
124
+ * ```
125
+ *
126
+ * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —
127
+ * so shell syntax works, and so does the user's rc-file behaviour. The child inherits
128
+ * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is
129
+ * visible to it.
130
+ *
131
+ * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},
132
+ * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,
133
+ * `rows` 24, `cwd` `process.cwd()`.
134
+ *
135
+ * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is
136
+ * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow
137
+ * starter returns an empty string that reads exactly like "the program said nothing". Poll with
138
+ * `writeStdin(id, "")` rather than raising `yieldMs`.
139
+ *
140
+ * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist
141
+ * (checked here because node-pty spawns a broken session instead of failing), or when the spawn
142
+ * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent
143
+ * swallows the distinction — when `maxSessions` is set and reached.
144
+ *
145
+ * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.
146
+ */
64
147
  startInteractive(command: string, opts?: StartInteractiveOptions): Promise<StartInteractiveResult>;
148
+ /**
149
+ * Write `chars` to a live session's stdin and return the output produced during the yield window,
150
+ * plus whether the process is still alive.
151
+ *
152
+ * YOU SUPPLY THE NEWLINE. `"print(1+1)"` leaves a REPL waiting; `"print(1+1)\n"` runs it. An
153
+ * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still
154
+ * producing output.
155
+ *
156
+ * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match
157
+ * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.
158
+ *
159
+ * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —
160
+ * two writes in flight cannot steal each other's output. They are not, however, interleaved with
161
+ * anything the program prints unprompted, which is drained by whichever call is waiting.
162
+ *
163
+ * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for
164
+ * idleness. That is a different fact from a failing command: the correct response is to start a
165
+ * new session, not to report an error from the program.
166
+ */
65
167
  writeStdin(sessionId: string, chars: string, opts?: WriteStdinOptions): Promise<WriteStdinResult>;
66
- /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */
168
+ /**
169
+ * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a
170
+ * silent no-op, never a throw.
171
+ *
172
+ * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful
173
+ * shutdown and no chance for the program to flush or save. Falls back to killing just the pty
174
+ * process if the group kill fails. Any output buffered but not yet collected is discarded.
175
+ */
67
176
  kill(sessionId: string): void;
68
- /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */
177
+ /**
178
+ * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`
179
+ * contract.
180
+ *
181
+ * Also runs automatically on the host process's `exit` event, armed lazily on the first
182
+ * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's
183
+ * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.
184
+ */
69
185
  killAll(): void;
70
- /** Live session count — for observability / tests. */
186
+ /**
187
+ * How many sessions this instance currently holds — the number `maxSessions` is compared against.
188
+ * Extra to the `InteractiveBackend` contract.
189
+ *
190
+ * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a
191
+ * counter, and it never counts sessions belonging to another backend instance.
192
+ */
71
193
  activeSessionCount(): number;
72
194
  }
73
195
 
package/dist/index.js CHANGED
@@ -51,7 +51,17 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
51
51
  }
52
52
  return this.ptyModule;
53
53
  }
54
- /** Whether the interactive (PTY) path is usable in this environment. */
54
+ /**
55
+ * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.
56
+ *
57
+ * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch
58
+ * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and
59
+ * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a
60
+ * module installed afterwards.
61
+ *
62
+ * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —
63
+ * both surface as {@link InteractiveUnavailableError} from `startInteractive`.
64
+ */
55
65
  available() {
56
66
  return this.loadPty() !== null;
57
67
  }
@@ -103,6 +113,37 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
103
113
  );
104
114
  }
105
115
  }
116
+ /**
117
+ * Spawn `command` on a real pty and return its id plus whatever it printed during the yield
118
+ * window.
119
+ *
120
+ * ```ts
121
+ * const { sessionId, output } = await backend.startInteractive("python3 -i");
122
+ * await backend.writeStdin(sessionId, "print(1+1)\n");
123
+ * backend.kill(sessionId);
124
+ * ```
125
+ *
126
+ * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —
127
+ * so shell syntax works, and so does the user's rc-file behaviour. The child inherits
128
+ * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is
129
+ * visible to it.
130
+ *
131
+ * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},
132
+ * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,
133
+ * `rows` 24, `cwd` `process.cwd()`.
134
+ *
135
+ * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is
136
+ * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow
137
+ * starter returns an empty string that reads exactly like "the program said nothing". Poll with
138
+ * `writeStdin(id, "")` rather than raising `yieldMs`.
139
+ *
140
+ * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist
141
+ * (checked here because node-pty spawns a broken session instead of failing), or when the spawn
142
+ * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent
143
+ * swallows the distinction — when `maxSessions` is set and reached.
144
+ *
145
+ * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.
146
+ */
106
147
  async startInteractive(command, opts) {
107
148
  this.armExitReaper();
108
149
  if (this.maxSessions !== void 0 && this.sessions.size >= this.maxSessions) {
@@ -132,6 +173,25 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
132
173
  const output = await this.collect(session, opts?.yieldMs ?? DEFAULT_YIELD_MS, maxBytes);
133
174
  return { sessionId: id, output };
134
175
  }
176
+ /**
177
+ * Write `chars` to a live session's stdin and return the output produced during the yield window,
178
+ * plus whether the process is still alive.
179
+ *
180
+ * YOU SUPPLY THE NEWLINE. `"print(1+1)"` leaves a REPL waiting; `"print(1+1)\n"` runs it. An
181
+ * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still
182
+ * producing output.
183
+ *
184
+ * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match
185
+ * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.
186
+ *
187
+ * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —
188
+ * two writes in flight cannot steal each other's output. They are not, however, interleaved with
189
+ * anything the program prints unprompted, which is drained by whichever call is waiting.
190
+ *
191
+ * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for
192
+ * idleness. That is a different fact from a failing command: the correct response is to start a
193
+ * new session, not to report an error from the program.
194
+ */
135
195
  async writeStdin(sessionId, chars, opts) {
136
196
  const session = this.sessions.get(sessionId);
137
197
  if (session === void 0 || !session.alive) throw new NoSuchSessionError(sessionId);
@@ -149,7 +209,14 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
149
209
  session.writeChain = run.catch(() => void 0);
150
210
  return run;
151
211
  }
152
- /** Kill a single session (idempotent). Kills the whole process GROUP so a detached grandchild dies too. */
212
+ /**
213
+ * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a
214
+ * silent no-op, never a throw.
215
+ *
216
+ * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful
217
+ * shutdown and no chance for the program to flush or save. Falls back to killing just the pty
218
+ * process if the group kill fails. Any output buffered but not yet collected is discarded.
219
+ */
153
220
  kill(sessionId) {
154
221
  const session = this.sessions.get(sessionId);
155
222
  if (session === void 0) return;
@@ -165,11 +232,24 @@ var PtyInteractiveBackend = class extends InteractiveBackend {
165
232
  }
166
233
  }
167
234
  }
168
- /** Reap every session — used by the process-exit reaper; also callable on `/clear`. */
235
+ /**
236
+ * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`
237
+ * contract.
238
+ *
239
+ * Also runs automatically on the host process's `exit` event, armed lazily on the first
240
+ * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's
241
+ * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.
242
+ */
169
243
  killAll() {
170
244
  for (const id of [...this.sessions.keys()]) this.kill(id);
171
245
  }
172
- /** Live session count — for observability / tests. */
246
+ /**
247
+ * How many sessions this instance currently holds — the number `maxSessions` is compared against.
248
+ * Extra to the `InteractiveBackend` contract.
249
+ *
250
+ * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a
251
+ * counter, and it never counts sessions belonging to another backend instance.
252
+ */
173
253
  activeSessionCount() {
174
254
  return this.sessions.size;
175
255
  }
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 — 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"]}
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), `startInteractive`\n * throws the SDK's typed {@link InteractiveUnavailableError} so the caller falls\n * back to non-interactive exec; `available()` reports `false` up front, and\n * `writeStdin` / `kill` are unreachable because no session was ever created.\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\n/**\n * Floor for `StartInteractiveOptions.yieldMs` — how long a call waits for the process to produce\n * output before returning what it has.\n *\n * A shorter wait does not make the call faster, it makes it return an empty or truncated buffer that\n * the model then reasons about as if the program had said nothing. Anything below this is clamped\n * up, silently and on purpose: the alternative is an agent concluding a command produced no output\n * when it simply had not flushed yet.\n */\nexport const YIELD_MIN_MS = 250;\n\n/**\n * Ceiling for `StartInteractiveOptions.yieldMs`, clamped down.\n *\n * A pty session is interactive; a caller asking to block for longer than this wants a batch command,\n * which `shell` already does without holding a session open. The cap is what keeps a mistyped\n * `yieldMs` from parking an agent turn indefinitely.\n */\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 * Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new\n * PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.\n *\n * @public\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\n * 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 *\n * It EXTENDS {@link InteractiveUnavailableError} and inherits its\n * `code === \"interactive_unavailable\"`, so a caller catching the parent — or branching on `code` —\n * cannot tell \"no pty on this machine\" from \"you already have N open\". Use `instanceof\n * MaxSessionsError` when the two need different handling; only this one is retryable after a\n * `kill()`.\n *\n * @public\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\n/**\n * `InteractiveBackend` backed by a real pty, for programs that only behave correctly on a terminal.\n *\n * Use it when the target REQUIRES a tty — a REPL that prints a prompt, an installer that asks a\n * question, `ssh` asking for a passphrase, anything that checks `isatty` and changes behaviour. For\n * a command that reads stdin and exits, the plain `shell` tool is simpler and does not leave a\n * session to reap.\n *\n * `node-pty` is an OPTIONAL dependency (`optionalDependencies`, not a peer), imported lazily on\n * first use: an install that cannot build the native module still succeeds, and a caller that never\n * starts a session never needs one. When it is absent,\n * starting a session raises `InteractiveUnavailableError` rather than failing at import time — so an\n * agent that merely has the backend registered still runs.\n *\n * Sessions are stateful and idle-reaped (5 minutes by default). A session id that has been reaped\n * raises `NoSuchSessionError`, which is a different fact from \"the command failed\" and should be\n * handled as \"start again\", not as an error from the program.\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 /**\n * Whether `node-pty` loaded — i.e. whether `startInteractive` can work at all here.\n *\n * Extra to the `InteractiveBackend` contract; a caller writing against the base class must catch\n * {@link InteractiveUnavailableError} instead. `require`s the native module on the first call and\n * CACHES the answer for the life of this instance, so it is cheap to poll and will not notice a\n * module installed afterwards.\n *\n * `true` does not promise a session: the spawn can still fail, and a missing `cwd` is rejected —\n * both surface as {@link InteractiveUnavailableError} from `startInteractive`.\n */\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 /**\n * Spawn `command` on a real pty and return its id plus whatever it printed during the yield\n * window.\n *\n * ```ts\n * const { sessionId, output } = await backend.startInteractive(\"python3 -i\");\n * await backend.writeStdin(sessionId, \"print(1+1)\\n\");\n * backend.kill(sessionId);\n * ```\n *\n * The command runs as `$SHELL -c <command>`, falling back to `/bin/bash` when `SHELL` is unset —\n * so shell syntax works, and so does the user's rc-file behaviour. The child inherits\n * `process.env` WHOLE; there is no allowlist, so every secret in the parent's environment is\n * visible to it.\n *\n * Defaults applied here: `yieldMs` 500 (clamped into [{@link YIELD_MIN_MS},\n * {@link YIELD_MAX_MS}]), `ttlMs` 300000 (5 min idle, then reaped), `maxBytes` 100000, `cols` 80,\n * `rows` 24, `cwd` `process.cwd()`.\n *\n * IT ALWAYS RESOLVES AFTER THE YIELD WINDOW, never when the program is ready. `output` is\n * whatever arrived by then, TAIL-capped to `maxBytes` with a `…(truncated)` marker — so a slow\n * starter returns an empty string that reads exactly like \"the program said nothing\". Poll with\n * `writeStdin(id, \"\")` rather than raising `yieldMs`.\n *\n * Throws {@link InteractiveUnavailableError} when `node-pty` is absent, when `cwd` does not exist\n * (checked here because node-pty spawns a broken session instead of failing), or when the spawn\n * itself fails. Throws {@link MaxSessionsError} — a SUBCLASS of it, so a `catch` on the parent\n * swallows the distinction — when `maxSessions` is set and reached.\n *\n * The session outlives this call and must be reclaimed: `kill()` it, or let the idle TTL do it.\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 /**\n * Write `chars` to a live session's stdin and return the output produced during the yield window,\n * plus whether the process is still alive.\n *\n * YOU SUPPLY THE NEWLINE. `\"print(1+1)\"` leaves a REPL waiting; `\"print(1+1)\\n\"` runs it. An\n * EMPTY `chars` writes nothing and just waits — the supported way to poll a program that is still\n * producing output.\n *\n * Every call resets the idle TTL, so a polled session never gets reaped mid-work. Defaults match\n * `startInteractive`: `yieldMs` 500 (clamped), `ttlMs` 300000, `maxBytes` 100000 tail-capped.\n *\n * Concurrent calls on the same session are SERIALIZED, each reading only its own output window —\n * two writes in flight cannot steal each other's output. They are not, however, interleaved with\n * anything the program prints unprompted, which is drained by whichever call is waiting.\n *\n * Throws {@link NoSuchSessionError} when the id is unknown, already killed, or was reaped for\n * idleness. That is a different fact from a failing command: the correct response is to start a\n * new session, not to report an error from the program.\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 /**\n * End a session and free its `maxSessions` slot. Idempotent — an unknown or already-killed id is a\n * silent no-op, never a throw.\n *\n * SIGKILLs the whole process GROUP, so a detached grandchild dies with it; there is no graceful\n * shutdown and no chance for the program to flush or save. Falls back to killing just the pty\n * process if the group kill fails. Any output buffered but not yet collected is discarded.\n */\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 /**\n * `kill()` every live session of THIS backend instance. Extra to the `InteractiveBackend`\n * contract.\n *\n * Also runs automatically on the host process's `exit` event, armed lazily on the first\n * `startInteractive`. Only `exit` — installing SIGINT/SIGTERM handlers would suppress Node's\n * default terminate-on-signal, so a `kill -9` of the host still orphans the ptys.\n */\n killAll(): void {\n for (const id of [...this.sessions.keys()]) this.kill(id);\n }\n\n /**\n * How many sessions this instance currently holds — the number `maxSessions` is compared against.\n * Extra to the `InteractiveBackend` contract.\n *\n * Drops on its own when a process exits or a TTL fires, so it is a live gauge rather than a\n * counter, and it never counts sessions belonging to another backend instance.\n */\n activeSessionCount(): number {\n return this.sessions.size;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "@theokit/sdk-pty",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
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
+ "license": "Apache-2.0",
6
+ "homepage": "https://github.com/usetheokit/theokit-sdk#readme",
7
+ "bugs": "https://github.com/usetheokit/theokit-sdk/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/usetheokit/theokit-sdk.git",
11
+ "directory": "packages/sdk-pty"
12
+ },
5
13
  "type": "module",
14
+ "sideEffects": false,
15
+ "engines": {
16
+ "node": ">=22.12.0"
17
+ },
6
18
  "main": "./dist/index.cjs",
7
19
  "module": "./dist/index.js",
8
20
  "types": "./dist/index.d.ts",
@@ -19,29 +31,31 @@
19
31
  },
20
32
  "./package.json": "./package.json"
21
33
  },
22
- "scripts": {
23
- "build": "tsup",
24
- "test": "vitest run",
25
- "typecheck": "tsc --noEmit"
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE"
39
+ ],
40
+ "publishConfig": {
41
+ "provenance": true,
42
+ "access": "public"
26
43
  },
27
44
  "peerDependencies": {
28
45
  "@theokit/sdk": ">=4.4.1"
29
46
  },
30
- "optionalDependencies": {
31
- "node-pty": "^1.1.0"
32
- },
33
47
  "devDependencies": {
34
- "@theokit/sdk": "^4.40.0",
35
48
  "tsup": "^8.3.5",
36
49
  "typescript": "^5.7.2",
37
- "vitest": "^4.1.8"
50
+ "vitest": "^4.1.8",
51
+ "@theokit/sdk": "4.54.0"
38
52
  },
39
- "files": [
40
- "dist",
41
- "README.md",
42
- "LICENSE"
43
- ],
44
- "publishConfig": {
45
- "access": "public"
53
+ "optionalDependencies": {
54
+ "node-pty": "^1.1.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsup && node ../../tools/repair-dts-imports.mjs .",
58
+ "test": "vitest run",
59
+ "typecheck": "tsc --noEmit"
46
60
  }
47
- }
61
+ }