@hydraharness/harness-terminal-bash 0.0.0-stage → 0.1.1-rc.7
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/LICENSE +21 -0
- package/README.md +38 -2
- package/lib/index.js +901 -0
- package/lib/invariant.js +23 -0
- package/lib/types/config.d.ts +71 -0
- package/lib/types/index.d.ts +36 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/sanitize.d.ts +47 -0
- package/lib/types/session.d.ts +70 -0
- package/package.json +61 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hydraharness/harness-terminal-bash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, retains bounded line-oriented output, and detects readiness while the subprocess provider owns PTY allocation, environment scrubbing, foreground process groups, signalling, and complete terminal-session cleanup. The same PTY backend therefore composes with local or remote execution-world providers.
|
|
4
|
+
|
|
5
|
+
## Plugin (`terminal-bash`)
|
|
6
|
+
|
|
7
|
+
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
|
8
|
+
|
|
9
|
+
`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `@hydraharness/harness-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `@hydraharness/harness-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic.
|
|
10
|
+
|
|
11
|
+
The pwsh bootstrap is submitted once, including when the first read returns no output. On POSIX, PSReadLine's initial cursor-position query is answered before that first write, including queries split across output chunks. Cancellation, timeout, or closure while waiting prevents the bootstrap write. Startup requires the controlled prompt at the end of the viewport or retained scrollback; the prompt string inside an echoed setup command is insufficient.
|
|
12
|
+
|
|
13
|
+
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
|
14
|
+
|
|
15
|
+
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
|
16
|
+
|
|
17
|
+
## Model Experience
|
|
18
|
+
|
|
19
|
+
### Current file policy and indirect consumer
|
|
20
|
+
|
|
21
|
+
#### What the model sees
|
|
22
|
+
|
|
23
|
+
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@hydraharness/harness-tool-terminal` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
|
|
24
|
+
|
|
25
|
+
#### Token effect
|
|
26
|
+
|
|
27
|
+
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
|
|
28
|
+
|
|
29
|
+
#### KV Cache effect
|
|
30
|
+
|
|
31
|
+
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
|
|
32
|
+
|
|
33
|
+
## Known Limitations and Deferred Work
|
|
34
|
+
|
|
35
|
+
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
|
|
36
|
+
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound.
|
|
37
|
+
- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page.
|
|
38
|
+
- Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer.
|
|
39
|
+
- Sessions do not survive harness process exit.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { TerminalBackendCleanupError, TerminalError } from "@hydraharness/harness-terminal";
|
|
2
|
+
import { effectiveSandboxMode } from "@hydraharness/harness-sandbox-policy";
|
|
3
|
+
import { ENCODING_PREAMBLE, resolvePwshPath } from "@hydraharness/harness-pwsh-local";
|
|
4
|
+
import z from "@hydraharness/schemastery";
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
6
|
+
//#region lib/types/config.js
|
|
7
|
+
/** Validated configuration for the local PTY backend. */
|
|
8
|
+
/** Bash dialect default executable. */
|
|
9
|
+
const DEFAULT_BASH_SHELL = "/bin/bash";
|
|
10
|
+
/** Bash dialect default arguments (interactive, profile-free). */
|
|
11
|
+
const DEFAULT_BASH_ARGS = [
|
|
12
|
+
"--noprofile",
|
|
13
|
+
"--norc",
|
|
14
|
+
"-i"
|
|
15
|
+
];
|
|
16
|
+
/** Pwsh dialect default arguments (interactive host, profile-free). */
|
|
17
|
+
const DEFAULT_PWSH_ARGS = ["-NoLogo", "-NoProfile"];
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the effective per-dialect shell specification. Defaulting is this
|
|
20
|
+
* explicit step: an unset or empty `shellPath`/`shellArgs` selects the
|
|
21
|
+
* dialect's defaults, while a non-empty explicit value always wins.
|
|
22
|
+
* (Schemastery materializes an absent optional array as `[]`, so emptiness —
|
|
23
|
+
* not just `undefined` — means "dialect default".)
|
|
24
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
25
|
+
* @returns the fully resolved configuration.
|
|
26
|
+
*/
|
|
27
|
+
function resolveConfig(config) {
|
|
28
|
+
const shellDialect = config.shellDialect ?? "bash";
|
|
29
|
+
return {
|
|
30
|
+
...config,
|
|
31
|
+
shellDialect,
|
|
32
|
+
shellPath: config.shellPath !== void 0 && config.shellPath.length > 0 ? config.shellPath : shellDialect === "pwsh" ? resolvePwshPath() : DEFAULT_BASH_SHELL,
|
|
33
|
+
shellArgs: config.shellArgs !== void 0 && config.shellArgs.length > 0 ? config.shellArgs : shellDialect === "pwsh" ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Schemastery config exposed by the plugin. */
|
|
37
|
+
const Config = z.object({
|
|
38
|
+
backendType: z.string().default("shell"),
|
|
39
|
+
shellDialect: z.union(["bash", "pwsh"]).default("bash"),
|
|
40
|
+
shellPath: z.string().required(false),
|
|
41
|
+
shellArgs: z.array(z.string()).required(false),
|
|
42
|
+
rows: z.number().default(40),
|
|
43
|
+
cols: z.number().default(160),
|
|
44
|
+
scrollbackLines: z.number().default(1e4),
|
|
45
|
+
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
|
|
46
|
+
maxReadBytes: z.number().default(256 * 1024),
|
|
47
|
+
pollIntervalMs: z.number().default(50),
|
|
48
|
+
exactProbeAfterMs: z.number().default(150),
|
|
49
|
+
idleSilenceMs: z.number().default(3e3),
|
|
50
|
+
handoffGraceMs: z.number().default(500),
|
|
51
|
+
timeoutMs: z.number().default(3e4),
|
|
52
|
+
disposeGraceMs: z.number().default(3e3)
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Assert every effective numeric config field is a positive safe integer and bounds compose.
|
|
56
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
57
|
+
* @returns Narrows the input to the fully resolved configuration.
|
|
58
|
+
*/
|
|
59
|
+
function validateConfig(config) {
|
|
60
|
+
const resolved = config;
|
|
61
|
+
if (resolved.backendType.length === 0) throw new Error("terminal-bash: backendType must be non-empty");
|
|
62
|
+
if (resolved.shellPath.length === 0) throw new Error("terminal-bash: shellPath must be non-empty");
|
|
63
|
+
for (const [name, value] of Object.entries(resolved)) if (typeof value === "number" && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(`terminal-bash: ${name} must be a positive safe integer`);
|
|
64
|
+
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error("terminal-bash: maxReadBytes must not exceed scrollbackMaxBytes");
|
|
65
|
+
if (resolved.handoffGraceMs < resolved.pollIntervalMs) throw new Error("terminal-bash: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window");
|
|
66
|
+
}
|
|
67
|
+
/** Exact printable prompt emitted after the private marker. */
|
|
68
|
+
const CONTROLLED_PROMPT = "hh> ";
|
|
69
|
+
/**
|
|
70
|
+
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
|
71
|
+
* Full terminal emulation is deliberately deferred; ordinary line output and
|
|
72
|
+
* the private prompt marker are the supported contract.
|
|
73
|
+
*/
|
|
74
|
+
var TerminalSanitizer = class {
|
|
75
|
+
maxPendingBytes;
|
|
76
|
+
pending = "";
|
|
77
|
+
discardMode;
|
|
78
|
+
discardOscEscape = false;
|
|
79
|
+
trailingCarriageReturn = false;
|
|
80
|
+
trackingPromptTail = false;
|
|
81
|
+
constructor(maxPendingBytes) {
|
|
82
|
+
this.maxPendingBytes = maxPendingBytes;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Consume one decoded `node-pty` data chunk.
|
|
86
|
+
* @param chunk - decoded terminal data.
|
|
87
|
+
* @returns Printable text and whether the private prompt marker completed.
|
|
88
|
+
*/
|
|
89
|
+
push(chunk) {
|
|
90
|
+
this.pending += this.discardPrefix(chunk);
|
|
91
|
+
let text = "";
|
|
92
|
+
let prompt = false;
|
|
93
|
+
let includePromptTail = this.trackingPromptTail;
|
|
94
|
+
let promptTail = "";
|
|
95
|
+
let index = 0;
|
|
96
|
+
const appendText = (value) => {
|
|
97
|
+
text += value;
|
|
98
|
+
if (this.trackingPromptTail) promptTail += value;
|
|
99
|
+
};
|
|
100
|
+
while (index < this.pending.length) {
|
|
101
|
+
const escape = this.pending.indexOf("\x1B", index);
|
|
102
|
+
if (escape < 0) {
|
|
103
|
+
appendText(this.pending.slice(index));
|
|
104
|
+
index = this.pending.length;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
appendText(this.pending.slice(index, escape));
|
|
108
|
+
if (escape + 1 >= this.pending.length) {
|
|
109
|
+
index = escape;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
const kind = this.pending[escape + 1];
|
|
113
|
+
if (kind === "]") {
|
|
114
|
+
const bel = this.pending.indexOf("\x07", escape + 2);
|
|
115
|
+
const stringTerminator = this.pending.indexOf("\x1B\\", escape + 2);
|
|
116
|
+
let end = -1;
|
|
117
|
+
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2);
|
|
118
|
+
else if (bel >= 0) end = bel + 1;
|
|
119
|
+
else if (stringTerminator >= 0) end = stringTerminator + 2;
|
|
120
|
+
if (end < 0) {
|
|
121
|
+
index = escape;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
const terminatorBytes = this.pending[end - 1] === "\x07" ? 1 : 2;
|
|
125
|
+
if (this.pending.slice(escape + 2, end - terminatorBytes).startsWith("133;D;")) {
|
|
126
|
+
prompt = true;
|
|
127
|
+
this.trackingPromptTail = true;
|
|
128
|
+
includePromptTail = true;
|
|
129
|
+
promptTail = "";
|
|
130
|
+
}
|
|
131
|
+
index = end;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (kind === "[") {
|
|
135
|
+
let end = escape + 2;
|
|
136
|
+
while (end < this.pending.length) {
|
|
137
|
+
const code = this.pending.charCodeAt(end);
|
|
138
|
+
if (code >= 64 && code <= 126) break;
|
|
139
|
+
end += 1;
|
|
140
|
+
}
|
|
141
|
+
if (end >= this.pending.length) {
|
|
142
|
+
index = escape;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
index = end + 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
index = escape + 2;
|
|
149
|
+
}
|
|
150
|
+
this.pending = this.pending.slice(index);
|
|
151
|
+
this.enforcePendingBound();
|
|
152
|
+
return {
|
|
153
|
+
text: this.normalizeText(text),
|
|
154
|
+
prompt,
|
|
155
|
+
...includePromptTail ? { promptTail } : {}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Flush a trailing printable fragment when the PTY exits.
|
|
160
|
+
* @returns Remaining printable text; incomplete escapes are discarded.
|
|
161
|
+
*/
|
|
162
|
+
flush() {
|
|
163
|
+
const text = this.pending.startsWith("\x1B") ? "" : this.pending;
|
|
164
|
+
this.pending = "";
|
|
165
|
+
this.discardMode = void 0;
|
|
166
|
+
this.discardOscEscape = false;
|
|
167
|
+
this.trackingPromptTail = false;
|
|
168
|
+
const normalized = this.normalizeText(text);
|
|
169
|
+
if (!this.trailingCarriageReturn) return normalized;
|
|
170
|
+
this.trailingCarriageReturn = false;
|
|
171
|
+
return `${normalized}\n`;
|
|
172
|
+
}
|
|
173
|
+
normalizeText(text) {
|
|
174
|
+
let complete = this.trailingCarriageReturn ? `\r${text}` : text;
|
|
175
|
+
this.trailingCarriageReturn = false;
|
|
176
|
+
if (complete.endsWith("\r")) {
|
|
177
|
+
complete = complete.slice(0, -1);
|
|
178
|
+
this.trailingCarriageReturn = true;
|
|
179
|
+
}
|
|
180
|
+
return normalizeTerminalText(complete);
|
|
181
|
+
}
|
|
182
|
+
enforcePendingBound() {
|
|
183
|
+
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return;
|
|
184
|
+
this.discardMode = this.pending[1] === "]" ? "osc" : "csi";
|
|
185
|
+
this.pending = "";
|
|
186
|
+
}
|
|
187
|
+
discardPrefix(chunk) {
|
|
188
|
+
if (this.discardMode === void 0) return chunk;
|
|
189
|
+
if (this.discardMode === "csi") {
|
|
190
|
+
for (let index = 0; index < chunk.length; index += 1) {
|
|
191
|
+
const code = chunk.charCodeAt(index);
|
|
192
|
+
if (code >= 64 && code <= 126) {
|
|
193
|
+
this.discardMode = void 0;
|
|
194
|
+
return chunk.slice(index + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
let index = 0;
|
|
200
|
+
if (this.discardOscEscape) {
|
|
201
|
+
this.discardOscEscape = false;
|
|
202
|
+
if (chunk.startsWith("\\")) {
|
|
203
|
+
this.discardMode = void 0;
|
|
204
|
+
return chunk.slice(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
while (index < chunk.length) {
|
|
208
|
+
if (chunk[index] === "\x07") {
|
|
209
|
+
this.discardMode = void 0;
|
|
210
|
+
return chunk.slice(index + 1);
|
|
211
|
+
}
|
|
212
|
+
if (chunk[index] === "\x1B") {
|
|
213
|
+
if (chunk[index + 1] === "\\") {
|
|
214
|
+
this.discardMode = void 0;
|
|
215
|
+
return chunk.slice(index + 2);
|
|
216
|
+
}
|
|
217
|
+
if (index + 1 === chunk.length) this.discardOscEscape = true;
|
|
218
|
+
}
|
|
219
|
+
index += 1;
|
|
220
|
+
}
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
|
226
|
+
* @param text - sanitized terminal text.
|
|
227
|
+
* @returns Line-normalized text with BEL removed.
|
|
228
|
+
*/
|
|
229
|
+
function normalizeTerminalText(text) {
|
|
230
|
+
return text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\x07", "");
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region lib/types/session.js
|
|
234
|
+
/** Persistent PTY session over the subprocess seam's terminal primitive. */
|
|
235
|
+
const CURSOR_POSITION_QUERY = "\x1B[6n";
|
|
236
|
+
const CURSOR_POSITION_RESPONSE = "\x1B[1;1R";
|
|
237
|
+
function utf8Tail(text, maxBytes) {
|
|
238
|
+
if (Buffer.byteLength(text) <= maxBytes) return {
|
|
239
|
+
text,
|
|
240
|
+
truncated: false
|
|
241
|
+
};
|
|
242
|
+
const chars = Array.from(text);
|
|
243
|
+
let bytes = 0;
|
|
244
|
+
let start = chars.length;
|
|
245
|
+
while (start > 0) {
|
|
246
|
+
const next = Buffer.byteLength(chars[start - 1]);
|
|
247
|
+
if (bytes + next > maxBytes) break;
|
|
248
|
+
bytes += next;
|
|
249
|
+
start -= 1;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
text: chars.slice(start).join(""),
|
|
253
|
+
truncated: true
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
var BoundedTextBuffer = class {
|
|
257
|
+
maxBytes;
|
|
258
|
+
maxLines;
|
|
259
|
+
value = "";
|
|
260
|
+
dropped = false;
|
|
261
|
+
constructor(maxBytes, maxLines) {
|
|
262
|
+
this.maxBytes = maxBytes;
|
|
263
|
+
this.maxLines = maxLines;
|
|
264
|
+
}
|
|
265
|
+
append(text) {
|
|
266
|
+
if (text.length === 0) return;
|
|
267
|
+
this.value += text;
|
|
268
|
+
if (this.maxLines !== void 0) {
|
|
269
|
+
const lines = this.value.split("\n");
|
|
270
|
+
if (lines.length > this.maxLines) {
|
|
271
|
+
this.value = lines.slice(lines.length - this.maxLines).join("\n");
|
|
272
|
+
this.dropped = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const tail = utf8Tail(this.value, this.maxBytes);
|
|
276
|
+
this.value = tail.text;
|
|
277
|
+
this.dropped ||= tail.truncated;
|
|
278
|
+
}
|
|
279
|
+
consume() {
|
|
280
|
+
const delta = this.value;
|
|
281
|
+
const truncated = this.dropped;
|
|
282
|
+
this.value = "";
|
|
283
|
+
this.dropped = false;
|
|
284
|
+
return {
|
|
285
|
+
delta,
|
|
286
|
+
truncated
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
snapshot() {
|
|
290
|
+
return {
|
|
291
|
+
text: this.value,
|
|
292
|
+
truncated: this.dropped
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
var LocalSendOperation = class {
|
|
297
|
+
startedAt;
|
|
298
|
+
onCancel;
|
|
299
|
+
output;
|
|
300
|
+
promise;
|
|
301
|
+
finished = false;
|
|
302
|
+
cancellationRequested = false;
|
|
303
|
+
initialForegroundLeftWait;
|
|
304
|
+
initialForegroundPgid;
|
|
305
|
+
constructor(maxBytes, startedAt, onCancel) {
|
|
306
|
+
this.startedAt = startedAt;
|
|
307
|
+
this.onCancel = onCancel;
|
|
308
|
+
this.output = new BoundedTextBuffer(maxBytes);
|
|
309
|
+
this.promise = Promise.withResolvers();
|
|
310
|
+
this.initialForegroundLeftWait = true;
|
|
311
|
+
}
|
|
312
|
+
get done() {
|
|
313
|
+
return this.promise.promise;
|
|
314
|
+
}
|
|
315
|
+
get settled() {
|
|
316
|
+
return this.finished;
|
|
317
|
+
}
|
|
318
|
+
get cancelRequested() {
|
|
319
|
+
return this.cancellationRequested;
|
|
320
|
+
}
|
|
321
|
+
append(text) {
|
|
322
|
+
if (!this.finished) this.output.append(text);
|
|
323
|
+
}
|
|
324
|
+
settle(waitReason, sessionStatus, inheritedTruncation) {
|
|
325
|
+
if (this.finished) return;
|
|
326
|
+
this.finished = true;
|
|
327
|
+
const read = this.output.snapshot();
|
|
328
|
+
this.promise.resolve({
|
|
329
|
+
viewport: read.text,
|
|
330
|
+
waitReason,
|
|
331
|
+
sessionStatus,
|
|
332
|
+
truncated: read.truncated || inheritedTruncation
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
fail(error) {
|
|
336
|
+
if (this.finished) return;
|
|
337
|
+
this.finished = true;
|
|
338
|
+
this.promise.reject(error);
|
|
339
|
+
}
|
|
340
|
+
readOutput() {
|
|
341
|
+
return this.output.consume();
|
|
342
|
+
}
|
|
343
|
+
setInitialForeground(foreground) {
|
|
344
|
+
this.initialForegroundPgid = foreground?.processGroupId;
|
|
345
|
+
this.initialForegroundLeftWait = foreground?.inputWaiting !== true;
|
|
346
|
+
}
|
|
347
|
+
acceptsStdinWait(pgid, waiting) {
|
|
348
|
+
if (pgid !== this.initialForegroundPgid) return waiting;
|
|
349
|
+
if (!waiting) this.initialForegroundLeftWait = true;
|
|
350
|
+
return waiting && this.initialForegroundLeftWait;
|
|
351
|
+
}
|
|
352
|
+
cancel() {
|
|
353
|
+
if (this.finished) return false;
|
|
354
|
+
this.cancellationRequested = true;
|
|
355
|
+
this.onCancel();
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
/** Backend session wrapping one provider-owned terminal process. */
|
|
360
|
+
var LocalPtySession = class {
|
|
361
|
+
terminal;
|
|
362
|
+
config;
|
|
363
|
+
motd = "";
|
|
364
|
+
pid;
|
|
365
|
+
decoder = new TextDecoder();
|
|
366
|
+
sanitizer;
|
|
367
|
+
cursorQueryTail = "";
|
|
368
|
+
scrollback;
|
|
369
|
+
firstOutput = Promise.withResolvers();
|
|
370
|
+
firstWrite = true;
|
|
371
|
+
outputEnded = Promise.withResolvers();
|
|
372
|
+
completion;
|
|
373
|
+
statusValue = { kind: "running" };
|
|
374
|
+
active;
|
|
375
|
+
activeTimer;
|
|
376
|
+
activeDeadlineTimer;
|
|
377
|
+
activeAbort;
|
|
378
|
+
interrupting;
|
|
379
|
+
activeWrite;
|
|
380
|
+
pollingReady;
|
|
381
|
+
polling = false;
|
|
382
|
+
promptSeen = false;
|
|
383
|
+
promptTextSeen = false;
|
|
384
|
+
promptTail = "";
|
|
385
|
+
shellPgid;
|
|
386
|
+
initializing = false;
|
|
387
|
+
lastOutputAt = Date.now();
|
|
388
|
+
closing = false;
|
|
389
|
+
closePromise;
|
|
390
|
+
transportFailure;
|
|
391
|
+
constructor(terminal, config) {
|
|
392
|
+
this.terminal = terminal;
|
|
393
|
+
this.config = config;
|
|
394
|
+
this.pid = terminal.pid;
|
|
395
|
+
this.sanitizer = new TerminalSanitizer(config.maxReadBytes);
|
|
396
|
+
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines);
|
|
397
|
+
terminal.output.on("data", this.onTerminalData);
|
|
398
|
+
terminal.output.once("end", this.onTerminalEnd);
|
|
399
|
+
terminal.output.once("error", this.onTerminalError);
|
|
400
|
+
this.completion = terminal.done.then((outcome) => this.onExit(outcome), (error) => {
|
|
401
|
+
this.onTransportFailure(error);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Capture startup output through the same readiness contract as later sends.
|
|
406
|
+
* @param signal - optional cancellation while the shell reaches its first prompt.
|
|
407
|
+
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
|
|
408
|
+
*/
|
|
409
|
+
async initialize(signal) {
|
|
410
|
+
this.initializing = true;
|
|
411
|
+
try {
|
|
412
|
+
const result = await this.startSend({
|
|
413
|
+
text: "",
|
|
414
|
+
submit: false,
|
|
415
|
+
...signal !== void 0 ? { signal } : {}
|
|
416
|
+
}).done;
|
|
417
|
+
if (result.waitReason === "session_exit") throw new Error("PTY shell exited during startup");
|
|
418
|
+
if (result.waitReason === "timeout") throw new Error("PTY shell did not reach readiness before startup timeout");
|
|
419
|
+
this.motd = result.viewport;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
signal?.throwIfAborted();
|
|
422
|
+
throw error;
|
|
423
|
+
} finally {
|
|
424
|
+
this.initializing = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
startSend(request) {
|
|
428
|
+
if (this.closing) throw new Error("PTY session is closing");
|
|
429
|
+
if (this.statusValue.kind === "exited") throw new Error("PTY session has exited");
|
|
430
|
+
if (this.active !== void 0) throw new TerminalError(`PTY session already has an active send${this.activeWrite !== void 0 ? " or draining provider write" : this.interrupting !== void 0 ? " or draining foreground interrupt" : ""}`, "SEND_ACTIVE");
|
|
431
|
+
if (request.signal?.aborted === true) throw new Error("PTY send aborted before write");
|
|
432
|
+
const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
|
|
433
|
+
this.interrupt(operation);
|
|
434
|
+
});
|
|
435
|
+
this.active = operation;
|
|
436
|
+
this.resetReadinessEvidence();
|
|
437
|
+
if (request.signal !== void 0) {
|
|
438
|
+
const onAbort = () => {
|
|
439
|
+
operation.cancel();
|
|
440
|
+
};
|
|
441
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
442
|
+
this.activeAbort = () => request.signal?.removeEventListener("abort", onAbort);
|
|
443
|
+
}
|
|
444
|
+
this.activeDeadlineTimer = setTimeout(() => {
|
|
445
|
+
if (this.active === operation) this.settleActive("timeout", this.activeWrite !== void 0 || this.interrupting === operation);
|
|
446
|
+
}, this.config.timeoutMs);
|
|
447
|
+
this.beginSend(operation, request);
|
|
448
|
+
return operation;
|
|
449
|
+
}
|
|
450
|
+
async beginSend(operation, request) {
|
|
451
|
+
let foreground;
|
|
452
|
+
try {
|
|
453
|
+
foreground = await this.terminal.inspectForeground();
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
if (this.config.shellDialect === "pwsh" && this.firstWrite && (request.text.length > 0 || request.submit)) await Promise.race([this.firstOutput.promise, operation.done]);
|
|
460
|
+
if (this.active !== operation || this.closing || this.interrupting === operation) return;
|
|
461
|
+
operation.setInitialForeground(foreground);
|
|
462
|
+
const input = `${request.text}${request.submit ? "\r" : ""}`;
|
|
463
|
+
if (input.length > 0 && !operation.cancelRequested) {
|
|
464
|
+
this.resetReadinessEvidence();
|
|
465
|
+
const write = this.terminal.write(input);
|
|
466
|
+
this.activeWrite = write.then(() => true, () => false);
|
|
467
|
+
try {
|
|
468
|
+
await write;
|
|
469
|
+
} finally {
|
|
470
|
+
this.activeWrite = void 0;
|
|
471
|
+
}
|
|
472
|
+
this.firstWrite = false;
|
|
473
|
+
}
|
|
474
|
+
if (operation.cancelRequested) return;
|
|
475
|
+
if (this.active === operation && operation.settled) {
|
|
476
|
+
this.clearActive();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (this.active === operation && !this.closing) {
|
|
480
|
+
this.pollingReady = operation;
|
|
481
|
+
this.schedulePoll(operation);
|
|
482
|
+
}
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (this.active === operation && !this.closing) if (operation.settled) this.clearActive();
|
|
485
|
+
else this.failActive(error);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
resetReadinessEvidence() {
|
|
489
|
+
this.lastOutputAt = Date.now();
|
|
490
|
+
this.promptSeen = false;
|
|
491
|
+
this.promptTextSeen = false;
|
|
492
|
+
this.promptTail = "";
|
|
493
|
+
}
|
|
494
|
+
read(request) {
|
|
495
|
+
const snapshot = this.scrollback.snapshot();
|
|
496
|
+
const lines = snapshot.text.split("\n");
|
|
497
|
+
const totalLines = snapshot.text.length === 0 ? 0 : lines.length;
|
|
498
|
+
const offset = request.offset ?? 0;
|
|
499
|
+
const count = request.count ?? 500;
|
|
500
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("PTY read offset must be a non-negative safe integer");
|
|
501
|
+
if (!Number.isSafeInteger(count) || count <= 0) throw new Error("PTY read count must be a positive safe integer");
|
|
502
|
+
if (offset >= totalLines) return {
|
|
503
|
+
text: "",
|
|
504
|
+
totalLines,
|
|
505
|
+
lineBegin: offset,
|
|
506
|
+
lineEnd: offset,
|
|
507
|
+
truncated: snapshot.truncated
|
|
508
|
+
};
|
|
509
|
+
const end = totalLines - offset;
|
|
510
|
+
const start = Math.max(0, end - count);
|
|
511
|
+
const bounded = utf8Tail(lines.slice(start, end).join("\n"), this.config.maxReadBytes);
|
|
512
|
+
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split("\n").length;
|
|
513
|
+
return {
|
|
514
|
+
text: bounded.text,
|
|
515
|
+
totalLines,
|
|
516
|
+
lineBegin: offset,
|
|
517
|
+
lineEnd: offset + returnedLines,
|
|
518
|
+
truncated: snapshot.truncated || bounded.truncated
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
async signal(signal) {
|
|
522
|
+
if (this.closing) throw new Error("PTY session is closing");
|
|
523
|
+
return {
|
|
524
|
+
delivered: true,
|
|
525
|
+
targetPgid: await this.terminal.signalForeground(signal)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
status() {
|
|
529
|
+
return this.statusValue;
|
|
530
|
+
}
|
|
531
|
+
close(reason) {
|
|
532
|
+
this.closing = true;
|
|
533
|
+
if (this.closePromise !== void 0) return this.closePromise;
|
|
534
|
+
const closing = this.closeOnce(reason).catch((error) => {
|
|
535
|
+
this.closePromise = void 0;
|
|
536
|
+
this.failActive(error);
|
|
537
|
+
throw error;
|
|
538
|
+
});
|
|
539
|
+
this.closePromise = closing;
|
|
540
|
+
return closing;
|
|
541
|
+
}
|
|
542
|
+
onTerminalData = (chunk) => {
|
|
543
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
544
|
+
this.onData(this.decoder.decode(bytes, { stream: true }));
|
|
545
|
+
};
|
|
546
|
+
onTerminalEnd = () => {
|
|
547
|
+
this.firstOutput.resolve();
|
|
548
|
+
this.onData(this.decoder.decode());
|
|
549
|
+
this.appendOutput(this.sanitizer.flush());
|
|
550
|
+
this.outputEnded.resolve();
|
|
551
|
+
};
|
|
552
|
+
onTerminalError = (error) => {
|
|
553
|
+
this.firstOutput.resolve();
|
|
554
|
+
this.onTransportFailure(error);
|
|
555
|
+
this.outputEnded.resolve();
|
|
556
|
+
};
|
|
557
|
+
onData(data) {
|
|
558
|
+
this.respondToCursorQueries(data);
|
|
559
|
+
if (this.cursorQueryTail.length === 0) this.firstOutput.resolve();
|
|
560
|
+
const sanitized = this.sanitizer.push(data);
|
|
561
|
+
this.appendOutput(sanitized.text);
|
|
562
|
+
if (sanitized.prompt) {
|
|
563
|
+
this.promptSeen = true;
|
|
564
|
+
this.promptTail = "";
|
|
565
|
+
this.lastOutputAt = Date.now();
|
|
566
|
+
}
|
|
567
|
+
if (this.promptSeen && sanitized.promptTail !== void 0) {
|
|
568
|
+
const remaining = Math.max(0, 5 - this.promptTail.length);
|
|
569
|
+
this.promptTail += sanitized.promptTail.slice(0, remaining);
|
|
570
|
+
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`;
|
|
571
|
+
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
respondToCursorQueries(data) {
|
|
575
|
+
const input = this.cursorQueryTail + data;
|
|
576
|
+
let offset = 0;
|
|
577
|
+
while (true) {
|
|
578
|
+
const query = input.indexOf(CURSOR_POSITION_QUERY, offset);
|
|
579
|
+
if (query < 0) break;
|
|
580
|
+
offset = query + 4;
|
|
581
|
+
if (!this.closing && this.statusValue.kind === "running") this.terminal.write(CURSOR_POSITION_RESPONSE).catch((error) => {
|
|
582
|
+
if (!this.closing) this.onTransportFailure(error);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
this.cursorQueryTail = "";
|
|
586
|
+
for (let length = Math.min(3, input.length); length > 0; length -= 1) if (input.endsWith(CURSOR_POSITION_QUERY.slice(0, length))) {
|
|
587
|
+
this.cursorQueryTail = input.slice(-length);
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
async onExit(outcome) {
|
|
592
|
+
await this.outputEnded.promise;
|
|
593
|
+
if (this.transportFailure !== void 0) return;
|
|
594
|
+
this.statusValue = {
|
|
595
|
+
kind: "exited",
|
|
596
|
+
exitCode: outcome.exitCode,
|
|
597
|
+
signal: outcome.signal
|
|
598
|
+
};
|
|
599
|
+
this.settleActive("session_exit");
|
|
600
|
+
}
|
|
601
|
+
onTransportFailure(error) {
|
|
602
|
+
this.firstOutput.resolve();
|
|
603
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
604
|
+
this.transportFailure ??= failure;
|
|
605
|
+
this.statusValue = {
|
|
606
|
+
kind: "exited",
|
|
607
|
+
exitCode: null,
|
|
608
|
+
signal: null
|
|
609
|
+
};
|
|
610
|
+
this.failActive(failure);
|
|
611
|
+
this.terminal.terminate().catch(() => {});
|
|
612
|
+
}
|
|
613
|
+
appendOutput(text) {
|
|
614
|
+
if (text.length === 0) return;
|
|
615
|
+
this.lastOutputAt = Date.now();
|
|
616
|
+
this.scrollback.append(text);
|
|
617
|
+
this.active?.append(text);
|
|
618
|
+
}
|
|
619
|
+
schedulePoll(operation, delayMs = this.config.pollIntervalMs) {
|
|
620
|
+
if (this.active !== operation || this.interrupting === operation || this.polling) return;
|
|
621
|
+
if (this.activeTimer !== void 0) clearTimeout(this.activeTimer);
|
|
622
|
+
this.activeTimer = setTimeout(() => {
|
|
623
|
+
this.activeTimer = void 0;
|
|
624
|
+
this.pollReadiness(operation);
|
|
625
|
+
}, delayMs);
|
|
626
|
+
}
|
|
627
|
+
async pollReadiness(operation) {
|
|
628
|
+
if (this.active !== operation || this.polling) return;
|
|
629
|
+
this.polling = true;
|
|
630
|
+
try {
|
|
631
|
+
if (this.statusValue.kind === "exited") {
|
|
632
|
+
this.settleActive("session_exit");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const foreground = await this.terminal.inspectForeground();
|
|
636
|
+
if (this.active !== operation || this.closing || this.interrupting === operation) return;
|
|
637
|
+
const idleFor = Date.now() - this.lastOutputAt;
|
|
638
|
+
if (this.promptSeen && foreground !== void 0 && this.shellPgid === void 0) this.shellPgid = foreground.processGroupId;
|
|
639
|
+
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs && foreground?.processGroupId === this.shellPgid) {
|
|
640
|
+
this.settleActive("stdin_read");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const elapsed = Date.now() - operation.startedAt;
|
|
644
|
+
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0;
|
|
645
|
+
const acceptsStdinWait = startupHasOutput && foreground !== void 0 && operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting);
|
|
646
|
+
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
|
|
647
|
+
this.settleActive("stdin_read");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0;
|
|
651
|
+
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) this.settleActive("inferred_idle");
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error);
|
|
654
|
+
} finally {
|
|
655
|
+
this.polling = false;
|
|
656
|
+
const active = this.active;
|
|
657
|
+
if (active !== void 0 && this.pollingReady === active) this.schedulePoll(active);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
settleActive(waitReason, retainOwnership = false) {
|
|
661
|
+
const operation = this.active;
|
|
662
|
+
if (operation === void 0) return;
|
|
663
|
+
const scrollbackTruncated = this.scrollback.snapshot().truncated;
|
|
664
|
+
if (retainOwnership) {
|
|
665
|
+
this.stopPolling();
|
|
666
|
+
this.activeAbort?.();
|
|
667
|
+
this.activeAbort = void 0;
|
|
668
|
+
} else this.clearActive();
|
|
669
|
+
operation.settle(waitReason, this.statusValue, scrollbackTruncated);
|
|
670
|
+
}
|
|
671
|
+
stopPolling() {
|
|
672
|
+
this.stopReadinessPolling();
|
|
673
|
+
if (this.activeDeadlineTimer !== void 0) clearTimeout(this.activeDeadlineTimer);
|
|
674
|
+
this.activeDeadlineTimer = void 0;
|
|
675
|
+
}
|
|
676
|
+
stopReadinessPolling() {
|
|
677
|
+
if (this.activeTimer !== void 0) clearTimeout(this.activeTimer);
|
|
678
|
+
this.activeTimer = void 0;
|
|
679
|
+
this.pollingReady = void 0;
|
|
680
|
+
}
|
|
681
|
+
clearActive() {
|
|
682
|
+
const operation = this.active;
|
|
683
|
+
this.stopPolling();
|
|
684
|
+
this.activeAbort?.();
|
|
685
|
+
this.activeAbort = void 0;
|
|
686
|
+
if (this.interrupting === operation) this.interrupting = void 0;
|
|
687
|
+
this.pollingReady = void 0;
|
|
688
|
+
this.active = void 0;
|
|
689
|
+
}
|
|
690
|
+
failActive(error) {
|
|
691
|
+
const operation = this.active;
|
|
692
|
+
if (operation === void 0) return;
|
|
693
|
+
this.clearActive();
|
|
694
|
+
operation.fail(error);
|
|
695
|
+
}
|
|
696
|
+
interrupt(operation) {
|
|
697
|
+
if (this.active !== operation) return;
|
|
698
|
+
this.interrupting = operation;
|
|
699
|
+
this.stopReadinessPolling();
|
|
700
|
+
this.interruptOnce(operation);
|
|
701
|
+
}
|
|
702
|
+
async interruptOnce(operation) {
|
|
703
|
+
try {
|
|
704
|
+
const activeWrite = this.activeWrite;
|
|
705
|
+
if (activeWrite !== void 0 && !await activeWrite) return;
|
|
706
|
+
await this.terminal.signalForeground("SIGINT");
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (this.active === operation && !this.closing) this.onTransportFailure(error);
|
|
709
|
+
return;
|
|
710
|
+
} finally {
|
|
711
|
+
if (this.interrupting === operation) this.interrupting = void 0;
|
|
712
|
+
}
|
|
713
|
+
if (this.active === operation && operation.settled) this.clearActive();
|
|
714
|
+
else if (this.active === operation && !this.closing) {
|
|
715
|
+
this.pollingReady = operation;
|
|
716
|
+
this.schedulePoll(operation, 0);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async closeOnce(reason) {
|
|
720
|
+
this.stopPolling();
|
|
721
|
+
try {
|
|
722
|
+
await this.terminal.terminate();
|
|
723
|
+
} catch (error) {
|
|
724
|
+
throw new Error(`PTY cleanup failed (${reason})`, { cause: error });
|
|
725
|
+
}
|
|
726
|
+
this.settleActive("session_exit");
|
|
727
|
+
await this.completion;
|
|
728
|
+
this.terminal.output.off("data", this.onTerminalData);
|
|
729
|
+
this.terminal.output.off("end", this.onTerminalEnd);
|
|
730
|
+
this.terminal.output.off("error", this.onTerminalError);
|
|
731
|
+
if (this.transportFailure !== void 0) throw this.transportFailure;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
//#endregion
|
|
735
|
+
//#region lib/types/index.js
|
|
736
|
+
/**
|
|
737
|
+
* Persistent shell PTY backend over the subprocess terminal primitive, shared
|
|
738
|
+
* sandbox policy, bounded output, and provider-owned session cleanup.
|
|
739
|
+
* @module @hydraharness/harness-terminal-bash
|
|
740
|
+
*/
|
|
741
|
+
/** Cordis plugin name. */
|
|
742
|
+
const name = "terminal-bash";
|
|
743
|
+
/** Required services: PTY registry, shared confinement policy, and process substrate. */
|
|
744
|
+
const inject = [
|
|
745
|
+
"terminals",
|
|
746
|
+
"sandboxPolicy",
|
|
747
|
+
"subprocess"
|
|
748
|
+
];
|
|
749
|
+
const sandboxModeFences = /* @__PURE__ */ new WeakMap();
|
|
750
|
+
function ensureSandboxModeFence(ctx, owner) {
|
|
751
|
+
const existing = sandboxModeFences.get(owner);
|
|
752
|
+
if (existing !== void 0) {
|
|
753
|
+
existing.pty = ctx.terminals;
|
|
754
|
+
existing.sandboxPolicy = ctx.sandboxPolicy;
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const state = {
|
|
758
|
+
pty: ctx.terminals,
|
|
759
|
+
sandboxPolicy: ctx.sandboxPolicy
|
|
760
|
+
};
|
|
761
|
+
sandboxModeFences.set(owner, state);
|
|
762
|
+
owner.ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
763
|
+
if (eventName !== "session/event") return;
|
|
764
|
+
const [session, event] = args;
|
|
765
|
+
if (session !== owner.session || event.type !== "sandbox/mode") return;
|
|
766
|
+
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode;
|
|
767
|
+
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return;
|
|
768
|
+
throw new Error(`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`);
|
|
769
|
+
}, { global: true });
|
|
770
|
+
}
|
|
771
|
+
function childEnvironment(spec, dialect) {
|
|
772
|
+
const common = {
|
|
773
|
+
TERM: "dumb",
|
|
774
|
+
PAGER: "cat",
|
|
775
|
+
GIT_PAGER: "cat",
|
|
776
|
+
HYDRA_SHELL: "1",
|
|
777
|
+
HYDRA_SESSION_ID: spec.owner.id,
|
|
778
|
+
HYDRA_PTY_SESSION_ID: spec.sessionId
|
|
779
|
+
};
|
|
780
|
+
if (dialect === "pwsh") return {
|
|
781
|
+
...common,
|
|
782
|
+
NO_COLOR: "1"
|
|
783
|
+
};
|
|
784
|
+
return {
|
|
785
|
+
...common,
|
|
786
|
+
PS1: CONTROLLED_PROMPT,
|
|
787
|
+
PROMPT_COMMAND: `printf "\\033]133;D;%s\\007" "$?"; PS1='${CONTROLLED_PROMPT}'`,
|
|
788
|
+
BASH_SILENCE_DEPRECATION_WARNING: "1"
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
|
|
793
|
+
* before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7`
|
|
794
|
+
* build the control bytes at runtime because raw ESC characters in submitted
|
|
795
|
+
* input are unreliable under PSReadLine.
|
|
796
|
+
*/
|
|
797
|
+
const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); 'hh> ' }";
|
|
798
|
+
function spawnArgv(ctx, config, policy) {
|
|
799
|
+
const argv = [config.shellPath, ...config.shellArgs];
|
|
800
|
+
if (policy.mode === "danger-full-access") return argv;
|
|
801
|
+
const sandbox = ctx.get("sandbox");
|
|
802
|
+
if (sandbox === void 0) throw new Error(`terminal-bash: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`);
|
|
803
|
+
return sandbox.confine(argv, {
|
|
804
|
+
...policy,
|
|
805
|
+
mode: policy.mode
|
|
806
|
+
}).argv;
|
|
807
|
+
}
|
|
808
|
+
async function startupSession(session, dialect, signal) {
|
|
809
|
+
const start = async () => {
|
|
810
|
+
if (dialect === "bash") {
|
|
811
|
+
await session.initialize(signal);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
let viewport = "";
|
|
815
|
+
let first = true;
|
|
816
|
+
for (;;) {
|
|
817
|
+
const operation = session.startSend({
|
|
818
|
+
text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : "",
|
|
819
|
+
submit: first,
|
|
820
|
+
...signal !== void 0 ? { signal } : {}
|
|
821
|
+
});
|
|
822
|
+
first = false;
|
|
823
|
+
const result = await operation.done;
|
|
824
|
+
if (result.waitReason === "session_exit") throw new Error("PTY shell exited during startup");
|
|
825
|
+
if (result.waitReason === "timeout") throw new Error("PTY shell did not reach readiness before startup timeout");
|
|
826
|
+
viewport = result.viewport;
|
|
827
|
+
const scrollback = session.read({
|
|
828
|
+
offset: 0,
|
|
829
|
+
count: 20
|
|
830
|
+
}).text;
|
|
831
|
+
if (viewport.endsWith("hh> ") || scrollback.endsWith("hh> ")) break;
|
|
832
|
+
}
|
|
833
|
+
session.motd = viewport;
|
|
834
|
+
};
|
|
835
|
+
if (signal === void 0) {
|
|
836
|
+
await start();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const aborted = Promise.withResolvers();
|
|
840
|
+
const onAbort = () => {
|
|
841
|
+
aborted.reject(signal.reason);
|
|
842
|
+
};
|
|
843
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
844
|
+
try {
|
|
845
|
+
signal.throwIfAborted();
|
|
846
|
+
await Promise.race([start(), aborted.promise]);
|
|
847
|
+
} finally {
|
|
848
|
+
signal.removeEventListener("abort", onAbort);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Local shell backend registered under the configured type. */
|
|
852
|
+
var BashTerminalBackend = class {
|
|
853
|
+
ctx;
|
|
854
|
+
config;
|
|
855
|
+
spawnTerminal;
|
|
856
|
+
createSession;
|
|
857
|
+
type;
|
|
858
|
+
constructor(ctx, config, spawnTerminal = (spec) => ctx.subprocess.spawnTerminal(spec), createSession = (terminal, config) => new LocalPtySession(terminal, config)) {
|
|
859
|
+
this.ctx = ctx;
|
|
860
|
+
this.config = config;
|
|
861
|
+
this.spawnTerminal = spawnTerminal;
|
|
862
|
+
this.createSession = createSession;
|
|
863
|
+
this.type = config.backendType;
|
|
864
|
+
}
|
|
865
|
+
async spawn(spec) {
|
|
866
|
+
spec.signal?.throwIfAborted();
|
|
867
|
+
ensureSandboxModeFence(this.ctx, spec.owner);
|
|
868
|
+
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session });
|
|
869
|
+
const argv = spawnArgv(this.ctx, this.config, policy);
|
|
870
|
+
if (argv[0] === void 0) throw new Error("terminal-bash: sandbox returned empty argv");
|
|
871
|
+
const terminal = await this.spawnTerminal({
|
|
872
|
+
argv,
|
|
873
|
+
cwd: spec.cwd ?? policy.workspaceRoot,
|
|
874
|
+
env: childEnvironment(spec, this.config.shellDialect),
|
|
875
|
+
rows: this.config.rows,
|
|
876
|
+
cols: this.config.cols,
|
|
877
|
+
graceMs: this.config.disposeGraceMs,
|
|
878
|
+
signal: spec.signal
|
|
879
|
+
});
|
|
880
|
+
const session = this.createSession(terminal, this.config);
|
|
881
|
+
try {
|
|
882
|
+
await startupSession(session, this.config.shellDialect, spec.signal);
|
|
883
|
+
return session;
|
|
884
|
+
} catch (error) {
|
|
885
|
+
try {
|
|
886
|
+
await session.close("PTY startup failed");
|
|
887
|
+
} catch (closeError) {
|
|
888
|
+
throw new TerminalBackendCleanupError(error, closeError);
|
|
889
|
+
}
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
/** Register the local PTY backend. */
|
|
895
|
+
function apply(ctx, config) {
|
|
896
|
+
const resolved = resolveConfig(config);
|
|
897
|
+
validateConfig(resolved);
|
|
898
|
+
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, resolved));
|
|
899
|
+
}
|
|
900
|
+
//#endregion
|
|
901
|
+
export { BashTerminalBackend, Config, PWSH_PROMPT_SETUP, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-terminal-bash`.
|
|
4
|
+
* @module @hydraharness/harness-terminal-bash/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-terminal-bash";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "terminal-bash-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: readiness, terminal buffers, and process-tree state are private per-session
|
|
13
|
+
* implementation state, and the backend publishes no independent lifecycle stream or snapshot.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Validated configuration for the local PTY backend. */
|
|
2
|
+
import z from '@hydraharness/schemastery';
|
|
3
|
+
/** One supported interactive shell dialect. */
|
|
4
|
+
export type ShellDialect = 'bash' | 'pwsh';
|
|
5
|
+
/** Public plugin configuration. */
|
|
6
|
+
export interface Config {
|
|
7
|
+
/** Backend registry type (default: `shell`). */
|
|
8
|
+
backendType?: string;
|
|
9
|
+
/** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
|
|
10
|
+
shellDialect?: ShellDialect;
|
|
11
|
+
/** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
|
|
12
|
+
shellPath?: string;
|
|
13
|
+
/** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
|
|
14
|
+
shellArgs?: string[];
|
|
15
|
+
/** Terminal rows. */
|
|
16
|
+
rows?: number;
|
|
17
|
+
/** Terminal columns. */
|
|
18
|
+
cols?: number;
|
|
19
|
+
/** Maximum retained logical lines. */
|
|
20
|
+
scrollbackLines?: number;
|
|
21
|
+
/** Maximum retained UTF-8 bytes. */
|
|
22
|
+
scrollbackMaxBytes?: number;
|
|
23
|
+
/** Maximum bytes returned by one read or settled viewport. */
|
|
24
|
+
maxReadBytes?: number;
|
|
25
|
+
/** Readiness polling interval. */
|
|
26
|
+
pollIntervalMs?: number;
|
|
27
|
+
/** Delay before Linux exact syscall probes. */
|
|
28
|
+
exactProbeAfterMs?: number;
|
|
29
|
+
/** Silence duration that yields `inferred_idle`. */
|
|
30
|
+
idleSilenceMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
|
|
33
|
+
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
|
34
|
+
*/
|
|
35
|
+
handoffGraceMs?: number;
|
|
36
|
+
/** Absolute send wait bound. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Grace before teardown escalates to `SIGKILL`. */
|
|
39
|
+
disposeGraceMs?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Configuration after Schemastery defaults and dialect resolution. */
|
|
42
|
+
export type ResolvedConfig = Omit<Required<Config>, 'shellDialect' | 'shellPath' | 'shellArgs'> & {
|
|
43
|
+
shellDialect: ShellDialect;
|
|
44
|
+
shellPath: string;
|
|
45
|
+
shellArgs: string[];
|
|
46
|
+
};
|
|
47
|
+
/** Bash dialect default executable. */
|
|
48
|
+
export declare const DEFAULT_BASH_SHELL = "/bin/bash";
|
|
49
|
+
/** Bash dialect default arguments (interactive, profile-free). */
|
|
50
|
+
export declare const DEFAULT_BASH_ARGS: string[];
|
|
51
|
+
/** Pwsh dialect default arguments (interactive host, profile-free). */
|
|
52
|
+
export declare const DEFAULT_PWSH_ARGS: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the effective per-dialect shell specification. Defaulting is this
|
|
55
|
+
* explicit step: an unset or empty `shellPath`/`shellArgs` selects the
|
|
56
|
+
* dialect's defaults, while a non-empty explicit value always wins.
|
|
57
|
+
* (Schemastery materializes an absent optional array as `[]`, so emptiness —
|
|
58
|
+
* not just `undefined` — means "dialect default".)
|
|
59
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
60
|
+
* @returns the fully resolved configuration.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveConfig(config: Config): ResolvedConfig;
|
|
63
|
+
/** Schemastery config exposed by the plugin. */
|
|
64
|
+
export declare const Config: z<Config>;
|
|
65
|
+
/**
|
|
66
|
+
* Assert every effective numeric config field is a positive safe integer and bounds compose.
|
|
67
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
68
|
+
* @returns Narrows the input to the fully resolved configuration.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateConfig(config: Config): asserts config is ResolvedConfig;
|
|
71
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent shell PTY backend over the subprocess terminal primitive, shared
|
|
3
|
+
* sandbox policy, bounded output, and provider-owned session cleanup.
|
|
4
|
+
* @module @hydraharness/harness-terminal-bash
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@hydraharness/cordis';
|
|
7
|
+
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@hydraharness/harness-terminal';
|
|
8
|
+
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@hydraharness/harness-subprocess';
|
|
9
|
+
import { type Config, type ResolvedConfig } from './config.ts';
|
|
10
|
+
import { LocalPtySession } from './session.ts';
|
|
11
|
+
export { Config } from './config.ts';
|
|
12
|
+
export type { Config as TerminalLocalConfig } from './config.ts';
|
|
13
|
+
/** Cordis plugin name. */
|
|
14
|
+
export declare const name = "terminal-bash";
|
|
15
|
+
/** Required services: PTY registry, shared confinement policy, and process substrate. */
|
|
16
|
+
export declare const inject: string[];
|
|
17
|
+
/**
|
|
18
|
+
* The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
|
|
19
|
+
* before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7`
|
|
20
|
+
* build the control bytes at runtime because raw ESC characters in submitted
|
|
21
|
+
* input are unreliable under PSReadLine.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PWSH_PROMPT_SETUP: string;
|
|
24
|
+
/** Local shell backend registered under the configured type. */
|
|
25
|
+
export declare class BashTerminalBackend implements TerminalBackend {
|
|
26
|
+
private readonly ctx;
|
|
27
|
+
private readonly config;
|
|
28
|
+
private readonly spawnTerminal;
|
|
29
|
+
private readonly createSession;
|
|
30
|
+
readonly type: string;
|
|
31
|
+
constructor(ctx: Context, config: ResolvedConfig, spawnTerminal?: (spec: SubprocessTerminalSpawnSpec) => Promise<SubprocessTerminalHandle>, createSession?: (terminal: SubprocessTerminalHandle, config: ResolvedConfig) => LocalPtySession);
|
|
32
|
+
spawn(spec: TerminalBackendSpawnSpec): Promise<LocalPtySession>;
|
|
33
|
+
}
|
|
34
|
+
/** Register the local PTY backend. */
|
|
35
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-terminal-bash`.
|
|
3
|
+
* @module @hydraharness/harness-terminal-bash/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "terminal-bash-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
|
2
|
+
/** OSC marker emitted by the controlled bash before each prompt. */
|
|
3
|
+
export declare const PROMPT_MARKER_PREFIX = "133;D;";
|
|
4
|
+
/** Exact printable prompt emitted after the private marker. */
|
|
5
|
+
export declare const CONTROLLED_PROMPT = "hh> ";
|
|
6
|
+
/** One sanitized chunk plus whether it contained the owned prompt marker. */
|
|
7
|
+
export interface SanitizedChunk {
|
|
8
|
+
text: string;
|
|
9
|
+
prompt: boolean;
|
|
10
|
+
/** Printable text after the latest owned marker in this chunk. */
|
|
11
|
+
promptTail?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
|
15
|
+
* Full terminal emulation is deliberately deferred; ordinary line output and
|
|
16
|
+
* the private prompt marker are the supported contract.
|
|
17
|
+
*/
|
|
18
|
+
export declare class TerminalSanitizer {
|
|
19
|
+
private readonly maxPendingBytes;
|
|
20
|
+
private pending;
|
|
21
|
+
private discardMode;
|
|
22
|
+
private discardOscEscape;
|
|
23
|
+
private trailingCarriageReturn;
|
|
24
|
+
private trackingPromptTail;
|
|
25
|
+
constructor(maxPendingBytes: number);
|
|
26
|
+
/**
|
|
27
|
+
* Consume one decoded `node-pty` data chunk.
|
|
28
|
+
* @param chunk - decoded terminal data.
|
|
29
|
+
* @returns Printable text and whether the private prompt marker completed.
|
|
30
|
+
*/
|
|
31
|
+
push(chunk: string): SanitizedChunk;
|
|
32
|
+
/**
|
|
33
|
+
* Flush a trailing printable fragment when the PTY exits.
|
|
34
|
+
* @returns Remaining printable text; incomplete escapes are discarded.
|
|
35
|
+
*/
|
|
36
|
+
flush(): string;
|
|
37
|
+
private normalizeText;
|
|
38
|
+
private enforcePendingBound;
|
|
39
|
+
private discardPrefix;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
|
43
|
+
* @param text - sanitized terminal text.
|
|
44
|
+
* @returns Line-normalized text with BEL removed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeTerminalText(text: string): string;
|
|
47
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Persistent PTY session over the subprocess seam's terminal primitive. */
|
|
2
|
+
import type { SubprocessTerminalHandle } from '@hydraharness/harness-subprocess';
|
|
3
|
+
import type { TerminalBackendSession, TerminalReadRequest, TerminalReadResult, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal, TerminalSignalResult } from '@hydraharness/harness-terminal';
|
|
4
|
+
import type { ResolvedConfig } from './config.ts';
|
|
5
|
+
/** Backend session wrapping one provider-owned terminal process. */
|
|
6
|
+
export declare class LocalPtySession implements TerminalBackendSession {
|
|
7
|
+
private readonly terminal;
|
|
8
|
+
private readonly config;
|
|
9
|
+
motd: string;
|
|
10
|
+
readonly pid: number;
|
|
11
|
+
private readonly decoder;
|
|
12
|
+
private readonly sanitizer;
|
|
13
|
+
private cursorQueryTail;
|
|
14
|
+
private readonly scrollback;
|
|
15
|
+
private readonly firstOutput;
|
|
16
|
+
private firstWrite;
|
|
17
|
+
private readonly outputEnded;
|
|
18
|
+
private readonly completion;
|
|
19
|
+
private statusValue;
|
|
20
|
+
private active;
|
|
21
|
+
private activeTimer;
|
|
22
|
+
private activeDeadlineTimer;
|
|
23
|
+
private activeAbort;
|
|
24
|
+
private interrupting;
|
|
25
|
+
private activeWrite;
|
|
26
|
+
private pollingReady;
|
|
27
|
+
private polling;
|
|
28
|
+
private promptSeen;
|
|
29
|
+
private promptTextSeen;
|
|
30
|
+
private promptTail;
|
|
31
|
+
private shellPgid;
|
|
32
|
+
private initializing;
|
|
33
|
+
private lastOutputAt;
|
|
34
|
+
private closing;
|
|
35
|
+
private closePromise;
|
|
36
|
+
private transportFailure;
|
|
37
|
+
constructor(terminal: SubprocessTerminalHandle, config: ResolvedConfig);
|
|
38
|
+
/**
|
|
39
|
+
* Capture startup output through the same readiness contract as later sends.
|
|
40
|
+
* @param signal - optional cancellation while the shell reaches its first prompt.
|
|
41
|
+
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
|
|
42
|
+
*/
|
|
43
|
+
initialize(signal?: AbortSignal): Promise<void>;
|
|
44
|
+
startSend(request: TerminalSendRequest): TerminalSendOperation;
|
|
45
|
+
private beginSend;
|
|
46
|
+
private resetReadinessEvidence;
|
|
47
|
+
read(request: TerminalReadRequest): TerminalReadResult;
|
|
48
|
+
signal(signal: TerminalSignal): Promise<TerminalSignalResult>;
|
|
49
|
+
status(): TerminalSessionStatus;
|
|
50
|
+
close(reason: string): Promise<void>;
|
|
51
|
+
private readonly onTerminalData;
|
|
52
|
+
private readonly onTerminalEnd;
|
|
53
|
+
private readonly onTerminalError;
|
|
54
|
+
private onData;
|
|
55
|
+
private respondToCursorQueries;
|
|
56
|
+
private onExit;
|
|
57
|
+
private onTransportFailure;
|
|
58
|
+
private appendOutput;
|
|
59
|
+
private schedulePoll;
|
|
60
|
+
private pollReadiness;
|
|
61
|
+
private settleActive;
|
|
62
|
+
private stopPolling;
|
|
63
|
+
private stopReadinessPolling;
|
|
64
|
+
private clearActive;
|
|
65
|
+
private failActive;
|
|
66
|
+
private interrupt;
|
|
67
|
+
private interruptOnce;
|
|
68
|
+
private closeOnce;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=session.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydraharness/harness-terminal-bash",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
3
|
+
"description": "Persistent shell PTY backend over the Hydra harness subprocess terminal primitive",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Provide a persistent shell terminal for commands that need an interactive process."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.7",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/terminal/terminal-bash"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./src/*": "./src/*",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/index.js",
|
|
35
|
+
"lib/invariant.js",
|
|
36
|
+
"lib/types/**/*.d.ts"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.7",
|
|
41
|
+
"@hydraharness/harness-terminal": "^0.1.1-rc.7",
|
|
42
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
43
|
+
"@hydraharness/harness-sandbox": "^0.1.1-rc.7",
|
|
44
|
+
"@hydraharness/harness-session": "^0.1.1-rc.7",
|
|
45
|
+
"@hydraharness/harness-sandbox-policy": "^0.1.1-rc.7",
|
|
46
|
+
"@hydraharness/harness-subprocess": "^0.1.1-rc.7",
|
|
47
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@hydraharness/schemastery": "^3.18.2",
|
|
51
|
+
"@hydraharness/harness-pwsh-local": "^0.1.1-rc.7"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@hydraharness/harness-terminal": "^0.1.1-rc.7",
|
|
55
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.7",
|
|
56
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
57
|
+
"@hydraharness/harness-sandbox": "^0.1.1-rc.7",
|
|
58
|
+
"@hydraharness/harness-sandbox-policy": "^0.1.1-rc.7",
|
|
59
|
+
"@hydraharness/harness-session": "^0.1.1-rc.7",
|
|
60
|
+
"@hydraharness/harness-subprocess": "^0.1.1-rc.7",
|
|
61
|
+
"@hydraharness/harness-subprocess-local": "^0.1.1-rc.7",
|
|
62
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
63
|
+
}
|
|
6
64
|
}
|