@steerable/agent-shell 0.6.15
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 +91 -0
- package/contracts/tool-contract.json +326 -0
- package/dist/attachments.d.ts +41 -0
- package/dist/attachments.js +147 -0
- package/dist/brand.d.ts +24 -0
- package/dist/brand.js +92 -0
- package/dist/host/http-routes.d.ts +21 -0
- package/dist/host/http-routes.js +55 -0
- package/dist/host/ipc.d.ts +11 -0
- package/dist/host/ipc.js +20 -0
- package/dist/host/pack-assembly.d.ts +86 -0
- package/dist/host/pack-assembly.js +32 -0
- package/dist/host/runtime.d.ts +69 -0
- package/dist/host/runtime.js +207 -0
- package/dist/host/visible-terminal-exec.d.ts +21 -0
- package/dist/host/visible-terminal-exec.js +151 -0
- package/dist/hosted-web-search.d.ts +13 -0
- package/dist/hosted-web-search.js +82 -0
- package/dist/image-attachment.d.ts +39 -0
- package/dist/image-attachment.js +133 -0
- package/dist/insights/flush.d.ts +10 -0
- package/dist/insights/flush.js +139 -0
- package/dist/insights/record.d.ts +17 -0
- package/dist/insights/record.js +44 -0
- package/dist/json-store.d.ts +9 -0
- package/dist/json-store.js +22 -0
- package/dist/llm/index.d.ts +23 -0
- package/dist/llm/index.js +105 -0
- package/dist/llm/ollama.d.ts +23 -0
- package/dist/llm/ollama.js +242 -0
- package/dist/llm/openai-compat.d.ts +20 -0
- package/dist/llm/openai-compat.js +199 -0
- package/dist/llm/sidecar-provider.d.ts +37 -0
- package/dist/llm/sidecar-provider.js +163 -0
- package/dist/llm/tool-choice.d.ts +35 -0
- package/dist/llm/tool-choice.js +85 -0
- package/dist/llm/types.d.ts +122 -0
- package/dist/llm/types.js +1 -0
- package/dist/local-backend/agent-capability.d.ts +101 -0
- package/dist/local-backend/agent-capability.js +174 -0
- package/dist/local-backend/ai-title.d.ts +43 -0
- package/dist/local-backend/ai-title.js +173 -0
- package/dist/local-backend/auto-continue-helper.d.ts +80 -0
- package/dist/local-backend/auto-continue-helper.js +83 -0
- package/dist/local-backend/branch-helper.d.ts +24 -0
- package/dist/local-backend/branch-helper.js +27 -0
- package/dist/local-backend/context-compactor.d.ts +81 -0
- package/dist/local-backend/context-compactor.js +213 -0
- package/dist/local-backend/coreloop-stream.d.ts +245 -0
- package/dist/local-backend/coreloop-stream.js +277 -0
- package/dist/local-backend/deferred-detector.d.ts +15 -0
- package/dist/local-backend/deferred-detector.js +124 -0
- package/dist/local-backend/history-helper.d.ts +30 -0
- package/dist/local-backend/history-helper.js +34 -0
- package/dist/local-backend/interrupted-helper.d.ts +29 -0
- package/dist/local-backend/interrupted-helper.js +25 -0
- package/dist/local-backend/live-stream.d.ts +36 -0
- package/dist/local-backend/live-stream.js +23 -0
- package/dist/local-backend/llm-diagnose.d.ts +37 -0
- package/dist/local-backend/llm-diagnose.js +284 -0
- package/dist/local-backend/message-triggers.d.ts +27 -0
- package/dist/local-backend/message-triggers.js +67 -0
- package/dist/local-backend/pack-backend-routes.d.ts +31 -0
- package/dist/local-backend/pack-backend-routes.js +62 -0
- package/dist/local-backend/pack-turn-hooks.d.ts +37 -0
- package/dist/local-backend/pack-turn-hooks.js +67 -0
- package/dist/local-backend/prompt-builder.d.ts +101 -0
- package/dist/local-backend/prompt-builder.js +246 -0
- package/dist/local-backend/regenerate-helper.d.ts +62 -0
- package/dist/local-backend/regenerate-helper.js +75 -0
- package/dist/local-backend/router.d.ts +175 -0
- package/dist/local-backend/router.js +3139 -0
- package/dist/local-backend/skill-install.d.ts +19 -0
- package/dist/local-backend/skill-install.js +71 -0
- package/dist/local-backend/skill-loader.d.ts +92 -0
- package/dist/local-backend/skill-loader.js +146 -0
- package/dist/local-backend/skills/00-identity/SKILL.md +32 -0
- package/dist/local-backend/skills/10-goal/SKILL.md +59 -0
- package/dist/local-backend/skills/11-loop/SKILL.md +71 -0
- package/dist/local-backend/skills/12-create-skill/SKILL.md +88 -0
- package/dist/local-backend/skills/70-plan-mode/SKILL.md +58 -0
- package/dist/local-backend/skills/80-tool-usage/SKILL.md +70 -0
- package/dist/local-backend/skills/81-anti-deferred/SKILL.md +53 -0
- package/dist/local-backend/skills/82-data-grounding/SKILL.md +56 -0
- package/dist/local-backend/skills/85-local-exec/SKILL.md +86 -0
- package/dist/local-backend/skills/86-proactive-coding/SKILL.md +51 -0
- package/dist/local-backend/subagent-profiles.d.ts +30 -0
- package/dist/local-backend/subagent-profiles.js +74 -0
- package/dist/local-backend/task-process.d.ts +12 -0
- package/dist/local-backend/task-process.js +176 -0
- package/dist/local-backend/task-service.d.ts +135 -0
- package/dist/local-backend/task-service.js +565 -0
- package/dist/local-backend/turn-duration.d.ts +2 -0
- package/dist/local-backend/turn-duration.js +9 -0
- package/dist/local-backend/turn-timeline.d.ts +16 -0
- package/dist/local-backend/turn-timeline.js +42 -0
- package/dist/local-backend/worktree-service.d.ts +84 -0
- package/dist/local-backend/worktree-service.js +243 -0
- package/dist/local-edit.d.ts +48 -0
- package/dist/local-edit.js +44 -0
- package/dist/local-executor.d.ts +255 -0
- package/dist/local-executor.js +881 -0
- package/dist/local-script-registry.d.ts +28 -0
- package/dist/local-script-registry.js +63 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +12 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +855 -0
- package/dist/mcp-executor.d.ts +45 -0
- package/dist/mcp-executor.js +241 -0
- package/dist/mcp-server-registry.d.ts +104 -0
- package/dist/mcp-server-registry.js +234 -0
- package/dist/preload-default.d.ts +1 -0
- package/dist/preload-default.js +9 -0
- package/dist/preload.cjs +395 -0
- package/dist/preload.d.ts +20 -0
- package/dist/preload.js +411 -0
- package/dist/product-config.d.ts +43 -0
- package/dist/product-config.js +26 -0
- package/dist/project-registry.d.ts +55 -0
- package/dist/project-registry.js +106 -0
- package/dist/project-rules.d.ts +15 -0
- package/dist/project-rules.js +102 -0
- package/dist/runtime.d.ts +62 -0
- package/dist/runtime.js +217 -0
- package/dist/scenario/pack.d.ts +8 -0
- package/dist/scenario/pack.js +1 -0
- package/dist/scenario/registry.d.ts +24 -0
- package/dist/scenario/registry.js +31 -0
- package/dist/server/http-server.d.ts +39 -0
- package/dist/server/http-server.js +361 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +107 -0
- package/dist/server/sse-bus.d.ts +14 -0
- package/dist/server/sse-bus.js +31 -0
- package/dist/shell-adapt.d.ts +21 -0
- package/dist/shell-adapt.js +104 -0
- package/dist/sidecar/boot.d.ts +36 -0
- package/dist/sidecar/boot.js +343 -0
- package/dist/sidecar/egress-hint.d.ts +15 -0
- package/dist/sidecar/egress-hint.js +46 -0
- package/dist/sidecar/egress-proxy.d.ts +183 -0
- package/dist/sidecar/egress-proxy.js +419 -0
- package/dist/sidecar/errors.d.ts +22 -0
- package/dist/sidecar/errors.js +38 -0
- package/dist/sidecar/exec-sandbox.d.ts +48 -0
- package/dist/sidecar/exec-sandbox.js +94 -0
- package/dist/sidecar/handle.d.ts +32 -0
- package/dist/sidecar/handle.js +53 -0
- package/dist/sidecar/index.d.ts +14 -0
- package/dist/sidecar/index.js +13 -0
- package/dist/sidecar/proxy-detect.d.ts +50 -0
- package/dist/sidecar/proxy-detect.js +182 -0
- package/dist/sidecar/reverse-approval.d.ts +55 -0
- package/dist/sidecar/reverse-approval.js +86 -0
- package/dist/sidecar/reverse-ask-user.d.ts +34 -0
- package/dist/sidecar/reverse-ask-user.js +59 -0
- package/dist/sidecar/reverse-spawn.d.ts +19 -0
- package/dist/sidecar/reverse-spawn.js +161 -0
- package/dist/sidecar/reverse-tools.d.ts +29 -0
- package/dist/sidecar/reverse-tools.js +106 -0
- package/dist/sidecar/safety-patterns.d.ts +41 -0
- package/dist/sidecar/safety-patterns.js +157 -0
- package/dist/sidecar/storage-path.d.ts +14 -0
- package/dist/sidecar/storage-path.js +35 -0
- package/dist/sidecar/supervisor.d.ts +218 -0
- package/dist/sidecar/supervisor.js +932 -0
- package/dist/sidecar/types.d.ts +601 -0
- package/dist/sidecar/types.js +1 -0
- package/dist/single-instance.d.ts +11 -0
- package/dist/single-instance.js +21 -0
- package/dist/storage/empty-chats.d.ts +9 -0
- package/dist/storage/empty-chats.js +16 -0
- package/dist/storage/index.d.ts +373 -0
- package/dist/storage/index.js +1158 -0
- package/dist/storage/insights-redact.d.ts +2 -0
- package/dist/storage/insights-redact.js +30 -0
- package/dist/storage/insights-settings.d.ts +53 -0
- package/dist/storage/insights-settings.js +92 -0
- package/dist/storage/llm-settings.d.ts +120 -0
- package/dist/storage/llm-settings.js +233 -0
- package/dist/storage/local-store-singleton.d.ts +28 -0
- package/dist/storage/local-store-singleton.js +38 -0
- package/dist/storage/message-order.d.ts +25 -0
- package/dist/storage/message-order.js +27 -0
- package/dist/storage/pack-migrations.d.ts +22 -0
- package/dist/storage/pack-migrations.js +24 -0
- package/dist/storage/pack-seeds.d.ts +36 -0
- package/dist/storage/pack-seeds.js +42 -0
- package/dist/storage/telemetry-settings.d.ts +38 -0
- package/dist/storage/telemetry-settings.js +59 -0
- package/dist/storage/usage-summary.d.ts +55 -0
- package/dist/storage/usage-summary.js +38 -0
- package/dist/storage/web-search-settings.d.ts +38 -0
- package/dist/storage/web-search-settings.js +74 -0
- package/dist/storage/write-lease.d.ts +26 -0
- package/dist/storage/write-lease.js +74 -0
- package/dist/terminal-manager.d.ts +83 -0
- package/dist/terminal-manager.js +506 -0
- package/dist/tool-router.d.ts +228 -0
- package/dist/tool-router.js +930 -0
- package/dist/tool-search-rank.d.ts +42 -0
- package/dist/tool-search-rank.js +96 -0
- package/package.json +67 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import log from 'electron-log';
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
import { adaptCommandForPowerShell } from './shell-adapt.js';
|
|
7
|
+
const DEFAULT_COLS = 100;
|
|
8
|
+
const DEFAULT_ROWS = 30;
|
|
9
|
+
const SENTINEL_PREFIX = '__DP_END_';
|
|
10
|
+
const MAX_EXEC_OUTPUT_BYTES = 256 * 1024; // 256 KiB cap per command capture
|
|
11
|
+
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
|
12
|
+
// Grace window for the sentinel after a timeout-triggered SIGINT — the
|
|
13
|
+
// recovery probe runs as soon as the shell returns to a prompt, so this
|
|
14
|
+
// only needs to cover shell scheduling, not real work.
|
|
15
|
+
const KILL_GRACE_MS = 3_000;
|
|
16
|
+
// Let the SIGINT echo settle before typing the recovery probe line.
|
|
17
|
+
const KILL_PROBE_DELAY_MS = 400;
|
|
18
|
+
const REPLAY_BUFFER_BYTES = 64 * 1024; // per-session ring buffer for late subscribers
|
|
19
|
+
// Build the sentinel regex against raw PTY bytes. We wrap the agent's command
|
|
20
|
+
// in a `printf` that emits an **OSC 9999** private control sequence carrying
|
|
21
|
+
// our nonce + exit code. Conformant terminal emulators (xterm.js included)
|
|
22
|
+
// silently discard unknown OSC commands, so the user never sees this marker
|
|
23
|
+
// rendered in the visible terminal — but our capture parser sees the raw
|
|
24
|
+
// bytes before any ANSI processing, so it still finds the exit code.
|
|
25
|
+
//
|
|
26
|
+
// ESC ] 9999 ; __DP_END_<nonce>__:<exitCode> BEL
|
|
27
|
+
// \u001b]9999;__DP_END_<n>__:0\u0007
|
|
28
|
+
function buildSentinelRegex(nonce) {
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
return new RegExp(`\u001b\\]9999;${SENTINEL_PREFIX}${nonce}__:(-?\\d+)\u0007`);
|
|
31
|
+
}
|
|
32
|
+
// ANSI escape sequence stripper. Covers CSI (\x1b[...), OSC (\x1b]...\x07 or \x1b\\),
|
|
33
|
+
// SS2/SS3 (\x1bN /\x1bO), and bare control sequences. This is the de-facto regex
|
|
34
|
+
// used by `strip-ansi` — inlined to avoid pulling another dep into the main process.
|
|
35
|
+
// eslint-disable-next-line no-control-regex
|
|
36
|
+
const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
37
|
+
function stripAnsi(input) {
|
|
38
|
+
return input.replace(ANSI_RE, '');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Owns interactive PTY sessions and provides two complementary APIs:
|
|
42
|
+
*
|
|
43
|
+
* 1. **Streaming**: spawn a PTY, push raw bytes to subscribers (xterm UI),
|
|
44
|
+
* accept user input via `write()`. Standard interactive terminal model.
|
|
45
|
+
*
|
|
46
|
+
* 2. **Programmatic exec**: `exec(id, command)` writes a command to the PTY
|
|
47
|
+
* wrapped with a unique end-of-command sentinel, captures the output
|
|
48
|
+
* between write and sentinel, and resolves with stdout + exit code.
|
|
49
|
+
* This is what the agent uses when it wants to "drive" the user's
|
|
50
|
+
* visible terminal — the user sees the command being typed and the
|
|
51
|
+
* output streaming live, while the agent gets a structured result back.
|
|
52
|
+
*
|
|
53
|
+
* Concurrency note: only one `exec()` may be in flight per session. Callers
|
|
54
|
+
* must serialize their requests; this class throws if exec is invoked while
|
|
55
|
+
* another exec is still pending on the same session.
|
|
56
|
+
*/
|
|
57
|
+
export class TerminalManager extends EventEmitter {
|
|
58
|
+
sessions = new Map();
|
|
59
|
+
spawn(options = {}) {
|
|
60
|
+
const id = randomUUID();
|
|
61
|
+
const shell = options.shell || this.defaultShell();
|
|
62
|
+
const cwd = options.cwd || os.homedir();
|
|
63
|
+
const cols = options.cols || DEFAULT_COLS;
|
|
64
|
+
const rows = options.rows || DEFAULT_ROWS;
|
|
65
|
+
const env = {
|
|
66
|
+
...process.env,
|
|
67
|
+
...(options.env || {}),
|
|
68
|
+
// Keep the prompt simple so the sentinel-based capture is robust.
|
|
69
|
+
// Users can override in their dotfiles; only TERM/LANG are forced.
|
|
70
|
+
TERM: 'xterm-256color',
|
|
71
|
+
LANG: process.env.LANG || 'en_US.UTF-8',
|
|
72
|
+
DEEPPATH_AGENT_PTY: '1',
|
|
73
|
+
};
|
|
74
|
+
// cmd.exe needs delayed expansion (`/v:on`) so `exec()` can read a
|
|
75
|
+
// freshly-set `!ERRORLEVEL!` for a command joined on the same line via
|
|
76
|
+
// `&` — see the comment in `exec()`. `%ERRORLEVEL%` would otherwise be
|
|
77
|
+
// substituted once when the whole compound line is parsed (i.e. before
|
|
78
|
+
// the preceding command has even run), so it always reads the *previous*
|
|
79
|
+
// command's exit code instead of the one we just ran.
|
|
80
|
+
const args = shell.toLowerCase().includes('cmd.exe') ? ['/v:on'] : [];
|
|
81
|
+
const proc = pty.spawn(shell, args, {
|
|
82
|
+
name: 'xterm-256color',
|
|
83
|
+
cols,
|
|
84
|
+
rows,
|
|
85
|
+
cwd,
|
|
86
|
+
env,
|
|
87
|
+
});
|
|
88
|
+
const entry = {
|
|
89
|
+
id,
|
|
90
|
+
proc,
|
|
91
|
+
shell,
|
|
92
|
+
cwd,
|
|
93
|
+
cols,
|
|
94
|
+
rows,
|
|
95
|
+
capture: null,
|
|
96
|
+
replay: '',
|
|
97
|
+
};
|
|
98
|
+
this.sessions.set(id, entry);
|
|
99
|
+
proc.onData((chunk) => {
|
|
100
|
+
this.emit('data', id, chunk);
|
|
101
|
+
// Keep a small replay buffer so a terminal window opened *after* output
|
|
102
|
+
// has already started can show what was missed.
|
|
103
|
+
entry.replay += chunk;
|
|
104
|
+
if (entry.replay.length > REPLAY_BUFFER_BYTES) {
|
|
105
|
+
entry.replay = entry.replay.slice(-REPLAY_BUFFER_BYTES);
|
|
106
|
+
}
|
|
107
|
+
const cap = entry.capture;
|
|
108
|
+
if (!cap)
|
|
109
|
+
return;
|
|
110
|
+
cap.buffer += chunk;
|
|
111
|
+
if (cap.buffer.length > MAX_EXEC_OUTPUT_BYTES) {
|
|
112
|
+
// Keep the tail (where the sentinel will land) and remember that we
|
|
113
|
+
// dropped bytes — `body.length` after this can never exceed the cap,
|
|
114
|
+
// so callers must rely on this flag rather than re-deriving it from
|
|
115
|
+
// the final output length.
|
|
116
|
+
cap.buffer = cap.buffer.slice(-MAX_EXEC_OUTPUT_BYTES);
|
|
117
|
+
cap.truncated = true;
|
|
118
|
+
}
|
|
119
|
+
// Match the OSC sentinel (invisible to the user, present in raw bytes).
|
|
120
|
+
// The shell-echoed command source contains the literal text
|
|
121
|
+
// printf '\033]9999;__DP_END_<n>__:%s\007' "$?"
|
|
122
|
+
// where `\033` is 4 ASCII chars (backslash + 0 + 3 + 3), NOT an ESC
|
|
123
|
+
// byte. So that echo can never accidentally satisfy this regex — only
|
|
124
|
+
// the printf *output* contains a real ESC byte.
|
|
125
|
+
const sentinelRe = buildSentinelRegex(cap.nonce);
|
|
126
|
+
const m = sentinelRe.exec(cap.buffer);
|
|
127
|
+
if (m && m.index !== undefined) {
|
|
128
|
+
// Keep everything before the OSC, plus a synthetic textual marker
|
|
129
|
+
// that exec() can parse without re-doing OSC matching. The OSC bytes
|
|
130
|
+
// themselves are dropped — they're invisible UI noise.
|
|
131
|
+
const before = cap.buffer.slice(0, m.index);
|
|
132
|
+
const captured = `${before}\n${SENTINEL_PREFIX}${cap.nonce}__:${m[1]}__`;
|
|
133
|
+
entry.capture = null;
|
|
134
|
+
// A shell that continues the wrapper's `; printf` list after the
|
|
135
|
+
// timeout SIGINT (bash) resolves through this normal sentinel path
|
|
136
|
+
// rather than the recovery probe — it is still an interruption.
|
|
137
|
+
cap.resolve({
|
|
138
|
+
text: captured,
|
|
139
|
+
truncated: cap.truncated,
|
|
140
|
+
interrupted: cap.recoveryNonce ? true : undefined,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Recovery probe after a timeout SIGINT: its sentinel means the shell
|
|
145
|
+
// is back at a prompt. Resolve the interrupted exec with exit 130.
|
|
146
|
+
if (cap.recoveryNonce) {
|
|
147
|
+
const recoveryRe = buildSentinelRegex(cap.recoveryNonce);
|
|
148
|
+
const rm = recoveryRe.exec(cap.buffer);
|
|
149
|
+
if (rm && rm.index !== undefined) {
|
|
150
|
+
const before = cap.buffer.slice(0, rm.index);
|
|
151
|
+
const captured = `${before}\n${SENTINEL_PREFIX}${cap.nonce}__:130__`;
|
|
152
|
+
entry.capture = null;
|
|
153
|
+
cap.resolve({ text: captured, truncated: cap.truncated, interrupted: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
158
|
+
this.emit('exit', id, exitCode, typeof signal === 'number' ? String(signal) : null);
|
|
159
|
+
// A pending exec() has nothing left to capture from — fail it right
|
|
160
|
+
// away instead of leaving it to hang until its own timeout fires.
|
|
161
|
+
if (entry.capture) {
|
|
162
|
+
const pending = entry.capture;
|
|
163
|
+
entry.capture = null;
|
|
164
|
+
pending.reject(new Error(`terminal session ${id} exited (code=${exitCode}) while a command was still running`));
|
|
165
|
+
}
|
|
166
|
+
this.sessions.delete(id);
|
|
167
|
+
});
|
|
168
|
+
// PowerShell:预定义哨兵函数,让 exec() 只需在命令尾追加 `; __dpe '<marker>'`
|
|
169
|
+
// (~30 字符)。旧实现把整段 Write-Host 包装内联在命令行尾部,整行轻松超过
|
|
170
|
+
// 终端列宽(100),ConPTY 会把回显折行甚至用光标重绘合并——下游的回显剥离
|
|
171
|
+
// 因此时而残留回显碎片、时而把真实输出一起吞掉(flaky:"expected '' to
|
|
172
|
+
// contain 'hi'")。函数体与旧内联逻辑一致:$? 在函数入口处仍持有上一条
|
|
173
|
+
// 命令的执行状态,映射成 0/1 后以 OSC 9999 私有序列发出。
|
|
174
|
+
if (shell.toLowerCase().includes('powershell') || shell.toLowerCase().includes('pwsh')) {
|
|
175
|
+
proc.write(`function __dpe([string]$n){ $c=if($?){0}else{1}; Write-Host -NoNewline (([char]27)+']9999;'+$n+':'+$c+([char]7)) }\r`);
|
|
176
|
+
}
|
|
177
|
+
const session = { id, shell, pid: proc.pid, cwd, cols, rows };
|
|
178
|
+
this.emit('spawned', session);
|
|
179
|
+
log.info('[terminal] spawned', { id, shell, pid: proc.pid, cwd });
|
|
180
|
+
return session;
|
|
181
|
+
}
|
|
182
|
+
list() {
|
|
183
|
+
return Array.from(this.sessions.values()).map(e => ({
|
|
184
|
+
id: e.id,
|
|
185
|
+
shell: e.shell,
|
|
186
|
+
pid: e.proc.pid,
|
|
187
|
+
cwd: e.cwd,
|
|
188
|
+
cols: e.cols,
|
|
189
|
+
rows: e.rows,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
primarySession() {
|
|
193
|
+
const first = this.sessions.values().next().value;
|
|
194
|
+
if (!first)
|
|
195
|
+
return null;
|
|
196
|
+
return {
|
|
197
|
+
id: first.id,
|
|
198
|
+
shell: first.shell,
|
|
199
|
+
pid: first.proc.pid,
|
|
200
|
+
cwd: first.cwd,
|
|
201
|
+
cols: first.cols,
|
|
202
|
+
rows: first.rows,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
ensurePrimary(options = {}) {
|
|
206
|
+
return this.primarySession() || this.spawn(options);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Snapshot of recent PTY output, used to "catch up" a renderer that
|
|
210
|
+
* subscribed after the session started producing output.
|
|
211
|
+
*/
|
|
212
|
+
getReplayBuffer(id) {
|
|
213
|
+
return this.sessions.get(id)?.replay ?? '';
|
|
214
|
+
}
|
|
215
|
+
write(id, data) {
|
|
216
|
+
const entry = this.sessions.get(id);
|
|
217
|
+
if (!entry)
|
|
218
|
+
return false;
|
|
219
|
+
entry.proc.write(data);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
resize(id, cols, rows) {
|
|
223
|
+
const entry = this.sessions.get(id);
|
|
224
|
+
if (!entry)
|
|
225
|
+
return false;
|
|
226
|
+
if (cols > 0 && rows > 0) {
|
|
227
|
+
try {
|
|
228
|
+
entry.proc.resize(cols, rows);
|
|
229
|
+
entry.cols = cols;
|
|
230
|
+
entry.rows = rows;
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
log.warn('[terminal] resize failed', { id, cols, rows, err: String(err) });
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
kill(id) {
|
|
241
|
+
const entry = this.sessions.get(id);
|
|
242
|
+
if (!entry)
|
|
243
|
+
return false;
|
|
244
|
+
try {
|
|
245
|
+
entry.proc.kill();
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
log.warn('[terminal] kill failed', { id, err: String(err) });
|
|
249
|
+
}
|
|
250
|
+
this.sessions.delete(id);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
killAll() {
|
|
254
|
+
for (const id of Array.from(this.sessions.keys()))
|
|
255
|
+
this.kill(id);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Execute a single command in the visible PTY and wait for it to finish.
|
|
259
|
+
* The user sees the command typed and output streaming live. The returned
|
|
260
|
+
* result is parsed from the captured stream by stripping the sentinel.
|
|
261
|
+
*
|
|
262
|
+
* Caller must ensure no other exec() is pending on the same session.
|
|
263
|
+
*
|
|
264
|
+
* `killOnTimeout`: on timeout, send SIGINT (Ctrl-C) to the PTY instead of
|
|
265
|
+
* just giving up on the capture. A timed-out command otherwise keeps
|
|
266
|
+
* running in the shared visible terminal — every subsequent exec jams
|
|
267
|
+
* behind it (typed into the busy terminal, never reaching a shell prompt)
|
|
268
|
+
* and times out in cascade. The wrapper's `; printf <sentinel>` survives
|
|
269
|
+
* the interrupt (SIGINT kills the foreground pipeline, not the shell's
|
|
270
|
+
* command list), so after Ctrl-C we keep waiting briefly for the sentinel
|
|
271
|
+
* and resolve with the interrupted exit code (130) instead of rejecting.
|
|
272
|
+
* GUI-launch commands pass false: their timeout means "launched, still
|
|
273
|
+
* running", and killing would terminate the app the user asked for.
|
|
274
|
+
*/
|
|
275
|
+
async exec(id, command, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS, killOnTimeout = false) {
|
|
276
|
+
const entry = this.sessions.get(id);
|
|
277
|
+
if (!entry) {
|
|
278
|
+
return {
|
|
279
|
+
success: false,
|
|
280
|
+
exitCode: -1,
|
|
281
|
+
stdout: '',
|
|
282
|
+
stderr: `terminal session ${id} not found`,
|
|
283
|
+
truncated: false,
|
|
284
|
+
durationMs: 0,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (entry.capture) {
|
|
288
|
+
throw new Error(`terminal ${id} is busy with another exec`);
|
|
289
|
+
}
|
|
290
|
+
let trimmed = command.trim();
|
|
291
|
+
if (!trimmed) {
|
|
292
|
+
return { success: true, exitCode: 0, stdout: '', stderr: '', truncated: false, durationMs: 0 };
|
|
293
|
+
}
|
|
294
|
+
// LLM 混写 cmd / bash 方言(&&、||、%VAR%)时,PowerShell 5.1 会直接报
|
|
295
|
+
// 解析错误。可见终端默认就是 powershell.exe,这里与 LocalExecutor 用同一套
|
|
296
|
+
// 确定性转换,保证两条执行路径行为一致。
|
|
297
|
+
if (/powershell|pwsh/i.test(entry.shell)) {
|
|
298
|
+
const adapted = adaptCommandForPowerShell(trimmed);
|
|
299
|
+
if (adapted !== trimmed) {
|
|
300
|
+
log.info('[terminal] adapted command for PowerShell', {
|
|
301
|
+
before: trimmed.slice(0, 200),
|
|
302
|
+
after: adapted.slice(0, 200),
|
|
303
|
+
});
|
|
304
|
+
trimmed = adapted;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Short-ish nonce keeps the echoed command line readable. 12 hex chars
|
|
308
|
+
// = 48 bits of entropy, more than enough for "no two pending execs in
|
|
309
|
+
// the same session" (which is already serialized).
|
|
310
|
+
const nonce = randomUUID().replace(/-/g, '').slice(0, 12);
|
|
311
|
+
const marker = `${SENTINEL_PREFIX}${nonce}__`;
|
|
312
|
+
// Wrap the command so we always get an exit code marker even if it fails.
|
|
313
|
+
// The marker is emitted as an OSC 999 private control sequence:
|
|
314
|
+
// ESC ] 9999 ; __DP_END_<nonce>__:<exitCode> BEL
|
|
315
|
+
// We construct the wrapped command depending on the shell to support cross-platform (PowerShell, CMD, Bash/Zsh).
|
|
316
|
+
const shellLower = entry.shell.toLowerCase();
|
|
317
|
+
let wrapped = '';
|
|
318
|
+
const ESC = '\u001b';
|
|
319
|
+
const BEL = '\u0007';
|
|
320
|
+
if (shellLower.includes('powershell') || shellLower.includes('pwsh')) {
|
|
321
|
+
// 哨兵通过 spawn() 时预定义的 __dpe 函数发出(OSC 9999,PowerShell 5.1
|
|
322
|
+
// 兼容,$? 对 cmdlet 也生效并映射为 0/1)。只追加 ~30 字符,避免整行
|
|
323
|
+
// 超过终端列宽被 ConPTY 折行/重绘,破坏下游的回显剥离。
|
|
324
|
+
wrapped = `${trimmed}; __dpe '${marker}'\r`;
|
|
325
|
+
}
|
|
326
|
+
else if (shellLower.includes('cmd.exe')) {
|
|
327
|
+
// `%ERRORLEVEL%` on a `&`-joined line is expanded once when the whole
|
|
328
|
+
// line is parsed, *before* `trimmed` has even run, so it always reports
|
|
329
|
+
// the previous command's exit code. `!ERRORLEVEL!` (delayed expansion,
|
|
330
|
+
// enabled at spawn time via `/v:on` — see `spawn()`) is instead
|
|
331
|
+
// expanded at execution time of this specific token, after `trimmed`
|
|
332
|
+
// has finished, which is what we actually want here.
|
|
333
|
+
wrapped = `${trimmed} & node -e "process.stdout.write('\\x1b]9999;${marker}:' + process.argv[1] + '\\x07\\n')\" !ERRORLEVEL!\r`;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
wrapped = `${trimmed}; printf '\\033]9999;${marker}:%s\\007\\n' "$?"\r`;
|
|
337
|
+
}
|
|
338
|
+
const start = Date.now();
|
|
339
|
+
const { text: captured, truncated: capTruncated, interrupted } = await new Promise((resolve, reject) => {
|
|
340
|
+
entry.capture = { buffer: '', nonce, truncated: false, resolve, reject };
|
|
341
|
+
let killGraceTimer = null;
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
if (!entry.capture || entry.capture.nonce !== nonce)
|
|
344
|
+
return;
|
|
345
|
+
if (killOnTimeout) {
|
|
346
|
+
// SIGINT the foreground pipeline. bash would continue the wrapper's
|
|
347
|
+
// `; printf <sentinel>` list; zsh aborts the whole list, so arm a
|
|
348
|
+
// recovery probe: a bare sentinel line the shell runs at the fresh
|
|
349
|
+
// prompt, proving the terminal is usable again.
|
|
350
|
+
const recoveryNonce = `R${nonce}`;
|
|
351
|
+
entry.capture.recoveryNonce = recoveryNonce;
|
|
352
|
+
entry.proc.write('\x03');
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
if (entry.capture && entry.capture.nonce === nonce) {
|
|
355
|
+
entry.proc.write(`printf '\\033]9999;${SENTINEL_PREFIX}${recoveryNonce}__:0\\007\\n'\r`);
|
|
356
|
+
}
|
|
357
|
+
}, KILL_PROBE_DELAY_MS);
|
|
358
|
+
killGraceTimer = setTimeout(() => {
|
|
359
|
+
if (entry.capture && entry.capture.nonce === nonce) {
|
|
360
|
+
entry.capture = null;
|
|
361
|
+
reject(new Error(`exec timeout after ${timeoutMs}ms: ${trimmed.slice(0, 80)}`));
|
|
362
|
+
}
|
|
363
|
+
}, KILL_GRACE_MS);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
entry.capture = null;
|
|
367
|
+
reject(new Error(`exec timeout after ${timeoutMs}ms: ${trimmed.slice(0, 80)}`));
|
|
368
|
+
}, timeoutMs);
|
|
369
|
+
// Once the inner promise settles, clear the timer.
|
|
370
|
+
const originalResolve = entry.capture.resolve;
|
|
371
|
+
entry.capture.resolve = (out) => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
if (killGraceTimer)
|
|
374
|
+
clearTimeout(killGraceTimer);
|
|
375
|
+
originalResolve(out);
|
|
376
|
+
};
|
|
377
|
+
const originalReject = entry.capture.reject;
|
|
378
|
+
entry.capture.reject = (err) => {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
if (killGraceTimer)
|
|
381
|
+
clearTimeout(killGraceTimer);
|
|
382
|
+
originalReject(err);
|
|
383
|
+
};
|
|
384
|
+
entry.proc.write(wrapped);
|
|
385
|
+
});
|
|
386
|
+
const durationMs = Date.now() - start;
|
|
387
|
+
// Parse the captured stream:
|
|
388
|
+
// <prompt>$ <wrapped-command>\r\n<output>...\n__DP_END_<nonce>__:<exitCode>__
|
|
389
|
+
// The OSC sentinel that the shell actually wrote to the PTY has already
|
|
390
|
+
// been intercepted in `onData` and replaced with this synthetic textual
|
|
391
|
+
// marker, so plain regex matching is enough here.
|
|
392
|
+
//
|
|
393
|
+
// PTY output is full of ANSI/cursor escapes (prompt themes, ls colors,
|
|
394
|
+
// bracketed-paste markers) plus carriage returns. We need to clean it up
|
|
395
|
+
// before handing back to the agent — it'll be displayed as plain text.
|
|
396
|
+
const cleaned = stripAnsi(captured)
|
|
397
|
+
// node-pty often emits \r\n; agents/UI expect \n.
|
|
398
|
+
.replace(/\r\n/g, '\n')
|
|
399
|
+
// Bare \r (cursor-to-col-1) is meaningless in our line-based output.
|
|
400
|
+
.replace(/\r/g, '')
|
|
401
|
+
// Bracketed-paste markers some shells inject around input.
|
|
402
|
+
.replace(/\u001B\[\?2004[lh]/g, '');
|
|
403
|
+
// Match the synthetic textual marker that `onData` injected after the
|
|
404
|
+
// real OSC sentinel was found in the raw stream. Anchor on a preceding
|
|
405
|
+
// LF (or start-of-buffer) so we don't accidentally match the echoed
|
|
406
|
+
// command source — the echo contains `__DP_END_<nonce>__:%s\007` (with
|
|
407
|
+
// a literal `%s`), which can never satisfy `:(-?\\d+)__` anyway, but
|
|
408
|
+
// anchoring is a cheap robustness win.
|
|
409
|
+
const sentinelRegex = new RegExp(`(?:^|\\n)(${SENTINEL_PREFIX}${nonce}__:(-?\\d+)__)`);
|
|
410
|
+
const m = cleaned.match(sentinelRegex);
|
|
411
|
+
const exitCode = m ? Number.parseInt(m[2], 10) : -1;
|
|
412
|
+
// Cut everything after (and including) the sentinel — m.index points at
|
|
413
|
+
// the leading LF (or 0). Use the captured group's position so we strip
|
|
414
|
+
// exactly the sentinel and keep the preceding output.
|
|
415
|
+
let body = m
|
|
416
|
+
? cleaned.slice(0, cleaned.indexOf(m[1]))
|
|
417
|
+
: cleaned;
|
|
418
|
+
// Strip the echoed wrapped command line. We wrote
|
|
419
|
+
// `<cmd>; printf '\033]9999;__DP_END_<nonce>__:%s\007\n' "$?"`
|
|
420
|
+
// and the shell echoes that whole thing back as literal characters
|
|
421
|
+
// (`\033` is 4 ASCII chars in the echo, not a real ESC). Find the
|
|
422
|
+
// unique nonce token in the echo and drop everything up to and
|
|
423
|
+
// including its trailing newline.
|
|
424
|
+
const echoToken = `${SENTINEL_PREFIX}${nonce}`;
|
|
425
|
+
const echoEnd = body.indexOf(echoToken);
|
|
426
|
+
if (echoEnd !== -1) {
|
|
427
|
+
const nl = body.indexOf('\n', echoEnd);
|
|
428
|
+
if (nl !== -1) {
|
|
429
|
+
body = body.slice(nl + 1);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// ConPTY 有时用光标重绘代替真实换行,ANSI 剥离后回显尾部和真实输出
|
|
433
|
+
// 会粘在同一"行"里。旧实现直接置空,把真实输出一起吞掉(flaky 回归:
|
|
434
|
+
// "expected '' to contain 'hi'")。这里改为:跳过 marker 后再剥掉各
|
|
435
|
+
// shell 已知的回显收尾片段,保留其后的内容;剥不掉就原样保留——
|
|
436
|
+
// 带一点回显噪音远好于凭空丢失输出。
|
|
437
|
+
let tail = body.slice(echoEnd + echoToken.length);
|
|
438
|
+
const knownEchoTails = [
|
|
439
|
+
/^__'[ \t]*\n?/, // PowerShell: __dpe '<marker>'
|
|
440
|
+
/^__:' \+ process\.argv\[1\] \+ '[^\n]*?!ERRORLEVEL![ \t]*\n?/, // cmd.exe
|
|
441
|
+
/^__:%s[^\n]*?"\$\?"[ \t]*\n?/, // POSIX printf
|
|
442
|
+
];
|
|
443
|
+
for (const re of knownEchoTails) {
|
|
444
|
+
const next = tail.replace(re, '');
|
|
445
|
+
if (next !== tail) {
|
|
446
|
+
tail = next;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
body = tail;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
// Fallback: the echo got line-wrapped or the sentinel name didn't appear
|
|
455
|
+
// in echo. Try matching the user's command on the first line(s).
|
|
456
|
+
const firstNl = body.indexOf('\n');
|
|
457
|
+
if (firstNl !== -1 && body.slice(0, firstNl).includes(trimmed)) {
|
|
458
|
+
body = body.slice(firstNl + 1);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Trim the trailing blank line that printf added before the sentinel.
|
|
462
|
+
body = body.replace(/\n+$/, '');
|
|
463
|
+
if (interrupted) {
|
|
464
|
+
// Drop the recovery probe's echoed command line and the ^C artifact so
|
|
465
|
+
// the agent only sees the interrupted command's own output.
|
|
466
|
+
body = body
|
|
467
|
+
.split('\n')
|
|
468
|
+
.filter((line) => !line.includes(`${SENTINEL_PREFIX}R${nonce}`))
|
|
469
|
+
.join('\n')
|
|
470
|
+
.replace(/\^C\s*$/, '')
|
|
471
|
+
.replace(/\n+$/, '');
|
|
472
|
+
}
|
|
473
|
+
// `body.length` can never exceed the cap by construction (see `onData`),
|
|
474
|
+
// so we must rely on the flag raised while capturing rather than
|
|
475
|
+
// re-deriving truncation from the final (already-capped) length.
|
|
476
|
+
const truncated = capTruncated;
|
|
477
|
+
return {
|
|
478
|
+
success: exitCode === 0,
|
|
479
|
+
exitCode,
|
|
480
|
+
stdout: body,
|
|
481
|
+
stderr: interrupted
|
|
482
|
+
? `[command exceeded ${timeoutMs}ms and was interrupted with SIGINT; the terminal is free again — re-run with a larger \`timeout\` if it needs longer]`
|
|
483
|
+
: '',
|
|
484
|
+
truncated,
|
|
485
|
+
durationMs,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
defaultShell() {
|
|
489
|
+
if (process.platform === 'win32') {
|
|
490
|
+
// Default to PowerShell so the visible terminal matches the headless
|
|
491
|
+
// executor (LocalExecutor also defaults to powershell.exe on Windows).
|
|
492
|
+
// COMSPEC points at cmd.exe on a standard Windows install, which would
|
|
493
|
+
// make agent-driven commands run under a different dialect than what the
|
|
494
|
+
// skill prompt assumes — the mismatch is what made the agent issue
|
|
495
|
+
// PowerShell cmdlets that silently failed under cmd. `cmd.exe` is still
|
|
496
|
+
// explicitly selectable via TerminalSpawnOptions.shell.
|
|
497
|
+
return 'powershell.exe';
|
|
498
|
+
}
|
|
499
|
+
const fromEnv = process.env.SHELL;
|
|
500
|
+
if (fromEnv?.endsWith('/zsh'))
|
|
501
|
+
return 'zsh';
|
|
502
|
+
if (fromEnv?.endsWith('/bash'))
|
|
503
|
+
return 'bash';
|
|
504
|
+
return 'zsh';
|
|
505
|
+
}
|
|
506
|
+
}
|