@epoch-agent/plugin-terminal 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1002 @@
1
+ // src/index.ts
2
+ import { isAbsolute, resolve } from "path";
3
+ import {
4
+ checkDangerousCommand,
5
+ isInWorkspace,
6
+ sandboxModeForLevel
7
+ } from "@epoch-agent/infra";
8
+
9
+ // src/background.ts
10
+ import {
11
+ artifactsDir,
12
+ killTrackedProcess,
13
+ openArtifact,
14
+ shellSpawnArgs,
15
+ startLongLivedProcess
16
+ } from "@epoch-agent/infra";
17
+
18
+ // src/sandbox.ts
19
+ import {
20
+ classifyFailure,
21
+ confine
22
+ } from "@epoch-agent/infra";
23
+ function unconfined(skipped) {
24
+ return { mode: null, backend: "none", enforcement: null, skipped, failure: null };
25
+ }
26
+ function confineShell(sh, policy, cwd) {
27
+ const bare = (outcome2) => ({
28
+ ...sh,
29
+ outcome: outcome2,
30
+ classify: () => outcome2
31
+ });
32
+ if (!policy) {
33
+ return bare(unconfined("not-requested"));
34
+ }
35
+ const result = confine(sh.file, sh.args, { ...policy, ...cwd ? { cwd } : {} });
36
+ if (!result.confined) {
37
+ return bare({
38
+ mode: result.mode,
39
+ backend: result.backend,
40
+ enforcement: null,
41
+ skipped: result.reason,
42
+ failure: null
43
+ });
44
+ }
45
+ const outcome = {
46
+ mode: result.mode,
47
+ backend: result.backend,
48
+ enforcement: result.enforcement,
49
+ skipped: null,
50
+ failure: null
51
+ };
52
+ return {
53
+ file: result.command,
54
+ args: result.args,
55
+ outcome,
56
+ classify: (exitCode, stderr) => ({
57
+ ...outcome,
58
+ failure: classifyFailure({
59
+ exitCode,
60
+ stderr,
61
+ denialSignatures: result.denialSignatures,
62
+ runnerFailureRules: result.runnerFailureRules
63
+ })
64
+ })
65
+ };
66
+ }
67
+
68
+ // src/background.ts
69
+ var MAX_TASKS = 8;
70
+ var MAX_TASK_OUTPUT = 256 * 1024;
71
+ var MAX_OUTPUT_CHUNK = 8 * 1024;
72
+ var bySession = /* @__PURE__ */ new Map();
73
+ var counter = 0;
74
+ function nextId() {
75
+ counter += 1;
76
+ return `t${counter}`;
77
+ }
78
+ function bucketOf(sessionId) {
79
+ const existing = bySession.get(sessionId);
80
+ if (existing) return existing;
81
+ const fresh = /* @__PURE__ */ new Map();
82
+ bySession.set(sessionId, fresh);
83
+ return fresh;
84
+ }
85
+ function taskIn(sessionId, id) {
86
+ return bySession.get(sessionId)?.get(id);
87
+ }
88
+ function startTask(sessionId, command, cwd, homeDir, sandbox) {
89
+ const bucket = bucketOf(sessionId);
90
+ const running = [...bucket.values()].filter((t) => t.status === "running");
91
+ if (running.length >= MAX_TASKS) {
92
+ const list = running.map((t) => `${t.id} (${t.command})`).join("\u3001");
93
+ return {
94
+ error: `\u540E\u53F0\u4EFB\u52A1\u5DF2\u8FBE\u4E0A\u9650 ${MAX_TASKS} \u4E2A\uFF0C\u5148\u7528 task_stop \u505C\u6389\u4E00\u4E2A\u3002\u5728\u8DD1\u7684\uFF1A${list}`
95
+ };
96
+ }
97
+ const id = nextId();
98
+ let finish;
99
+ const done2 = new Promise((resolve2) => {
100
+ finish = resolve2;
101
+ });
102
+ const raw = shellSpawnArgs(command);
103
+ const sh = confineShell({ file: raw.file, args: raw.args }, sandbox, cwd);
104
+ const task = {
105
+ id,
106
+ command,
107
+ ...cwd ? { cwd } : {},
108
+ pid: 0,
109
+ status: "running",
110
+ startedAt: Date.now(),
111
+ buffer: "",
112
+ totalBytes: 0,
113
+ droppedBytes: 0,
114
+ artifactsDir: artifactsDir(sessionId, homeDir),
115
+ sandbox: sh.outcome,
116
+ done: done2,
117
+ finish
118
+ };
119
+ bucket.set(id, task);
120
+ const started = startLongLivedProcess({
121
+ file: sh.file,
122
+ args: sh.args,
123
+ ...cwd ? { cwd } : {},
124
+ ...raw.options ? { spawnOptions: raw.options } : {},
125
+ onOutput: (chunk) => append(task, chunk),
126
+ onExit: (code) => {
127
+ if (task.status === "running") {
128
+ task.status = code === null ? "failed" : "exited";
129
+ if (code !== null) task.exitCode = code;
130
+ }
131
+ task.endedAt = Date.now();
132
+ if (task.status === "exited" && code !== null && code !== 0) {
133
+ task.sandbox = sh.classify(code, task.buffer);
134
+ }
135
+ task.spill?.close();
136
+ task.finish();
137
+ }
138
+ });
139
+ task.pid = started.pid;
140
+ return { task: toInfo(task), sandbox: sh.outcome };
141
+ }
142
+ function taskSandbox(sessionId, id) {
143
+ return taskIn(sessionId, id)?.sandbox;
144
+ }
145
+ function append(task, chunk) {
146
+ task.totalBytes += chunk.length;
147
+ task.buffer += chunk;
148
+ if (task.buffer.length <= MAX_TASK_OUTPUT) return;
149
+ if (task.spill === void 0) {
150
+ task.spill = openArtifact(task.artifactsDir, `task-${task.id}`, ".txt") ?? null;
151
+ task.spill?.append(task.buffer);
152
+ } else {
153
+ task.spill?.append(chunk);
154
+ }
155
+ const drop = task.buffer.length - MAX_TASK_OUTPUT;
156
+ task.buffer = task.buffer.slice(drop);
157
+ task.droppedBytes += drop;
158
+ }
159
+ function listTasks(sessionId) {
160
+ const bucket = bySession.get(sessionId);
161
+ return bucket ? [...bucket.values()].map(toInfo) : [];
162
+ }
163
+ function taskOutput(sessionId, id, since = 0) {
164
+ const task = taskIn(sessionId, id);
165
+ if (!task) return void 0;
166
+ const from = Math.max(since, task.droppedBytes);
167
+ const missed = Math.max(0, task.droppedBytes - since);
168
+ const slice = task.buffer.slice(from - task.droppedBytes);
169
+ const output = slice.slice(0, MAX_OUTPUT_CHUNK);
170
+ return {
171
+ info: toInfo(task),
172
+ output,
173
+ nextCursor: from + output.length,
174
+ missed,
175
+ hasMore: output.length < slice.length
176
+ };
177
+ }
178
+ async function stopTask(sessionId, id) {
179
+ const task = taskIn(sessionId, id);
180
+ if (!task || task.status !== "running") return false;
181
+ task.status = "killed";
182
+ task.endedAt = Date.now();
183
+ await killTrackedProcess(task.pid);
184
+ task.spill?.close();
185
+ task.finish();
186
+ return true;
187
+ }
188
+ async function waitTask(sessionId, id, timeoutMs) {
189
+ const task = taskIn(sessionId, id);
190
+ if (!task) return void 0;
191
+ if (task.status !== "running") return { info: toInfo(task), timedOut: false };
192
+ let timer;
193
+ const timeout = new Promise((resolve2) => {
194
+ timer = setTimeout(() => resolve2("timeout"), timeoutMs);
195
+ timer.unref?.();
196
+ });
197
+ try {
198
+ const winner = await Promise.race([task.done.then(() => "done"), timeout]);
199
+ return { info: toInfo(task), timedOut: winner === "timeout" };
200
+ } finally {
201
+ if (timer) clearTimeout(timer);
202
+ }
203
+ }
204
+ function rekeyTasks(from, to) {
205
+ if (from === to) return;
206
+ const moving = bySession.get(from);
207
+ if (!moving) return;
208
+ bySession.delete(from);
209
+ const target = bySession.get(to);
210
+ if (!target) {
211
+ bySession.set(to, moving);
212
+ return;
213
+ }
214
+ for (const [id, task] of moving) target.set(id, task);
215
+ }
216
+ function clearAllTasks() {
217
+ for (const bucket of bySession.values()) {
218
+ for (const task of bucket.values()) task.spill?.close();
219
+ }
220
+ bySession.clear();
221
+ counter = 0;
222
+ }
223
+ function toInfo(task) {
224
+ return {
225
+ id: task.id,
226
+ command: task.command,
227
+ ...task.cwd ? { cwd: task.cwd } : {},
228
+ pid: task.pid,
229
+ status: task.status,
230
+ ...task.exitCode === void 0 ? {} : { exitCode: task.exitCode },
231
+ startedAt: task.startedAt,
232
+ ...task.endedAt === void 0 ? {} : { endedAt: task.endedAt },
233
+ outputBytes: task.totalBytes,
234
+ truncated: task.droppedBytes > 0,
235
+ ...task.spill ? { artifact: task.spill.path } : {}
236
+ };
237
+ }
238
+ function describeTask(info) {
239
+ const dur = Math.round(((info.endedAt ?? Date.now()) - info.startedAt) / 1e3);
240
+ const state = info.status === "running" ? `\u8FD0\u884C\u4E2D ${dur}s` : info.status === "exited" ? `\u5DF2\u7ED3\u675F\uFF0C\u9000\u51FA\u7801 ${info.exitCode}` : info.status === "killed" ? "\u5DF2\u505C\u6B62" : "\u542F\u52A8\u5931\u8D25";
241
+ const trunc = info.truncated ? "\uFF0C\u5F00\u5934\u5DF2\u6EDA\u51FA\u7F13\u51B2" : "";
242
+ return `${info.id} ${state}\uFF08${info.outputBytes} \u5B57\u8282\u8F93\u51FA${trunc}\uFF09 ${info.command}`;
243
+ }
244
+
245
+ // src/exec.ts
246
+ import { spawn } from "child_process";
247
+ import {
248
+ createStreamDecoder,
249
+ killAllTrackedProcesses,
250
+ killProcessTree as killProcessTree2,
251
+ shellSpawnArgs as shellSpawnArgs2
252
+ } from "@epoch-agent/infra";
253
+
254
+ // src/limits.ts
255
+ var MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
256
+ var OVER_CAPTURE_NOTICE = `
257
+ [\u8F93\u51FA\u8D85\u8FC7 ${MAX_CAPTURE_BYTES / 1024 / 1024} MB\uFF0C\u5DF2\u622A\u65AD\u5E76\u7EC8\u6B62\u8FDB\u7A0B\uFF1A\u8FD9\u4E0D\u662F\u4E00\u6B21\u6B63\u5E38\u7684\u547D\u4EE4\u8F93\u51FA\uFF08\u591A\u534A\u662F cat \u4E86\u4E8C\u8FDB\u5236\u6587\u4EF6\u6216\u8FDB\u4E86\u6B7B\u5FAA\u73AF\uFF09\u3002\u8BF7\u7F29\u5C0F\u8F93\u51FA\u8303\u56F4\u91CD\u8BD5\uFF0C\u6BD4\u5982\u52A0 head / tail / grep\u3002]`;
258
+ var NO_OUTPUT = "(\u65E0\u8F93\u51FA)";
259
+
260
+ // src/pty.ts
261
+ import { spawn as spawnPty } from "node-pty";
262
+ import { IS_WINDOWS, killProcessTree, shellPtyArgs } from "@epoch-agent/infra";
263
+
264
+ // src/pty-helper.ts
265
+ import { accessSync, chmodSync, constants, existsSync } from "fs";
266
+ import { createRequire } from "module";
267
+ import { arch, platform } from "os";
268
+ import { dirname, join } from "path";
269
+ function helperCandidates() {
270
+ let packageDir;
271
+ try {
272
+ packageDir = dirname(dirname(createRequire(import.meta.url).resolve("node-pty")));
273
+ } catch {
274
+ return [];
275
+ }
276
+ return [
277
+ join(packageDir, "build", "Release", "spawn-helper"),
278
+ join(packageDir, "prebuilds", `${platform()}-${arch()}`, "spawn-helper")
279
+ ];
280
+ }
281
+ var failure;
282
+ var done = false;
283
+ function ensurePtyHelperExecutable() {
284
+ if (done || platform() === "win32") return;
285
+ done = true;
286
+ for (const candidate of helperCandidates()) {
287
+ if (!existsSync(candidate)) continue;
288
+ try {
289
+ accessSync(candidate, constants.X_OK);
290
+ } catch {
291
+ try {
292
+ chmodSync(candidate, 493);
293
+ } catch (err) {
294
+ failure = `${candidate}: ${err instanceof Error ? err.message : String(err)}`;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ function ptyHelperHint() {
300
+ if (failure === void 0) return "";
301
+ return `
302
+ node-pty \u7684 spawn-helper \u6CA1\u6709\u6267\u884C\u6743\u9650\uFF0C\u4E14\u81EA\u52A8\u4FEE\u590D\u5931\u8D25\uFF08${failure}\uFF09\u3002
303
+ \u624B\u52A8\u4FEE\uFF1Achmod +x <\u4E0A\u9762\u8FD9\u4E2A\u8DEF\u5F84>`;
304
+ }
305
+
306
+ // src/pty.ts
307
+ var PTY_IS_GROUP_LEADER = !IS_WINDOWS;
308
+ var ptySessions = /* @__PURE__ */ new Map();
309
+ function killPtyTree(pty) {
310
+ void killProcessTree({ pid: pty.pid, detached: PTY_IS_GROUP_LEADER, escalate: true, pty });
311
+ }
312
+ function confinePty(raw, policy, cwd) {
313
+ if (!Array.isArray(raw.args)) {
314
+ const outcome = unconfined(policy ? "no-backend" : "not-requested");
315
+ return { ...raw, outcome, classify: () => outcome };
316
+ }
317
+ const sh = confineShell({ file: raw.file, args: raw.args }, policy, cwd);
318
+ return { file: sh.file, args: sh.args, outcome: sh.outcome, classify: sh.classify };
319
+ }
320
+ function runCommandPty(command, options) {
321
+ return new Promise((resolve2) => {
322
+ const previous = ptySessions.get(options.tag);
323
+ if (previous) {
324
+ killPtyTree(previous);
325
+ ptySessions.delete(options.tag);
326
+ }
327
+ const raw = shellPtyArgs(command);
328
+ const sh = confinePty(raw, options.sandbox, options.cwd);
329
+ let pty;
330
+ try {
331
+ ensurePtyHelperExecutable();
332
+ pty = spawnPty(sh.file, sh.args, {
333
+ name: "xterm-256color",
334
+ cwd: options.cwd ?? process.cwd(),
335
+ env: { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" },
336
+ cols: 120,
337
+ rows: 40
338
+ });
339
+ } catch (err) {
340
+ resolve2({
341
+ stdout: `PTY \u542F\u52A8\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}${ptyHelperHint()}`,
342
+ exitCode: 1,
343
+ sandbox: sh.outcome
344
+ });
345
+ return;
346
+ }
347
+ ptySessions.set(options.tag, pty);
348
+ let output = "";
349
+ let truncated = false;
350
+ let settled = false;
351
+ const releaseSlot = () => {
352
+ if (ptySessions.get(options.tag) === pty) ptySessions.delete(options.tag);
353
+ };
354
+ const finish = (result) => {
355
+ if (settled) return;
356
+ settled = true;
357
+ clearTimeout(timer);
358
+ clearInterval(abortCheck);
359
+ releaseSlot();
360
+ resolve2(result);
361
+ };
362
+ const timer = setTimeout(() => {
363
+ killPtyTree(pty);
364
+ finish({ stdout: output.trimEnd() || "(\u8D85\u65F6\uFF0C\u65E0\u8F93\u51FA)", exitCode: -1, sandbox: sh.outcome });
365
+ }, options.timeout);
366
+ const abortCheck = setInterval(() => {
367
+ if (!options.signal?.aborted) return;
368
+ killPtyTree(pty);
369
+ finish({ stdout: output.trimEnd(), exitCode: -1, aborted: true, sandbox: sh.outcome });
370
+ }, 100);
371
+ if (options.stdin) {
372
+ setTimeout(() => {
373
+ try {
374
+ pty.write((options.stdin ?? "") + "\n");
375
+ } catch {
376
+ }
377
+ }, 200);
378
+ }
379
+ pty.onData((data) => {
380
+ if (truncated) return;
381
+ output += data;
382
+ options.onOutput?.(data);
383
+ if (output.length > MAX_CAPTURE_BYTES) {
384
+ truncated = true;
385
+ output = output.slice(0, MAX_CAPTURE_BYTES) + OVER_CAPTURE_NOTICE;
386
+ killPtyTree(pty);
387
+ }
388
+ });
389
+ pty.onExit(({ exitCode }) => {
390
+ finish({
391
+ stdout: output.trimEnd() || NO_OUTPUT,
392
+ exitCode,
393
+ // PTY 只有一股流,**没有独立的 stderr** —— 而拒绝方言和 runner
394
+ // 致命行都是 runner 往 stderr 打的。这里把整股流当 stderr 喂进去是
395
+ // 对的:那两组正则都锚在行首的后端自称上(`sandbox-exec:`),
396
+ // 混进来的 stdout 不会把它们撞出假阳性
397
+ sandbox: sh.classify(exitCode, output)
398
+ });
399
+ });
400
+ });
401
+ }
402
+ function killAllPtySessions() {
403
+ const pending = [];
404
+ for (const [tag, pty] of ptySessions) {
405
+ pending.push(
406
+ killProcessTree({ pid: pty.pid, detached: PTY_IS_GROUP_LEADER, escalate: true, pty })
407
+ );
408
+ ptySessions.delete(tag);
409
+ }
410
+ return pending;
411
+ }
412
+
413
+ // src/exec.ts
414
+ var FORCE_SETTLE_MS = 3e3;
415
+ function killChildTree(child) {
416
+ void killProcessTree2({
417
+ pid: child.pid ?? 0,
418
+ detached: false,
419
+ escalate: true,
420
+ isExited: () => child.exitCode !== null || child.signalCode !== null
421
+ });
422
+ }
423
+ function collectStreams(child, onOverflow, onOutput) {
424
+ let stdout = "";
425
+ let stderr = "";
426
+ let truncated = false;
427
+ const outDecoder = createStreamDecoder();
428
+ const errDecoder = createStreamDecoder();
429
+ child.stdout?.on("data", (chunk) => {
430
+ if (truncated) return;
431
+ const piece = outDecoder.write(chunk);
432
+ if (piece) {
433
+ stdout += piece;
434
+ onOutput?.(piece);
435
+ }
436
+ if (stdout.length > MAX_CAPTURE_BYTES) {
437
+ truncated = true;
438
+ stdout = stdout.slice(0, MAX_CAPTURE_BYTES);
439
+ onOverflow();
440
+ }
441
+ });
442
+ child.stderr?.on("data", (chunk) => {
443
+ if (stderr.length >= MAX_CAPTURE_BYTES) return;
444
+ const piece = errDecoder.write(chunk);
445
+ if (!piece) return;
446
+ stderr += piece;
447
+ onOutput?.(piece);
448
+ });
449
+ return () => {
450
+ if (!truncated) stdout += outDecoder.end();
451
+ if (stderr.length < MAX_CAPTURE_BYTES) stderr += errDecoder.end();
452
+ const tail = truncated ? OVER_CAPTURE_NOTICE : "";
453
+ return { stdout: (stdout + tail).trimEnd(), stderr: stderr.trimEnd() };
454
+ };
455
+ }
456
+ function runCommand(command, options) {
457
+ return new Promise((resolve2, reject) => {
458
+ const raw = shellSpawnArgs2(command);
459
+ const sh = confineShell({ file: raw.file, args: raw.args }, options.sandbox, options.cwd);
460
+ const child = spawn(sh.file, sh.args, {
461
+ stdio: ["ignore", "pipe", "pipe"],
462
+ cwd: options.cwd,
463
+ ...raw.options
464
+ });
465
+ let killedBy = null;
466
+ let settled = false;
467
+ let forceTimer;
468
+ const drain = collectStreams(child, () => kill("overflow"), options.onOutput);
469
+ const timer = setTimeout(() => kill("timeout"), options.timeout);
470
+ const abortCheck = setInterval(() => {
471
+ if (options.signal?.aborted) kill("abort");
472
+ }, 100);
473
+ function clearTimers() {
474
+ clearTimeout(timer);
475
+ clearTimeout(forceTimer);
476
+ clearInterval(abortCheck);
477
+ }
478
+ function finish() {
479
+ if (settled) return;
480
+ settled = true;
481
+ clearTimers();
482
+ const { stdout, stderr } = drain();
483
+ const exitCode = child.exitCode ?? -1;
484
+ resolve2({
485
+ stdout,
486
+ stderr,
487
+ exitCode,
488
+ ...killedBy === "timeout" ? { timedOut: true } : {},
489
+ ...killedBy === "abort" ? { aborted: true } : {},
490
+ // 分类只在**我们没有主动杀它**的时候做:超时 / 中断的非零退出是我们
491
+ // 自己造成的,往分类器里送只会得到一句「命令自己失败了」的废话,
492
+ // 而调用方那边已经有 `timedOut` / `aborted` 两个更准确的字段
493
+ sandbox: killedBy ? sh.outcome : sh.classify(exitCode, stderr)
494
+ });
495
+ }
496
+ function kill(why) {
497
+ if (killedBy) return;
498
+ killedBy = why;
499
+ killChildTree(child);
500
+ forceTimer = setTimeout(finish, FORCE_SETTLE_MS);
501
+ }
502
+ if (options.signal?.aborted) kill("abort");
503
+ child.on("close", finish);
504
+ child.on("error", (err) => {
505
+ if (settled) return;
506
+ settled = true;
507
+ clearTimers();
508
+ reject(err);
509
+ });
510
+ });
511
+ }
512
+ async function cleanupBackgroundProcesses() {
513
+ await Promise.all([killAllTrackedProcesses(), ...killAllPtySessions()]);
514
+ }
515
+
516
+ // src/sandbox-notice.ts
517
+ var MODE_BOUNDARY = {
518
+ "read-only": "\u53EA\u8BFB\uFF1A\u5199\u4EFB\u4F55\u6587\u4EF6\u90FD\u4F1A\u88AB OS \u6321\u4E0B",
519
+ "workspace-write": "\u5199\u5165\u9650\u4E8E\u5DE5\u4F5C\u533A\u3001\u4E34\u65F6\u76EE\u5F55\u548C\u5DE5\u5177\u94FE\u7F13\u5B58\uFF1B\u8BFB\u53D6\u548C\u7F51\u7EDC\u4E0D\u53D7\u9650",
520
+ "danger-full-access": "\u4E0D\u8BBE\u9650"
521
+ };
522
+ function sandboxNotice(sb) {
523
+ if (sb.skipped === "not-requested") return "";
524
+ if (sb.skipped === "mode-disabled") {
525
+ return "[\u6C99\u7BB1: \u5173] \u5F53\u524D\u6743\u9650\u6863\u4F4D\u662F\u300C\u5141\u8BB8\u4E00\u5207\u300D\uFF0C\u672C\u6B21\u6CA1\u6709 OS \u7EA7\u9694\u79BB\u3002\u5371\u9669\u547D\u4EE4\u8868\u4ECD\u7136\u5728\u5C97\u3002";
526
+ }
527
+ if (sb.skipped === "no-backend") {
528
+ return `[\u6C99\u7BB1: \u65E0] \u672C\u6B21\u6CA1\u6709 OS \u7EA7\u9694\u79BB\u2014\u2014\u8FD9\u4E2A\u5E73\u53F0\uFF08${sb.backend === "none" ? process.platform : sb.backend}\uFF09\u4E0A\u63A2\u6D4B\u4E0D\u5230\u53EF\u7528\u540E\u7AEF\uFF0C\u547D\u4EE4\u80FD\u8BFB\u5199\u6574\u4E2A\u6587\u4EF6\u7CFB\u7EDF\u3002`;
529
+ }
530
+ const boundary = MODE_BOUNDARY[sb.mode ?? ""] ?? "";
531
+ if (sb.enforcement === "partial") {
532
+ return `[\u6C99\u7BB1: ${sb.backend} \xB7 ${sb.mode} \xB7 \u5F3A\u5236\u4E0D\u5B8C\u6574] ${boundary}\u3002\u4E0D\u5B8C\u6574\u4E4B\u5904\uFF1APOSIX shell \u5FC5\u987B\u7559\u7740 /dev/null \u8FD9\u4E2A\u5199\u5165\u53E3\uFF0C\u5426\u5219\u5B83\u81EA\u5DF1\u90FD\u8D77\u4E0D\u6765\u3002`;
533
+ }
534
+ return `[\u6C99\u7BB1: ${sb.backend} \xB7 ${sb.mode}] ${boundary}\u3002`;
535
+ }
536
+ function sandboxError(sb) {
537
+ const failure2 = sb.failure;
538
+ if (!failure2) return null;
539
+ if (failure2.kind === "runner-failed") {
540
+ return {
541
+ message: `\u6C99\u7BB1\u542F\u52A8\u5931\u8D25\uFF0C\u547D\u4EE4\u6CA1\u6709\u6267\u884C\uFF1A${failure2.evidence ?? "(\u6C99\u7BB1\u672A\u7ED9\u51FA\u539F\u56E0)"}`,
542
+ suggestion: "\u8FD9\u4E0D\u662F\u547D\u4EE4\u672C\u8EAB\u7684\u95EE\u9898\uFF0C\u522B\u6539\u547D\u4EE4\u3002\u628A\u8FD9\u6761\u539F\u6587\u62A5\u7ED9\u7528\u6237\uFF0C\u6216\u6362\u5230\u300C\u5141\u8BB8\u4E00\u5207\u300D\u6863\u4F4D\u7ED5\u5F00\u6C99\u7BB1\u3002"
543
+ };
544
+ }
545
+ if (failure2.kind === "sandbox-denied") {
546
+ return {
547
+ message: `\u547D\u4EE4\u88AB\u6C99\u7BB1\u6321\u4E0B\uFF1A${failure2.evidence ?? "(\u6C99\u7BB1\u672A\u7ED9\u51FA\u539F\u56E0)"}`,
548
+ suggestion: sb.mode === "read-only" ? "\u5F53\u524D\u662F\u53EA\u8BFB\u6863\u4F4D\uFF0C\u4EFB\u4F55\u5199\u5165\u90FD\u4F1A\u88AB\u6321\u3002\u8981\u771F\u7684\u6539\u4E1C\u897F\uFF0C\u5148\u8BF7\u7528\u6237\u5207\u6362\u6743\u9650\u6863\u4F4D\u3002" : "\u6C99\u7BB1\u53EA\u653E\u5F00\u5DE5\u4F5C\u533A\u3001\u4E34\u65F6\u76EE\u5F55\u548C\u5DE5\u5177\u94FE\u7F13\u5B58\u3002\u8981\u5199\u5230\u522B\u5904\uFF0C\u5148\u8BF7\u7528\u6237\u786E\u8BA4\u3002"
549
+ };
550
+ }
551
+ return null;
552
+ }
553
+
554
+ // src/spill.ts
555
+ import { artifactsDir as artifactsDir2, headCodePoints, tailCodePoints, writeArtifact } from "@epoch-agent/infra";
556
+ var PREVIEW_HEAD = 2e3;
557
+ var PREVIEW_TAIL = 2e3;
558
+ var SPILL_THRESHOLD = 5e4;
559
+ function formatBytes(text) {
560
+ return `${(Buffer.byteLength(text, "utf-8") / 1024).toFixed(1)} KB`;
561
+ }
562
+ function retrievalHint(locator) {
563
+ return `
564
+
565
+ \u5B8C\u6574\u8F93\u51FA\u5DF2\u4FDD\u5B58\uFF1A${locator}
566
+ \u53D6\u56DE\u65B9\u5F0F\uFF1A\u7528 file_search \u5728\u8FD9\u4E2A\u8DEF\u5F84\u91CC\u641C\u4F60\u8981\u7684\u5185\u5BB9\uFF08\u6BD4\u5982\u62A5\u9519\u5173\u952E\u5B57\uFF09\uFF0C\u6216\u7528 file_read \u5206\u9875\u8BFB\u3002`;
567
+ }
568
+ var NOT_STORED = "\n\n\uFF08\u8FD9\u6B21\u6CA1\u80FD\u4FDD\u5B58\u5B8C\u6574\u8F93\u51FA\uFF0C\u88AB\u7701\u7565\u7684\u90E8\u5206\u5DF2\u7ECF\u6CA1\u6709\u4E86\uFF09";
569
+ function spillOutput(input) {
570
+ const { full, sessionId, homeDir, label, mode } = input;
571
+ if (full.length <= SPILL_THRESHOLD) return { text: full };
572
+ const locator = sessionId ? writeArtifact(artifactsDir2(sessionId, homeDir), label, ".txt", full, "unique") : void 0;
573
+ const head = mode === "tail" ? "" : headCodePoints(full, PREVIEW_HEAD);
574
+ const tail = tailCodePoints(full, PREVIEW_TAIL);
575
+ const skipped = full.slice(head.length, full.length - tail.length);
576
+ if (skipped.length === 0) return { text: full, ...locator ? { locator } : {} };
577
+ const body = `[\u8F93\u51FA ${formatBytes(full)}\uFF0C\u4E0B\u9762\u53EA\u662F\u9884\u89C8]
578
+ ${head ? `${head}
579
+ ` : ""}\u2026[\u7701\u7565 ${formatBytes(skipped)}]\u2026
580
+ ${tail}`;
581
+ return {
582
+ text: body + (locator ? retrievalHint(locator) : NOT_STORED),
583
+ ...locator ? { locator } : {}
584
+ };
585
+ }
586
+ function describeMissed(missed, locator) {
587
+ const where = locator ? retrievalHint(locator) : NOT_STORED;
588
+ return `\uFF08\u5F00\u5934 ${missed} \u5B57\u8282\u5DF2\u7ECF\u6EDA\u51FA\u73AF\u5F62\u7F13\u51B2\uFF09${where}`;
589
+ }
590
+
591
+ // src/tasks.ts
592
+ var DEFAULT_WAIT_MS = 3e4;
593
+ var MAX_WAIT_MS = 3e5;
594
+ function ok(output) {
595
+ return { success: true, output, duration: 0 };
596
+ }
597
+ function notFound(id) {
598
+ return {
599
+ success: false,
600
+ output: "",
601
+ error: {
602
+ code: 2101,
603
+ message: `\u6CA1\u6709\u7F16\u53F7\u4E3A ${id} \u7684\u540E\u53F0\u4EFB\u52A1`,
604
+ suggestion: "\u7528 task_list \u770B\u5F53\u524D\u6709\u54EA\u4E9B"
605
+ },
606
+ duration: 0
607
+ };
608
+ }
609
+ var taskList = {
610
+ name: "task_list",
611
+ description: "\u5217\u51FA\u672C\u6B21\u4F1A\u8BDD\u8D77\u8FC7\u7684\u540E\u53F0\u4EFB\u52A1\uFF08terminal \u7684 background: true \u8D77\u7684\uFF09\uFF0C\u542B\u72B6\u6001\u3001\u9000\u51FA\u7801\u3001\u8F93\u51FA\u5B57\u8282\u6570\u3002",
612
+ parameters: { type: "object", properties: {}, required: [] },
613
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
614
+ operation: "file_read",
615
+ execute: (_args, ctx) => {
616
+ const tasks = listTasks(ctx.sessionId);
617
+ if (tasks.length === 0) {
618
+ return Promise.resolve(ok("\u6CA1\u6709\u540E\u53F0\u4EFB\u52A1\u3002\u7528 terminal \u7684 background: true \u8D77\u4E00\u4E2A\u3002"));
619
+ }
620
+ return Promise.resolve(
621
+ ok([`${tasks.length} \u4E2A\u540E\u53F0\u4EFB\u52A1`, ...tasks.map(describeTask)].join("\n"))
622
+ );
623
+ }
624
+ };
625
+ var taskOutputTool = {
626
+ name: "task_output",
627
+ description: `\u53D6\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\u7684\u8F93\u51FA\u3002\u53C2\u6570: id(\u5FC5\u586B), since(\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF0C\u7528\u6765\u53EA\u53D6\u65B0\u589E\u90E8\u5206)\u3002\u5355\u6B21\u6700\u591A ${MAX_OUTPUT_CHUNK} \u5B57\u8282\uFF1B\u8F93\u51FA\u5F88\u957F\u65F6\u4F1A\u5206\u6B21\u53D6\uFF0C\u8FD4\u56DE\u91CC\u4F1A\u8BF4\u660E\u8FD8\u6709\u6CA1\u6709\u66F4\u591A\u3002`,
628
+ parameters: {
629
+ type: "object",
630
+ properties: {
631
+ id: { type: "string", description: "\u4EFB\u52A1 id\uFF0C\u4F8B\u5982 t1" },
632
+ since: { type: "number", description: "\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF1B\u4E0D\u7ED9\u5C31\u4ECE\u5934\u53D6" }
633
+ },
634
+ required: ["id"]
635
+ },
636
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
637
+ operation: "file_read",
638
+ describeTarget: (args) => String(args["id"] ?? ""),
639
+ execute: (args, ctx) => {
640
+ const id = String(args["id"] ?? "");
641
+ const since = Number(args["since"]);
642
+ const result = taskOutput(ctx.sessionId, id, Number.isFinite(since) && since > 0 ? since : 0);
643
+ if (!result) return Promise.resolve(notFound(id));
644
+ const head = describeTask(result.info);
645
+ const notes = [];
646
+ if (result.missed > 0) notes.push(describeMissed(result.missed, result.info.artifact));
647
+ if (result.hasMore) notes.push(`\uFF08\u8FD8\u6709\u66F4\u591A\uFF0C\u7528 since: ${result.nextCursor} \u7EE7\u7EED\u53D6\uFF09`);
648
+ const sb = taskSandbox(ctx.sessionId, id);
649
+ const sbErr = sb ? sandboxError(sb) : null;
650
+ if (sbErr) notes.push(`${sbErr.message}
651
+ ${sbErr.suggestion}`);
652
+ const body = result.output || "\uFF08\u8FD9\u4E00\u6BB5\u6CA1\u6709\u65B0\u8F93\u51FA\uFF09";
653
+ return Promise.resolve(
654
+ ok([head, ...notes, "", body, "", `nextCursor: ${result.nextCursor}`].join("\n"))
655
+ );
656
+ }
657
+ };
658
+ var taskWait = {
659
+ name: "task_wait",
660
+ description: "\u963B\u585E\u7B49\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\u7ED3\u675F\u3002\u53C2\u6570: id(\u5FC5\u586B), timeoutMs(\u9ED8\u8BA4 30000\uFF0C\u6700\u5927 300000)\u3002**\u8D85\u65F6\u4E0D\u4F1A\u6740\u5B83** \u2014\u2014 \u53EA\u662F\u544A\u8BC9\u4F60\u8FD8\u5728\u8DD1\uFF0C\u8981\u505C\u7528 task_stop\u3002",
661
+ parameters: {
662
+ type: "object",
663
+ properties: {
664
+ id: { type: "string", description: "\u4EFB\u52A1 id" },
665
+ timeoutMs: { type: "number", description: "\u6700\u591A\u7B49\u591A\u4E45\uFF08\u6BEB\u79D2\uFF09" }
666
+ },
667
+ required: ["id"]
668
+ },
669
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
670
+ operation: "file_read",
671
+ describeTarget: (args) => String(args["id"] ?? ""),
672
+ execute: async (args, ctx) => {
673
+ const id = String(args["id"] ?? "");
674
+ const requested = Number(args["timeoutMs"]);
675
+ const timeout = Math.min(
676
+ Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_MS,
677
+ MAX_WAIT_MS
678
+ );
679
+ const result = await waitTask(ctx.sessionId, id, timeout);
680
+ if (!result) return notFound(id);
681
+ const tail = result.timedOut ? `
682
+ \u7B49\u4E86 ${Math.round(timeout / 1e3)}s \u8FD8\u6CA1\u7ED3\u675F\uFF0C**\u6CA1\u6709\u505C\u5B83**\u3002\u53EF\u4EE5\u63A5\u7740\u5E72\u522B\u7684\uFF0C\u6216\u518D\u7B49\u4E00\u6B21\u3002` : "\n\u7528 task_output \u53D6\u8F93\u51FA\u3002";
683
+ return ok(describeTask(result.info) + tail);
684
+ }
685
+ };
686
+ var taskStop = {
687
+ name: "task_stop",
688
+ description: "\u505C\u6389\u4E00\u4E2A\u540E\u53F0\u4EFB\u52A1\uFF08\u6740\u6574\u68F5\u8FDB\u7A0B\u6811\uFF0C\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002\u53C2\u6570: id(\u5FC5\u586B)\u3002",
689
+ parameters: {
690
+ type: "object",
691
+ properties: { id: { type: "string", description: "\u4EFB\u52A1 id" } },
692
+ required: ["id"]
693
+ },
694
+ // 它真的会杀进程,所以**不是** readOnly
695
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
696
+ operation: "file_read",
697
+ describeTarget: (args) => String(args["id"] ?? ""),
698
+ execute: async (args, ctx) => {
699
+ const id = String(args["id"] ?? "");
700
+ const stopped = await stopTask(ctx.sessionId, id);
701
+ if (!stopped) {
702
+ const tasks = listTasks(ctx.sessionId);
703
+ const exists = tasks.some((t) => t.id === id);
704
+ return exists ? ok(`${id} \u5DF2\u7ECF\u4E0D\u5728\u8FD0\u884C\u4E2D\u4E86\u3002`) : notFound(id);
705
+ }
706
+ return ok(`${id} \u5DF2\u505C\u6B62\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002`);
707
+ }
708
+ };
709
+ var TASK_TOOLS = [taskList, taskOutputTool, taskWait, taskStop];
710
+
711
+ // src/index.ts
712
+ var DEFAULT_TIMEOUT = 6e4;
713
+ var PTY_TIMEOUT = 3e4;
714
+ var CANCELLED = "\u547D\u4EE4\u5DF2\u88AB\u53D6\u6D88";
715
+ function detectCd(command) {
716
+ const trimmed = command.trim();
717
+ const cdMatch = trimmed.match(/^cd\s+([^&|;]+)$/);
718
+ if (!cdMatch) return null;
719
+ const rawTarget = cdMatch[1];
720
+ if (!rawTarget) return null;
721
+ return rawTarget.trim().replace(/^["']|["']$/g, "");
722
+ }
723
+ function sandboxPolicyFor(ctx, workspaceRoot) {
724
+ return {
725
+ mode: sandboxModeForLevel(ctx.permissionLevel),
726
+ workspaceRoot,
727
+ ...ctx.extraRoots ? { extraRoots: ctx.extraRoots } : {},
728
+ allowNetwork: true
729
+ };
730
+ }
731
+ function toSandboxFailure(sb, output, duration) {
732
+ const err = sandboxError(sb);
733
+ if (!err) return null;
734
+ return {
735
+ success: false,
736
+ output,
737
+ error: {
738
+ // 2008 = 被沙箱挡下(命令跑了,动作被拒)
739
+ // 2009 = 沙箱自己没起来(命令**没有**跑)
740
+ code: sb.failure?.kind === "runner-failed" ? 2009 : 2008,
741
+ message: err.message,
742
+ suggestion: err.suggestion
743
+ },
744
+ duration
745
+ };
746
+ }
747
+ var CD_NOTICE = (target) => `[\u63D0\u793A] cd \u53EA\u5728\u672C\u6B21\u5B50 shell \u5185\u751F\u6548\uFF0C\u4E0B\u6B21\u8C03\u7528\u4ECD\u5728\u539F\u5DE5\u4F5C\u76EE\u5F55\u3002\u82E5\u540E\u7EED\u547D\u4EE4\u9700\u8981\u5728 ${target} \u4E0B\u6267\u884C\uFF0C\u8BF7\u4F20 workdir \u53C2\u6570\uFF0C\u6216\u5199\u6210 "cd ${target} && <\u547D\u4EE4>"\u3002`;
748
+ var terminalTool = {
749
+ name: "terminal",
750
+ description: "\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C shell \u547D\u4EE4\u3002\u652F\u6301\u540E\u53F0(background)\u3001PTY \u4EA4\u4E92(pty)\u3001cwd \u5207\u6362\u3002\u9002\u7528\u4E8E\u811A\u672C\u3001\u6784\u5EFA\u3001\u5B89\u88C5\u3001\u4EA4\u4E92\u5F0F\u547D\u4EE4\u7B49\u3002",
751
+ parameters: {
752
+ type: "object",
753
+ properties: {
754
+ command: {
755
+ type: "string",
756
+ description: "\u8981\u6267\u884C\u7684 shell \u547D\u4EE4"
757
+ },
758
+ background: {
759
+ type: "boolean",
760
+ description: "\u540E\u53F0\u6267\u884C\uFF1A\u7ACB\u5373\u8FD4\u56DE\u4EFB\u52A1 id \u4E0D\u7B49\u7ED3\u679C\uFF0C\u914D\u5408 task_output / task_wait / task_stop \u7528\u3002\u9002\u5408\u6784\u5EFA\u3001\u8DD1 watch\u3001\u8D77\u670D\u52A1\uFF08\u8D77\u670D\u52A1\u518D curl \u5B83\u8FD9\u6761\u8DEF\u53EA\u80FD\u8FD9\u4E48\u8D70\uFF09\u3002**\u6743\u9650\u5224\u5B9A\u548C\u524D\u53F0\u5B8C\u5168\u4E00\u6837**\uFF0C\u540E\u53F0\u4E0D\u662F\u7ED5\u8FC7\u786E\u8BA4\u7684\u65C1\u8DEF\u3002"
761
+ },
762
+ pty: {
763
+ type: "boolean",
764
+ description: "PTY \u6A21\u5F0F\uFF1A\u7528\u4E8E\u4EA4\u4E92\u5F0F\u547D\u4EE4\uFF08npm init\u3001git add -p \u7B49\uFF09\u3002[Hermes/Codex]"
765
+ },
766
+ stdin: {
767
+ type: "string",
768
+ description: "PTY \u6A21\u5F0F\u4E0B\u53D1\u9001\u7ED9\u547D\u4EE4\u7684\u6807\u51C6\u8F93\u5165"
769
+ },
770
+ workdir: {
771
+ type: "string",
772
+ description: "\u5DE5\u4F5C\u76EE\u5F55\uFF08\u5FC5\u987B\u5728\u5DE5\u4F5C\u533A\u5185\uFF0C\u76F8\u5BF9\u8DEF\u5F84\u6309\u5DE5\u4F5C\u533A\u6839\u89E3\u6790\uFF09"
773
+ }
774
+ },
775
+ required: ["command"]
776
+ },
777
+ supportsParallel: false,
778
+ // 任意 shell 命令:能删数据、能联网,两个方向都到顶。
779
+ // `openWorldHint: true` 同时让 core 把输出包进 `<tool_output untrusted="true">`
780
+ // —— `curl` 抓回来的东西和 web_fetch 抓的没有区别。
781
+ annotations: {
782
+ readOnlyHint: false,
783
+ destructiveHint: true,
784
+ idempotentHint: false,
785
+ openWorldHint: true
786
+ },
787
+ operation: "command",
788
+ // 审批目标就是完整命令串(五候选里的 command 也能猜到,这里写明确的)
789
+ describeTarget: (args) => String(args["command"] ?? ""),
790
+ async execute(args, ctx) {
791
+ const command = String(args["command"] ?? "");
792
+ const isBackground = Boolean(args["background"]);
793
+ const usePty = Boolean(args["pty"]);
794
+ const stdin = args["stdin"] ? String(args["stdin"]) : void 0;
795
+ const workdir = args["workdir"] ? String(args["workdir"]) : void 0;
796
+ if (!command.trim()) {
797
+ return {
798
+ success: false,
799
+ output: "",
800
+ error: { code: 2004, message: "\u547D\u4EE4\u4E0D\u80FD\u4E3A\u7A7A", suggestion: "\u8BF7\u63D0\u4F9B\u8981\u6267\u884C\u7684\u547D\u4EE4" },
801
+ duration: 0
802
+ };
803
+ }
804
+ const danger = checkDangerousCommand(command);
805
+ if (danger.dangerous) {
806
+ return {
807
+ success: false,
808
+ output: "",
809
+ error: {
810
+ code: 2003,
811
+ message: `\u5371\u9669\u64CD\u4F5C\u5DF2\u88AB\u62E6\u622A: ${danger.desc}`,
812
+ suggestion: `\u547D\u4EE4 "${command}" \u6D89\u53CA ${danger.desc}`
813
+ },
814
+ duration: 0
815
+ };
816
+ }
817
+ const baseDir = ctx.workDir || process.cwd();
818
+ let cwd = baseDir;
819
+ if (workdir) {
820
+ if (!isInWorkspace(workdir, baseDir, ctx.extraRoots)) {
821
+ return {
822
+ success: false,
823
+ output: "",
824
+ error: {
825
+ code: 2006,
826
+ message: `workdir \u8D8A\u754C: ${workdir} \u4E0D\u5728\u5DE5\u4F5C\u533A ${baseDir} \u5185`,
827
+ suggestion: "\u53EA\u80FD\u5728\u5DE5\u4F5C\u533A\u5185\u6267\u884C\u547D\u4EE4\uFF1B\u5982\u9700\u8DE8\u76EE\u5F55\u64CD\u4F5C\u8BF7\u8BF4\u660E\u7406\u7531\u7531\u7528\u6237\u786E\u8BA4"
828
+ },
829
+ duration: 0
830
+ };
831
+ }
832
+ cwd = isAbsolute(workdir) ? resolve(workdir) : resolve(baseDir, workdir);
833
+ }
834
+ if (isBackground) {
835
+ const started = startTask(
836
+ ctx.sessionId,
837
+ command,
838
+ cwd,
839
+ ctx.homeDir,
840
+ sandboxPolicyFor(ctx, baseDir)
841
+ );
842
+ if ("error" in started) {
843
+ return {
844
+ success: false,
845
+ output: "",
846
+ error: { code: 2006, message: started.error, suggestion: "\u7528 task_stop \u505C\u6389\u4E00\u4E2A\u518D\u8BD5" },
847
+ duration: 0
848
+ };
849
+ }
850
+ const bgNotice = sandboxNotice(started.sandbox);
851
+ return {
852
+ success: true,
853
+ output: [
854
+ ...bgNotice ? [bgNotice] : [],
855
+ `\u540E\u53F0\u4EFB\u52A1 ${started.task.id} \u5DF2\u542F\u52A8\uFF1A${command}`,
856
+ "task_output \u53D6\u8F93\u51FA \xB7 task_wait \u7B49\u5B83\u7ED3\u675F \xB7 task_stop \u505C\u6389 \xB7 task_list \u770B\u5168\u90E8\u3002",
857
+ "\u6CE8\u610F\uFF1A\u540E\u53F0\u4EFB\u52A1\u6D3B\u4E0D\u8FC7 epoch \u8FDB\u7A0B\uFF0C\u9000\u51FA\u65F6\u4F1A\u88AB\u4E00\u8D77\u6536\u6389\u3002"
858
+ ].join("\n"),
859
+ duration: 0
860
+ };
861
+ }
862
+ const cdTarget = detectCd(command);
863
+ const startTime = Date.now();
864
+ if (usePty) {
865
+ try {
866
+ const result = await runCommandPty(command, {
867
+ timeout: PTY_TIMEOUT,
868
+ cwd,
869
+ tag: ctx.sessionId + "::pty",
870
+ ...stdin ? { stdin } : {},
871
+ ...ctx.signal ? { signal: ctx.signal } : {},
872
+ ...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
873
+ // 方案 46 PR-5:PTY 这条路 2026-08-17 起也有 OS 强制的写入边界。
874
+ // tty 分配 / 窗口尺寸 / 信号转发三样在真 Seatbelt 上逐条验过照旧,
875
+ // 实测表在 pty.ts 的文件头 —— 那三样正是方案 §三 点名要单独验的
876
+ sandbox: sandboxPolicyFor(ctx, baseDir)
877
+ });
878
+ const duration = Date.now() - startTime;
879
+ if (result.aborted) {
880
+ return {
881
+ success: false,
882
+ output: result.stdout,
883
+ error: { code: 2007, message: CANCELLED },
884
+ duration
885
+ };
886
+ }
887
+ let output = spillOutput({
888
+ full: result.stdout,
889
+ sessionId: ctx.sessionId,
890
+ ...ctx.homeDir ? { homeDir: ctx.homeDir } : {},
891
+ label: "pty",
892
+ mode: "head-tail"
893
+ }).text;
894
+ const ptyNotice = sandboxNotice(result.sandbox);
895
+ if (ptyNotice) output = `${ptyNotice}
896
+ ${output}`;
897
+ if (cdTarget) output += `
898
+ ${CD_NOTICE(cdTarget)}`;
899
+ const ptySandboxFailure = toSandboxFailure(result.sandbox, output || NO_OUTPUT, duration);
900
+ if (ptySandboxFailure) return ptySandboxFailure;
901
+ return { success: result.exitCode === 0, output, duration };
902
+ } catch (err) {
903
+ const duration = Date.now() - startTime;
904
+ const message = err instanceof Error ? err.message : String(err);
905
+ return {
906
+ success: false,
907
+ output: "",
908
+ error: {
909
+ code: 2005,
910
+ message: `PTY \u6267\u884C\u5931\u8D25: ${message}`,
911
+ suggestion: "\u68C0\u67E5 node-pty \u662F\u5426\u6B63\u786E\u5B89\u88C5"
912
+ },
913
+ duration
914
+ };
915
+ }
916
+ }
917
+ try {
918
+ const result = await runCommand(command, {
919
+ timeout: DEFAULT_TIMEOUT,
920
+ signal: ctx.signal,
921
+ cwd,
922
+ ...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
923
+ // 方案 46 PR-1:`terminal` 的默认路径从这里起有 OS 强制的写入边界。
924
+ // **边界按工作区主根算,不按 `cwd` 算** —— `cwd` 可能是工作区的子目录
925
+ // (模型传了 workdir),按它算的话「写工作区里另一个目录」会被挡,
926
+ // 而那是一次完全正常的操作
927
+ sandbox: sandboxPolicyFor(ctx, baseDir)
928
+ });
929
+ const duration = Date.now() - startTime;
930
+ const notice = sandboxNotice(result.sandbox);
931
+ let output = result.stdout;
932
+ if (result.stderr) output += (output ? "\n" : "") + result.stderr;
933
+ output = spillOutput({
934
+ full: output,
935
+ sessionId: ctx.sessionId,
936
+ ...ctx.homeDir ? { homeDir: ctx.homeDir } : {},
937
+ label: "terminal",
938
+ mode: "head-tail"
939
+ }).text;
940
+ if (notice) output = `${notice}
941
+ ${output}`;
942
+ if (result.timedOut) {
943
+ return {
944
+ success: false,
945
+ output,
946
+ error: {
947
+ code: 2002,
948
+ message: `\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF08${DEFAULT_TIMEOUT / 1e3} \u79D2\uFF09\uFF0C\u8FDB\u7A0B\u6811\u5DF2\u7EC8\u6B62`,
949
+ suggestion: "\u5C1D\u8BD5\u7B80\u5316\u547D\u4EE4\uFF0C\u6216\u7528 background: true \u653E\u540E\u53F0\u8DD1"
950
+ },
951
+ duration
952
+ };
953
+ }
954
+ if (result.aborted) {
955
+ return {
956
+ success: false,
957
+ output,
958
+ error: { code: 2007, message: CANCELLED },
959
+ duration
960
+ };
961
+ }
962
+ if (cdTarget) output += `
963
+ ${CD_NOTICE(cdTarget)}`;
964
+ const sandboxFailure = toSandboxFailure(result.sandbox, output || NO_OUTPUT, duration);
965
+ if (sandboxFailure) return sandboxFailure;
966
+ return { success: result.exitCode === 0, output: output || NO_OUTPUT, duration };
967
+ } catch (err) {
968
+ const duration = Date.now() - startTime;
969
+ const message = err instanceof Error ? err.message : String(err);
970
+ return {
971
+ success: false,
972
+ output: "",
973
+ error: { code: 2005, message: `\u547D\u4EE4\u6267\u884C\u5931\u8D25: ${message}`, suggestion: "\u68C0\u67E5\u547D\u4EE4\u662F\u5426\u6B63\u786E" },
974
+ duration
975
+ };
976
+ }
977
+ }
978
+ };
979
+ var terminalPlugin = {
980
+ name: "@epoch-agent/plugin-terminal",
981
+ version: "0.0.0",
982
+ description: "\u7EC8\u7AEF\u547D\u4EE4\u6267\u884C + PTY \u4EA4\u4E92 + \u540E\u53F0\u4EFB\u52A1 [Hermes/Codex]",
983
+ // 四个后台任务工具**无条件注册**:它们只读任务表,没有任务时 task_list
984
+ // 就说一句「没有」。按「有没有任务」动态注册的话,模型第一次起后台任务之后
985
+ // 工具表会变,而它这一轮看到的还是旧的
986
+ tools: [terminalTool, ...TASK_TOOLS]
987
+ };
988
+ export {
989
+ MAX_TASKS,
990
+ TASK_TOOLS,
991
+ cleanupBackgroundProcesses,
992
+ clearAllTasks,
993
+ describeTask,
994
+ listTasks,
995
+ rekeyTasks,
996
+ startTask,
997
+ stopTask,
998
+ taskOutput,
999
+ taskSandbox,
1000
+ terminalPlugin,
1001
+ waitTask
1002
+ };