@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 +138 -0
- package/LICENSE +2 -2
- package/README.md +24 -0
- package/dist/index.cjs +84 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -9
- package/dist/index.d.ts +131 -9
- package/dist/index.js +84 -4
- package/dist/index.js.map +1 -1
- package/package.json +32 -18
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/index.cjs.map
CHANGED
|
@@ -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":"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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), `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
|
-
*
|
|
24
|
+
* Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new
|
|
25
|
+
* PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.
|
|
9
26
|
*
|
|
10
|
-
*
|
|
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
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
24
|
+
* Constructor options for {@link PtyInteractiveBackend}. Both are optional; `new
|
|
25
|
+
* PtyInteractiveBackend()` gives unconfined commands and an unlimited number of sessions.
|
|
9
26
|
*
|
|
10
|
-
*
|
|
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
|
|
36
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
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
|
+
}
|