@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.
Files changed (204) hide show
  1. package/LICENSE +91 -0
  2. package/contracts/tool-contract.json +326 -0
  3. package/dist/attachments.d.ts +41 -0
  4. package/dist/attachments.js +147 -0
  5. package/dist/brand.d.ts +24 -0
  6. package/dist/brand.js +92 -0
  7. package/dist/host/http-routes.d.ts +21 -0
  8. package/dist/host/http-routes.js +55 -0
  9. package/dist/host/ipc.d.ts +11 -0
  10. package/dist/host/ipc.js +20 -0
  11. package/dist/host/pack-assembly.d.ts +86 -0
  12. package/dist/host/pack-assembly.js +32 -0
  13. package/dist/host/runtime.d.ts +69 -0
  14. package/dist/host/runtime.js +207 -0
  15. package/dist/host/visible-terminal-exec.d.ts +21 -0
  16. package/dist/host/visible-terminal-exec.js +151 -0
  17. package/dist/hosted-web-search.d.ts +13 -0
  18. package/dist/hosted-web-search.js +82 -0
  19. package/dist/image-attachment.d.ts +39 -0
  20. package/dist/image-attachment.js +133 -0
  21. package/dist/insights/flush.d.ts +10 -0
  22. package/dist/insights/flush.js +139 -0
  23. package/dist/insights/record.d.ts +17 -0
  24. package/dist/insights/record.js +44 -0
  25. package/dist/json-store.d.ts +9 -0
  26. package/dist/json-store.js +22 -0
  27. package/dist/llm/index.d.ts +23 -0
  28. package/dist/llm/index.js +105 -0
  29. package/dist/llm/ollama.d.ts +23 -0
  30. package/dist/llm/ollama.js +242 -0
  31. package/dist/llm/openai-compat.d.ts +20 -0
  32. package/dist/llm/openai-compat.js +199 -0
  33. package/dist/llm/sidecar-provider.d.ts +37 -0
  34. package/dist/llm/sidecar-provider.js +163 -0
  35. package/dist/llm/tool-choice.d.ts +35 -0
  36. package/dist/llm/tool-choice.js +85 -0
  37. package/dist/llm/types.d.ts +122 -0
  38. package/dist/llm/types.js +1 -0
  39. package/dist/local-backend/agent-capability.d.ts +101 -0
  40. package/dist/local-backend/agent-capability.js +174 -0
  41. package/dist/local-backend/ai-title.d.ts +43 -0
  42. package/dist/local-backend/ai-title.js +173 -0
  43. package/dist/local-backend/auto-continue-helper.d.ts +80 -0
  44. package/dist/local-backend/auto-continue-helper.js +83 -0
  45. package/dist/local-backend/branch-helper.d.ts +24 -0
  46. package/dist/local-backend/branch-helper.js +27 -0
  47. package/dist/local-backend/context-compactor.d.ts +81 -0
  48. package/dist/local-backend/context-compactor.js +213 -0
  49. package/dist/local-backend/coreloop-stream.d.ts +245 -0
  50. package/dist/local-backend/coreloop-stream.js +277 -0
  51. package/dist/local-backend/deferred-detector.d.ts +15 -0
  52. package/dist/local-backend/deferred-detector.js +124 -0
  53. package/dist/local-backend/history-helper.d.ts +30 -0
  54. package/dist/local-backend/history-helper.js +34 -0
  55. package/dist/local-backend/interrupted-helper.d.ts +29 -0
  56. package/dist/local-backend/interrupted-helper.js +25 -0
  57. package/dist/local-backend/live-stream.d.ts +36 -0
  58. package/dist/local-backend/live-stream.js +23 -0
  59. package/dist/local-backend/llm-diagnose.d.ts +37 -0
  60. package/dist/local-backend/llm-diagnose.js +284 -0
  61. package/dist/local-backend/message-triggers.d.ts +27 -0
  62. package/dist/local-backend/message-triggers.js +67 -0
  63. package/dist/local-backend/pack-backend-routes.d.ts +31 -0
  64. package/dist/local-backend/pack-backend-routes.js +62 -0
  65. package/dist/local-backend/pack-turn-hooks.d.ts +37 -0
  66. package/dist/local-backend/pack-turn-hooks.js +67 -0
  67. package/dist/local-backend/prompt-builder.d.ts +101 -0
  68. package/dist/local-backend/prompt-builder.js +246 -0
  69. package/dist/local-backend/regenerate-helper.d.ts +62 -0
  70. package/dist/local-backend/regenerate-helper.js +75 -0
  71. package/dist/local-backend/router.d.ts +175 -0
  72. package/dist/local-backend/router.js +3139 -0
  73. package/dist/local-backend/skill-install.d.ts +19 -0
  74. package/dist/local-backend/skill-install.js +71 -0
  75. package/dist/local-backend/skill-loader.d.ts +92 -0
  76. package/dist/local-backend/skill-loader.js +146 -0
  77. package/dist/local-backend/skills/00-identity/SKILL.md +32 -0
  78. package/dist/local-backend/skills/10-goal/SKILL.md +59 -0
  79. package/dist/local-backend/skills/11-loop/SKILL.md +71 -0
  80. package/dist/local-backend/skills/12-create-skill/SKILL.md +88 -0
  81. package/dist/local-backend/skills/70-plan-mode/SKILL.md +58 -0
  82. package/dist/local-backend/skills/80-tool-usage/SKILL.md +70 -0
  83. package/dist/local-backend/skills/81-anti-deferred/SKILL.md +53 -0
  84. package/dist/local-backend/skills/82-data-grounding/SKILL.md +56 -0
  85. package/dist/local-backend/skills/85-local-exec/SKILL.md +86 -0
  86. package/dist/local-backend/skills/86-proactive-coding/SKILL.md +51 -0
  87. package/dist/local-backend/subagent-profiles.d.ts +30 -0
  88. package/dist/local-backend/subagent-profiles.js +74 -0
  89. package/dist/local-backend/task-process.d.ts +12 -0
  90. package/dist/local-backend/task-process.js +176 -0
  91. package/dist/local-backend/task-service.d.ts +135 -0
  92. package/dist/local-backend/task-service.js +565 -0
  93. package/dist/local-backend/turn-duration.d.ts +2 -0
  94. package/dist/local-backend/turn-duration.js +9 -0
  95. package/dist/local-backend/turn-timeline.d.ts +16 -0
  96. package/dist/local-backend/turn-timeline.js +42 -0
  97. package/dist/local-backend/worktree-service.d.ts +84 -0
  98. package/dist/local-backend/worktree-service.js +243 -0
  99. package/dist/local-edit.d.ts +48 -0
  100. package/dist/local-edit.js +44 -0
  101. package/dist/local-executor.d.ts +255 -0
  102. package/dist/local-executor.js +881 -0
  103. package/dist/local-script-registry.d.ts +28 -0
  104. package/dist/local-script-registry.js +63 -0
  105. package/dist/log.d.ts +13 -0
  106. package/dist/log.js +12 -0
  107. package/dist/main.d.ts +1 -0
  108. package/dist/main.js +855 -0
  109. package/dist/mcp-executor.d.ts +45 -0
  110. package/dist/mcp-executor.js +241 -0
  111. package/dist/mcp-server-registry.d.ts +104 -0
  112. package/dist/mcp-server-registry.js +234 -0
  113. package/dist/preload-default.d.ts +1 -0
  114. package/dist/preload-default.js +9 -0
  115. package/dist/preload.cjs +395 -0
  116. package/dist/preload.d.ts +20 -0
  117. package/dist/preload.js +411 -0
  118. package/dist/product-config.d.ts +43 -0
  119. package/dist/product-config.js +26 -0
  120. package/dist/project-registry.d.ts +55 -0
  121. package/dist/project-registry.js +106 -0
  122. package/dist/project-rules.d.ts +15 -0
  123. package/dist/project-rules.js +102 -0
  124. package/dist/runtime.d.ts +62 -0
  125. package/dist/runtime.js +217 -0
  126. package/dist/scenario/pack.d.ts +8 -0
  127. package/dist/scenario/pack.js +1 -0
  128. package/dist/scenario/registry.d.ts +24 -0
  129. package/dist/scenario/registry.js +31 -0
  130. package/dist/server/http-server.d.ts +39 -0
  131. package/dist/server/http-server.js +361 -0
  132. package/dist/server/index.d.ts +1 -0
  133. package/dist/server/index.js +107 -0
  134. package/dist/server/sse-bus.d.ts +14 -0
  135. package/dist/server/sse-bus.js +31 -0
  136. package/dist/shell-adapt.d.ts +21 -0
  137. package/dist/shell-adapt.js +104 -0
  138. package/dist/sidecar/boot.d.ts +36 -0
  139. package/dist/sidecar/boot.js +343 -0
  140. package/dist/sidecar/egress-hint.d.ts +15 -0
  141. package/dist/sidecar/egress-hint.js +46 -0
  142. package/dist/sidecar/egress-proxy.d.ts +183 -0
  143. package/dist/sidecar/egress-proxy.js +419 -0
  144. package/dist/sidecar/errors.d.ts +22 -0
  145. package/dist/sidecar/errors.js +38 -0
  146. package/dist/sidecar/exec-sandbox.d.ts +48 -0
  147. package/dist/sidecar/exec-sandbox.js +94 -0
  148. package/dist/sidecar/handle.d.ts +32 -0
  149. package/dist/sidecar/handle.js +53 -0
  150. package/dist/sidecar/index.d.ts +14 -0
  151. package/dist/sidecar/index.js +13 -0
  152. package/dist/sidecar/proxy-detect.d.ts +50 -0
  153. package/dist/sidecar/proxy-detect.js +182 -0
  154. package/dist/sidecar/reverse-approval.d.ts +55 -0
  155. package/dist/sidecar/reverse-approval.js +86 -0
  156. package/dist/sidecar/reverse-ask-user.d.ts +34 -0
  157. package/dist/sidecar/reverse-ask-user.js +59 -0
  158. package/dist/sidecar/reverse-spawn.d.ts +19 -0
  159. package/dist/sidecar/reverse-spawn.js +161 -0
  160. package/dist/sidecar/reverse-tools.d.ts +29 -0
  161. package/dist/sidecar/reverse-tools.js +106 -0
  162. package/dist/sidecar/safety-patterns.d.ts +41 -0
  163. package/dist/sidecar/safety-patterns.js +157 -0
  164. package/dist/sidecar/storage-path.d.ts +14 -0
  165. package/dist/sidecar/storage-path.js +35 -0
  166. package/dist/sidecar/supervisor.d.ts +218 -0
  167. package/dist/sidecar/supervisor.js +932 -0
  168. package/dist/sidecar/types.d.ts +601 -0
  169. package/dist/sidecar/types.js +1 -0
  170. package/dist/single-instance.d.ts +11 -0
  171. package/dist/single-instance.js +21 -0
  172. package/dist/storage/empty-chats.d.ts +9 -0
  173. package/dist/storage/empty-chats.js +16 -0
  174. package/dist/storage/index.d.ts +373 -0
  175. package/dist/storage/index.js +1158 -0
  176. package/dist/storage/insights-redact.d.ts +2 -0
  177. package/dist/storage/insights-redact.js +30 -0
  178. package/dist/storage/insights-settings.d.ts +53 -0
  179. package/dist/storage/insights-settings.js +92 -0
  180. package/dist/storage/llm-settings.d.ts +120 -0
  181. package/dist/storage/llm-settings.js +233 -0
  182. package/dist/storage/local-store-singleton.d.ts +28 -0
  183. package/dist/storage/local-store-singleton.js +38 -0
  184. package/dist/storage/message-order.d.ts +25 -0
  185. package/dist/storage/message-order.js +27 -0
  186. package/dist/storage/pack-migrations.d.ts +22 -0
  187. package/dist/storage/pack-migrations.js +24 -0
  188. package/dist/storage/pack-seeds.d.ts +36 -0
  189. package/dist/storage/pack-seeds.js +42 -0
  190. package/dist/storage/telemetry-settings.d.ts +38 -0
  191. package/dist/storage/telemetry-settings.js +59 -0
  192. package/dist/storage/usage-summary.d.ts +55 -0
  193. package/dist/storage/usage-summary.js +38 -0
  194. package/dist/storage/web-search-settings.d.ts +38 -0
  195. package/dist/storage/web-search-settings.js +74 -0
  196. package/dist/storage/write-lease.d.ts +26 -0
  197. package/dist/storage/write-lease.js +74 -0
  198. package/dist/terminal-manager.d.ts +83 -0
  199. package/dist/terminal-manager.js +506 -0
  200. package/dist/tool-router.d.ts +228 -0
  201. package/dist/tool-router.js +930 -0
  202. package/dist/tool-search-rank.d.ts +42 -0
  203. package/dist/tool-search-rank.js +96 -0
  204. 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
+ }