@epoch-agent/plugin-terminal 0.1.0 → 0.2.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.
Files changed (4) hide show
  1. package/README.md +148 -14
  2. package/dist/index.d.ts +510 -135
  3. package/dist/index.js +825 -355
  4. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,25 +1,37 @@
1
1
  // src/index.ts
2
- import { isAbsolute, resolve } from "path";
3
- import {
4
- checkDangerousCommand,
5
- isInWorkspace,
6
- sandboxModeForLevel
7
- } from "@epoch-agent/infra";
8
-
2
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
3
+ import { checkDangerousCommand as checkDangerousCommand2, isInWorkspace as isInWorkspace2 } from "@epoch-agent/infra";
9
4
  // src/background.ts
10
5
  import {
11
6
  artifactsDir,
7
+ clearAllJobs,
8
+ getJob,
9
+ jobDetail,
12
10
  killTrackedProcess,
13
- openArtifact,
11
+ listJobs,
12
+ processTableFor,
13
+ readJob,
14
+ registerJob,
15
+ rekeyJobs,
14
16
  shellSpawnArgs,
15
- startLongLivedProcess
17
+ stopJob,
18
+ waitJob
16
19
  } from "@epoch-agent/infra";
17
-
18
20
  // src/sandbox.ts
19
21
  import {
20
22
  classifyFailure,
21
- confine
23
+ confine,
24
+ sandboxModeForLevel
22
25
  } from "@epoch-agent/infra";
26
+ function sandboxPolicyFor(ctx, workspaceRoot) {
27
+ return {
28
+ mode: sandboxModeForLevel(ctx.permissionLevel),
29
+ workspaceRoot,
30
+ ...ctx.extraRoots ? { extraRoots: ctx.extraRoots } : {},
31
+ allowNetwork: true,
32
+ enabled: ctx.sandboxTerminal !== false
33
+ };
34
+ }
23
35
  function unconfined(skipped) {
24
36
  return { mode: null, backend: "none", enforcement: null, skipped, failure: null };
25
37
  }
@@ -64,203 +76,232 @@ function confineShell(sh, policy, cwd) {
64
76
  })
65
77
  };
66
78
  }
67
-
68
79
  // src/background.ts
69
80
  var MAX_TASKS = 8;
70
81
  var MAX_TASK_OUTPUT = 256 * 1024;
71
82
  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
83
  function startTask(sessionId, command, cwd, homeDir, sandbox) {
89
- const bucket = bucketOf(sessionId);
90
- const running = [...bucket.values()].filter((t) => t.status === "running");
84
+ const running = listJobs(sessionId, "command").filter((j) => j.status === "running");
91
85
  if (running.length >= MAX_TASKS) {
92
- const list = running.map((t) => `${t.id} (${t.command})`).join("\u3001");
86
+ const list = running.map((j) => `${j.id} (${j.label})`).join("\u3001");
93
87
  return {
94
88
  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
89
  };
96
90
  }
97
- const id = nextId();
98
- let finish;
99
- const done2 = new Promise((resolve2) => {
100
- finish = resolve2;
101
- });
102
91
  const raw = shellSpawnArgs(command);
103
92
  const sh = confineShell({ file: raw.file, args: raw.args }, sandbox, cwd);
104
- const task = {
105
- id,
106
- command,
93
+ let pid = 0;
94
+ const job = registerJob({
95
+ sessionId,
96
+ kind: "command",
97
+ label: command,
107
98
  ...cwd ? { cwd } : {},
108
- pid: 0,
109
- status: "running",
110
- startedAt: Date.now(),
111
- buffer: "",
112
- totalBytes: 0,
113
- droppedBytes: 0,
99
+ maxOutput: MAX_TASK_OUTPUT,
114
100
  artifactsDir: artifactsDir(sessionId, homeDir),
115
- sandbox: sh.outcome,
116
- done: done2,
117
- finish
118
- };
119
- bucket.set(id, task);
120
- const started = startLongLivedProcess({
101
+ artifactLabel: "task",
102
+ stop: () => killTrackedProcess(pid),
103
+ terminates: true,
104
+ detail: { sandbox: sh.outcome }
105
+ });
106
+ const started = processTableFor(sessionId).start({
121
107
  file: sh.file,
122
108
  args: sh.args,
123
109
  ...cwd ? { cwd } : {},
124
110
  ...raw.options ? { spawnOptions: raw.options } : {},
125
- onOutput: (chunk) => append(task, chunk),
111
+ onOutput: (chunk) => job.append(chunk),
126
112
  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);
113
+ const status = code === null ? "failed" : "exited";
114
+ if (job.status() === "running" && status === "exited" && code !== null && code !== 0) {
115
+ job.detail.sandbox = sh.classify(code, bufferOf(sessionId, job.id));
134
116
  }
135
- task.spill?.close();
136
- task.finish();
117
+ job.finish(status, code ?? void 0);
137
118
  }
138
119
  });
139
- task.pid = started.pid;
140
- return { task: toInfo(task), sandbox: sh.outcome };
120
+ pid = started.pid;
121
+ job.setPid(pid);
122
+ return { task: toInfo(job.info()), sandbox: sh.outcome };
123
+ }
124
+ function bufferOf(sessionId, id) {
125
+ return readJob(sessionId, id, 0, MAX_TASK_OUTPUT)?.output ?? "";
141
126
  }
142
127
  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;
128
+ return jobDetail(sessionId, id)?.sandbox;
158
129
  }
159
130
  function listTasks(sessionId) {
160
- const bucket = bySession.get(sessionId);
161
- return bucket ? [...bucket.values()].map(toInfo) : [];
131
+ return listJobs(sessionId).map(toInfo);
132
+ }
133
+ function commandJob(sessionId, id) {
134
+ const info = getJob(sessionId, id);
135
+ return info?.kind === "command" ? info : void 0;
162
136
  }
163
137
  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
- };
138
+ if (!getJob(sessionId, id)) return void 0;
139
+ const read = readJob(sessionId, id, since, MAX_OUTPUT_CHUNK);
140
+ if (!read) return void 0;
141
+ return { ...read, info: toInfo(read.info) };
177
142
  }
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;
143
+ function stopTask(sessionId, id) {
144
+ if (!commandJob(sessionId, id)) return Promise.resolve(false);
145
+ return stopJob(sessionId, id);
187
146
  }
188
147
  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
- }
148
+ if (!commandJob(sessionId, id)) return void 0;
149
+ const result = await waitJob(sessionId, id, timeoutMs);
150
+ if (!result) return void 0;
151
+ return { info: toInfo(result.info), timedOut: result.timedOut };
203
152
  }
204
153
  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);
154
+ rekeyJobs(from, to, ["command"]);
215
155
  }
216
156
  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;
157
+ clearAllJobs(["command"]);
222
158
  }
223
- function toInfo(task) {
159
+ function toInfo(info) {
224
160
  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 } : {}
161
+ id: info.id,
162
+ kind: info.kind,
163
+ label: info.label,
164
+ ...info.kind === "command" ? { command: info.label } : {},
165
+ ...info.cwd ? { cwd: info.cwd } : {},
166
+ pid: info.pid,
167
+ status: taskStatus(info),
168
+ ...info.exitCode === void 0 ? {} : { exitCode: info.exitCode },
169
+ startedAt: info.startedAt,
170
+ ...info.endedAt === void 0 ? {} : { endedAt: info.endedAt },
171
+ outputBytes: info.outputBytes,
172
+ truncated: info.truncated,
173
+ ...info.artifact ? { artifact: info.artifact } : {}
236
174
  };
237
175
  }
176
+ function taskStatus(info) {
177
+ return info.status;
178
+ }
238
179
  function describeTask(info) {
239
180
  const dur = Math.round(((info.endedAt ?? Date.now()) - info.startedAt) / 1e3);
240
181
  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
182
  const trunc = info.truncated ? "\uFF0C\u5F00\u5934\u5DF2\u6EDA\u51FA\u7F13\u51B2" : "";
242
183
  return `${info.id} ${state}\uFF08${info.outputBytes} \u5B57\u8282\u8F93\u51FA${trunc}\uFF09 ${info.command}`;
243
184
  }
244
-
245
185
  // src/exec.ts
246
186
  import { spawn } from "child_process";
247
187
  import {
248
188
  createStreamDecoder,
249
- killAllTrackedProcesses,
250
- killProcessTree as killProcessTree2,
189
+ killProcessTree,
190
+ processFallbackTable,
251
191
  shellSpawnArgs as shellSpawnArgs2
252
192
  } from "@epoch-agent/infra";
253
-
254
193
  // src/limits.ts
255
194
  var MAX_CAPTURE_BYTES = 10 * 1024 * 1024;
256
195
  var OVER_CAPTURE_NOTICE = `
257
196
  [\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
197
  var NO_OUTPUT = "(\u65E0\u8F93\u51FA)";
259
-
198
+ // src/exec.ts
199
+ var FORCE_SETTLE_MS = 3e3;
200
+ function killChildTree(child) {
201
+ void killProcessTree({
202
+ pid: child.pid ?? 0,
203
+ detached: false,
204
+ escalate: true,
205
+ isExited: () => child.exitCode !== null || child.signalCode !== null
206
+ });
207
+ }
208
+ function collectStreams(child, onOverflow, onOutput) {
209
+ let stdout = "";
210
+ let stderr = "";
211
+ let truncated = false;
212
+ const outDecoder = createStreamDecoder();
213
+ const errDecoder = createStreamDecoder();
214
+ child.stdout?.on("data", (chunk) => {
215
+ if (truncated) return;
216
+ const piece = outDecoder.write(chunk);
217
+ if (piece) {
218
+ stdout += piece;
219
+ onOutput?.(piece);
220
+ }
221
+ if (stdout.length > MAX_CAPTURE_BYTES) {
222
+ truncated = true;
223
+ stdout = stdout.slice(0, MAX_CAPTURE_BYTES);
224
+ onOverflow();
225
+ }
226
+ });
227
+ child.stderr?.on("data", (chunk) => {
228
+ if (stderr.length >= MAX_CAPTURE_BYTES) return;
229
+ const piece = errDecoder.write(chunk);
230
+ if (!piece) return;
231
+ stderr += piece;
232
+ onOutput?.(piece);
233
+ });
234
+ return () => {
235
+ if (!truncated) stdout += outDecoder.end();
236
+ if (stderr.length < MAX_CAPTURE_BYTES) stderr += errDecoder.end();
237
+ const tail = truncated ? OVER_CAPTURE_NOTICE : "";
238
+ return { stdout: (stdout + tail).trimEnd(), stderr: stderr.trimEnd() };
239
+ };
240
+ }
241
+ function runCommand(command, options) {
242
+ return new Promise((resolve3, reject) => {
243
+ const raw = shellSpawnArgs2(command);
244
+ const sh = confineShell({ file: raw.file, args: raw.args }, options.sandbox, options.cwd);
245
+ const child = spawn(sh.file, sh.args, {
246
+ stdio: ["ignore", "pipe", "pipe"],
247
+ cwd: options.cwd,
248
+ ...raw.options
249
+ });
250
+ let killedBy = null;
251
+ let settled = false;
252
+ let forceTimer;
253
+ const drain = collectStreams(child, () => kill("overflow"), options.onOutput);
254
+ const timer = setTimeout(() => kill("timeout"), options.timeout);
255
+ const abortCheck = setInterval(() => {
256
+ if (options.signal?.aborted) kill("abort");
257
+ }, 100);
258
+ function clearTimers() {
259
+ clearTimeout(timer);
260
+ clearTimeout(forceTimer);
261
+ clearInterval(abortCheck);
262
+ }
263
+ function finish() {
264
+ if (settled) return;
265
+ settled = true;
266
+ clearTimers();
267
+ const { stdout, stderr } = drain();
268
+ const exitCode = child.exitCode ?? -1;
269
+ resolve3({
270
+ stdout,
271
+ stderr,
272
+ exitCode,
273
+ ...killedBy === "timeout" ? { timedOut: true } : {},
274
+ ...killedBy === "abort" ? { aborted: true } : {},
275
+ sandbox: killedBy ? sh.outcome : sh.classify(exitCode, stderr)
276
+ });
277
+ }
278
+ function kill(why) {
279
+ if (killedBy) return;
280
+ killedBy = why;
281
+ killChildTree(child);
282
+ forceTimer = setTimeout(finish, FORCE_SETTLE_MS);
283
+ }
284
+ if (options.signal?.aborted) kill("abort");
285
+ child.on("close", finish);
286
+ child.on("error", (err) => {
287
+ if (settled) return;
288
+ settled = true;
289
+ clearTimers();
290
+ reject(err);
291
+ });
292
+ });
293
+ }
294
+ async function cleanupBackgroundProcesses(table) {
295
+ await (table ?? processFallbackTable()).killAll();
296
+ }
260
297
  // src/pty.ts
261
298
  import { spawn as spawnPty } from "node-pty";
262
- import { IS_WINDOWS, killProcessTree, shellPtyArgs } from "@epoch-agent/infra";
263
-
299
+ import {
300
+ IS_WINDOWS,
301
+ killProcessTree as killProcessTree2,
302
+ processTableFor as processTableFor2,
303
+ shellPtyArgs
304
+ } from "@epoch-agent/infra";
264
305
  // src/pty-helper.ts
265
306
  import { accessSync, chmodSync, constants, existsSync } from "fs";
266
307
  import { createRequire } from "module";
@@ -302,12 +343,24 @@ function ptyHelperHint() {
302
343
  node-pty \u7684 spawn-helper \u6CA1\u6709\u6267\u884C\u6743\u9650\uFF0C\u4E14\u81EA\u52A8\u4FEE\u590D\u5931\u8D25\uFF08${failure}\uFF09\u3002
303
344
  \u624B\u52A8\u4FEE\uFF1Achmod +x <\u4E0A\u9762\u8FD9\u4E2A\u8DEF\u5F84>`;
304
345
  }
305
-
306
346
  // src/pty.ts
307
347
  var PTY_IS_GROUP_LEADER = !IS_WINDOWS;
348
+ function asKeystrokes(input) {
349
+ return IS_WINDOWS ? input.replaceAll(/\r?\n/g, "\r") : input;
350
+ }
308
351
  var ptySessions = /* @__PURE__ */ new Map();
352
+ var killing = /* @__PURE__ */ new WeakMap();
309
353
  function killPtyTree(pty) {
310
- void killProcessTree({ pid: pty.pid, detached: PTY_IS_GROUP_LEADER, escalate: true, pty });
354
+ const started = killing.get(pty);
355
+ if (started) return started;
356
+ const running = killProcessTree2({
357
+ pid: pty.pid,
358
+ detached: PTY_IS_GROUP_LEADER,
359
+ escalate: true,
360
+ pty
361
+ });
362
+ killing.set(pty, running);
363
+ return running;
311
364
  }
312
365
  function confinePty(raw, policy, cwd) {
313
366
  if (!Array.isArray(raw.args)) {
@@ -318,10 +371,10 @@ function confinePty(raw, policy, cwd) {
318
371
  return { file: sh.file, args: sh.args, outcome: sh.outcome, classify: sh.classify };
319
372
  }
320
373
  function runCommandPty(command, options) {
321
- return new Promise((resolve2) => {
374
+ return new Promise((resolve3) => {
322
375
  const previous = ptySessions.get(options.tag);
323
376
  if (previous) {
324
- killPtyTree(previous);
377
+ void killPtyTree(previous);
325
378
  ptySessions.delete(options.tag);
326
379
  }
327
380
  const raw = shellPtyArgs(command);
@@ -337,7 +390,7 @@ function runCommandPty(command, options) {
337
390
  rows: 40
338
391
  });
339
392
  } catch (err) {
340
- resolve2({
393
+ resolve3({
341
394
  stdout: `PTY \u542F\u52A8\u5931\u8D25\uFF1A${err instanceof Error ? err.message : String(err)}${ptyHelperHint()}`,
342
395
  exitCode: 1,
343
396
  sandbox: sh.outcome
@@ -345,6 +398,10 @@ function runCommandPty(command, options) {
345
398
  return;
346
399
  }
347
400
  ptySessions.set(options.tag, pty);
401
+ const untrack = processTableFor2(options.sessionId).trackForeign(
402
+ pty.pid,
403
+ () => killPtyTree(pty)
404
+ );
348
405
  let output = "";
349
406
  let truncated = false;
350
407
  let settled = false;
@@ -357,21 +414,21 @@ function runCommandPty(command, options) {
357
414
  clearTimeout(timer);
358
415
  clearInterval(abortCheck);
359
416
  releaseSlot();
360
- resolve2(result);
417
+ resolve3(result);
361
418
  };
362
419
  const timer = setTimeout(() => {
363
- killPtyTree(pty);
420
+ void killPtyTree(pty);
364
421
  finish({ stdout: output.trimEnd() || "(\u8D85\u65F6\uFF0C\u65E0\u8F93\u51FA)", exitCode: -1, sandbox: sh.outcome });
365
422
  }, options.timeout);
366
423
  const abortCheck = setInterval(() => {
367
424
  if (!options.signal?.aborted) return;
368
- killPtyTree(pty);
425
+ void killPtyTree(pty);
369
426
  finish({ stdout: output.trimEnd(), exitCode: -1, aborted: true, sandbox: sh.outcome });
370
427
  }, 100);
371
428
  if (options.stdin) {
372
429
  setTimeout(() => {
373
430
  try {
374
- pty.write((options.stdin ?? "") + "\n");
431
+ pty.write(asKeystrokes((options.stdin ?? "") + "\n"));
375
432
  } catch {
376
433
  }
377
434
  }, 200);
@@ -383,136 +440,19 @@ function runCommandPty(command, options) {
383
440
  if (output.length > MAX_CAPTURE_BYTES) {
384
441
  truncated = true;
385
442
  output = output.slice(0, MAX_CAPTURE_BYTES) + OVER_CAPTURE_NOTICE;
386
- killPtyTree(pty);
443
+ void killPtyTree(pty);
387
444
  }
388
445
  });
389
446
  pty.onExit(({ exitCode }) => {
447
+ untrack();
390
448
  finish({
391
449
  stdout: output.trimEnd() || NO_OUTPUT,
392
450
  exitCode,
393
- // PTY 只有一股流,**没有独立的 stderr** —— 而拒绝方言和 runner
394
- // 致命行都是 runner 往 stderr 打的。这里把整股流当 stderr 喂进去是
395
- // 对的:那两组正则都锚在行首的后端自称上(`sandbox-exec:`),
396
- // 混进来的 stdout 不会把它们撞出假阳性
397
451
  sandbox: sh.classify(exitCode, output)
398
452
  });
399
453
  });
400
454
  });
401
455
  }
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
456
  // src/sandbox-notice.ts
517
457
  var MODE_BOUNDARY = {
518
458
  "read-only": "\u53EA\u8BFB\uFF1A\u5199\u4EFB\u4F55\u6587\u4EF6\u90FD\u4F1A\u88AB OS \u6321\u4E0B",
@@ -521,6 +461,9 @@ var MODE_BOUNDARY = {
521
461
  };
522
462
  function sandboxNotice(sb) {
523
463
  if (sb.skipped === "not-requested") return "";
464
+ if (sb.skipped === "config-disabled") {
465
+ return "[\u6C99\u7BB1: \u5173] \u914D\u7F6E\u91CC sandbox.terminal \u662F false\uFF0C\u672C\u6B21\u6CA1\u6709 OS \u7EA7\u9694\u79BB\uFF0C\u547D\u4EE4\u80FD\u8BFB\u5199\u6574\u4E2A\u6587\u4EF6\u7CFB\u7EDF\u3002\u8FD9\u4E00\u6863\u548C\u6743\u9650\u6863\u4F4D\u65E0\u5173\uFF08\u5BA1\u6279\u7167\u65E7\uFF09\uFF0C\u5371\u9669\u547D\u4EE4\u8868\u4ECD\u7136\u5728\u5C97\u3002";
466
+ }
524
467
  if (sb.skipped === "mode-disabled") {
525
468
  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
469
  }
@@ -539,18 +482,26 @@ function sandboxError(sb) {
539
482
  if (failure2.kind === "runner-failed") {
540
483
  return {
541
484
  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"
485
+ 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 \u2014\u2014 \u8981\u7ED5\u5F00\u7684\u8BDD\uFF0C\u8BA9\u4ED6\u5728 config.yaml \u91CC\u5199 sandbox.terminal: false\uFF08\u53EA\u5173\u6C99\u7BB1\uFF0C\u5BA1\u6279\u4E0D\u52A8\uFF09\uFF0C\u522B\u529D\u4ED6\u5207\u300C\u5141\u8BB8\u4E00\u5207\u300D\u6863\u4F4D\u3002"
543
486
  };
544
487
  }
545
488
  if (failure2.kind === "sandbox-denied") {
546
489
  return {
547
490
  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"
491
+ 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\uFF1B\u5982\u679C\u88AB\u6321\u7684\u662F\u4E00\u6761\u672C\u6765\u8BE5\u80FD\u8DD1\u7684\u547D\u4EE4\uFF08\u6BD4\u5982\u67D0\u4E2A\u6CA1\u767B\u8BB0\u7684\u5DE5\u5177\u94FE\u7F13\u5B58\u76EE\u5F55\uFF09\uFF0C\u4ED6\u53EF\u4EE5\u5728 config.yaml \u91CC\u5199 sandbox.terminal: false \u6574\u4E2A\u5173\u6389\u3002"
549
492
  };
550
493
  }
551
494
  return null;
552
495
  }
553
-
496
+ // src/shell/tools.ts
497
+ import { isAbsolute, resolve } from "path";
498
+ import { checkDangerousCommand, isInWorkspace, t } from "@epoch-agent/infra";
499
+ // src/shell/limits.ts
500
+ var MAX_SHELLS = 4;
501
+ var MAX_SHELL_OUTPUT = 256 * 1024;
502
+ var MAX_SHELL_CHUNK = 8 * 1024;
503
+ var SHELL_IDLE_MS = 10 * 60 * 1e3;
504
+ var SHELL_SWEEP_MS = 60 * 1e3;
554
505
  // src/spill.ts
555
506
  import { artifactsDir as artifactsDir2, headCodePoints, tailCodePoints, writeArtifact } from "@epoch-agent/infra";
556
507
  var PREVIEW_HEAD = 2e3;
@@ -587,48 +538,567 @@ function describeMissed(missed, locator) {
587
538
  const where = locator ? retrievalHint(locator) : NOT_STORED;
588
539
  return `\uFF08\u5F00\u5934 ${missed} \u5B57\u8282\u5DF2\u7ECF\u6EDA\u51FA\u73AF\u5F62\u7F13\u51B2\uFF09${where}`;
589
540
  }
590
-
591
- // src/tasks.ts
592
- var DEFAULT_WAIT_MS = 3e4;
593
- var MAX_WAIT_MS = 3e5;
541
+ function describeMore(nextCursor) {
542
+ return `\uFF08\u8FD8\u6709\u66F4\u591A\uFF0C\u7528 since: ${nextCursor} \u7EE7\u7EED\u53D6\uFF09`;
543
+ }
544
+ // src/shell/notice.ts
545
+ function stateOf(info) {
546
+ const alive = Math.round((Date.now() - info.startedAt) / 1e3);
547
+ switch (info.status) {
548
+ case "running": {
549
+ const idle = Math.round((Date.now() - info.lastActivity) / 1e3);
550
+ return `\u6D3B\u7740 ${alive}s\uFF08\u7A7A\u95F2 ${idle}s\uFF09`;
551
+ }
552
+ case "exited":
553
+ return `shell \u81EA\u5DF1\u9000\u4E86\uFF0C\u9000\u51FA\u7801 ${info.exitCode}`;
554
+ case "closed":
555
+ return "\u5DF2\u5173\u95ED";
556
+ case "reaped":
557
+ return `\u7A7A\u95F2\u8D85\u8FC7 ${SHELL_IDLE_MS / 6e4} \u5206\u949F\uFF0C\u5DF2\u56DE\u6536`;
558
+ }
559
+ }
560
+ function describeShell(info) {
561
+ const trunc = info.truncated ? "\uFF0C\u5F00\u5934\u5DF2\u6EDA\u51FA\u7F13\u51B2" : "";
562
+ return `${info.id} ${stateOf(info)} cwd=${info.cwd}\uFF08${info.outputBytes} \u5B57\u8282\u8F93\u51FA${trunc}\uFF09`;
563
+ }
564
+ function openedNotice(info) {
565
+ return [
566
+ `\u6301\u4E45 shell \u4F1A\u8BDD ${info.id} \u5DF2\u5F00\uFF1A${info.shell}\uFF0C\u5DE5\u4F5C\u76EE\u5F55 ${info.cwd}`,
567
+ "shell_send \u53D1\u8F93\u5165\uFF08\u8BB0\u5F97\u81EA\u5DF1\u5E26 \\n\uFF09\xB7 shell_read \u589E\u91CF\u8BFB \xB7 shell_close \u5173\u6389 \xB7 shell_list \u770B\u5168\u90E8\u3002",
568
+ `\u6CE8\u610F\uFF1A\u4F1A\u8BDD\u6D3B\u4E0D\u8FC7 epoch \u8FDB\u7A0B\uFF0C\u9000\u51FA\u65F6\u4F1A\u88AB\u4E00\u8D77\u6536\u6389\uFF1B\u7A7A\u95F2 ${SHELL_IDLE_MS / 6e4} \u5206\u949F\u4E5F\u4F1A\u88AB\u56DE\u6536\u3002`
569
+ ].join("\n");
570
+ }
571
+ function closedNotice(id) {
572
+ return `${id} \u5DF2\u5173\u95ED\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002`;
573
+ }
574
+ function alreadyGoneNotice(id) {
575
+ return `${id} \u5DF2\u7ECF\u4E0D\u5728\u8FD0\u884C\u4E2D\u4E86\u3002`;
576
+ }
577
+ var NO_SHELLS = "\u6CA1\u6709\u6301\u4E45 shell \u4F1A\u8BDD\u3002\u7528 shell_open \u5F00\u4E00\u4E2A\u3002";
578
+ function listHead(count) {
579
+ return `${count} \u4E2A\u6301\u4E45 shell \u4F1A\u8BDD\uFF08\u540C\u65F6\u6700\u591A ${MAX_SHELLS} \u4E2A\uFF09`;
580
+ }
581
+ var NO_NEW_OUTPUT = "\uFF08\u8FD9\u4E00\u6BB5\u6CA1\u6709\u65B0\u8F93\u51FA\u3002\u547D\u4EE4\u53EF\u80FD\u8FD8\u5728\u8DD1\uFF0C\u7B49\u4E00\u4F1A\u513F\u518D shell_read\uFF09";
582
+ function writeFailedNotice(message) {
583
+ return `
584
+ [\u5199\u5165\u5931\u8D25\uFF1A${message}]
585
+ `;
586
+ }
587
+ function readNotice(result) {
588
+ const notes = [];
589
+ if (result.missed > 0) notes.push(describeMissed(result.missed, result.info.artifact));
590
+ if (result.hasMore) notes.push(describeMore(result.nextCursor));
591
+ return [
592
+ describeShell(result.info),
593
+ ...notes,
594
+ "",
595
+ result.output || NO_NEW_OUTPUT,
596
+ "",
597
+ `nextCursor: ${result.nextCursor}`
598
+ ].join("\n");
599
+ }
600
+ // src/shell/sessions.ts
601
+ import { spawn as spawnPty2 } from "node-pty";
602
+ import {
603
+ allJobs,
604
+ appendJob,
605
+ artifactsDir as artifactsDir3,
606
+ clearAllJobs as clearAllJobs2,
607
+ getDefaultShell,
608
+ getJob as getJob2,
609
+ IS_WINDOWS as IS_WINDOWS2,
610
+ jobDetail as jobDetail2,
611
+ listJobs as listJobs2,
612
+ processTableFor as processTableFor3,
613
+ readJob as readJob2,
614
+ registerJob as registerJob2,
615
+ stopJob as stopJob2,
616
+ touchJob
617
+ } from "@epoch-agent/infra";
618
+ var CSI_PREAMBLE = /^(?:\x1b\[[0-9;?]*[ -/]*[@-~])+/;
619
+ var CONPTY_SIGNATURE = "\x1B[?9001h";
620
+ function dropConPtyPreamble(chunk) {
621
+ if (!IS_WINDOWS2 || !chunk.startsWith(CONPTY_SIGNATURE)) return chunk;
622
+ return chunk.slice(chunk.match(CSI_PREAMBLE)?.[0].length ?? 0);
623
+ }
624
+ function toShellInfo(info, detail) {
625
+ return {
626
+ id: info.id,
627
+ pid: info.pid,
628
+ cwd: info.cwd ?? info.label,
629
+ shell: detail?.shell ?? "",
630
+ status: shellStatus(info.status),
631
+ ...info.exitCode === void 0 ? {} : { exitCode: info.exitCode },
632
+ startedAt: info.startedAt,
633
+ lastActivity: info.lastActivity,
634
+ outputBytes: info.outputBytes,
635
+ truncated: info.truncated,
636
+ ...info.artifact ? { artifact: info.artifact } : {}
637
+ };
638
+ }
639
+ function shellStatus(status) {
640
+ switch (status) {
641
+ case "running":
642
+ case "exited":
643
+ case "reaped":
644
+ return status;
645
+ default:
646
+ return "closed";
647
+ }
648
+ }
649
+ function shellIn(sessionId, id) {
650
+ const info = getJob2(sessionId, id);
651
+ if (!info || info.kind !== "shell") return void 0;
652
+ const detail = jobDetail2(sessionId, id);
653
+ return detail ? { info, detail } : void 0;
654
+ }
655
+ function toShellRead(read, detail) {
656
+ return { ...read, info: toShellInfo(read.info, detail) };
657
+ }
658
+ function openShell(sessionId, cwd, homeDir, sandbox) {
659
+ const running = listJobs2(sessionId, "shell").filter((s) => s.status === "running");
660
+ if (running.length >= MAX_SHELLS) {
661
+ return {
662
+ error: "limit",
663
+ limit: MAX_SHELLS,
664
+ running: running.map((info) => toShellInfo(info, jobDetail2(sessionId, info.id)))
665
+ };
666
+ }
667
+ const file = getDefaultShell();
668
+ const sh = confineShell({ file, args: [] }, sandbox, cwd);
669
+ let pty;
670
+ try {
671
+ ensurePtyHelperExecutable();
672
+ pty = spawnPty2(sh.file, sh.args, {
673
+ name: "xterm-256color",
674
+ cwd,
675
+ env: { ...process.env, TERM: "xterm-256color", FORCE_COLOR: "1" },
676
+ cols: 120,
677
+ rows: 40
678
+ });
679
+ } catch (err) {
680
+ return {
681
+ error: "spawn-failed",
682
+ message: err instanceof Error ? err.message : String(err),
683
+ hint: ptyHelperHint()
684
+ };
685
+ }
686
+ const detail = {
687
+ pty,
688
+ shell: file,
689
+ sandbox: sh.outcome,
690
+ fresh: true,
691
+ untrack: () => {
692
+ }
693
+ };
694
+ const job = registerJob2({
695
+ sessionId,
696
+ kind: "shell",
697
+ label: cwd,
698
+ cwd,
699
+ maxOutput: MAX_SHELL_OUTPUT,
700
+ artifactsDir: artifactsDir3(sessionId, homeDir),
701
+ artifactLabel: "shell",
702
+ stop: () => killPtyTree(pty),
703
+ stoppedStatus: "closed",
704
+ terminates: false,
705
+ detail
706
+ });
707
+ job.setPid(pty.pid);
708
+ detail.untrack = processTableFor3(sessionId).trackForeign(pty.pid, () => killPtyTree(pty));
709
+ armSweep();
710
+ pty.onData((chunk) => {
711
+ if (detail.fresh) {
712
+ detail.fresh = false;
713
+ job.append(dropConPtyPreamble(chunk));
714
+ return;
715
+ }
716
+ job.append(chunk);
717
+ });
718
+ pty.onExit(({ exitCode }) => {
719
+ job.finish("exited", exitCode);
720
+ afterEnd(detail);
721
+ });
722
+ return { shell: toShellInfo(job.info(), detail), sandbox: sh.outcome };
723
+ }
724
+ function afterEnd(detail) {
725
+ detail.untrack();
726
+ disarmSweepIfIdle();
727
+ }
728
+ async function sendToShell(sessionId, id, input, waitMs) {
729
+ sweepIdleShells();
730
+ const found = shellIn(sessionId, id);
731
+ if (!found) return {};
732
+ if (found.info.status !== "running") return { info: toShellInfo(found.info, found.detail) };
733
+ const cursor = found.info.outputBytes;
734
+ touchJob(sessionId, id);
735
+ try {
736
+ found.detail.pty.write(asKeystrokes(input));
737
+ } catch (err) {
738
+ appendJob(sessionId, id, writeFailedNotice(err instanceof Error ? err.message : String(err)));
739
+ }
740
+ await sleep(waitMs);
741
+ touchJob(sessionId, id);
742
+ const read = readJob2(sessionId, id, cursor, MAX_SHELL_CHUNK);
743
+ return read ? toShellRead(read, found.detail) : {};
744
+ }
745
+ function readShell(sessionId, id, since = 0) {
746
+ sweepIdleShells();
747
+ const found = shellIn(sessionId, id);
748
+ if (!found) return {};
749
+ touchJob(sessionId, id);
750
+ const read = readJob2(sessionId, id, since, MAX_SHELL_CHUNK);
751
+ return read ? toShellRead(read, found.detail) : {};
752
+ }
753
+ async function closeShell(sessionId, id) {
754
+ const found = shellIn(sessionId, id);
755
+ if (!found || found.info.status !== "running") return false;
756
+ const closed = await stopJob2(sessionId, id);
757
+ if (closed) afterEnd(found.detail);
758
+ return closed;
759
+ }
760
+ function listShells(sessionId) {
761
+ sweepIdleShells();
762
+ return listJobs2(sessionId, "shell").map(
763
+ (info) => toShellInfo(info, jobDetail2(sessionId, info.id))
764
+ );
765
+ }
766
+ function clearAllShells() {
767
+ for (const { sessionId, info } of allJobs("shell")) {
768
+ jobDetail2(sessionId, info.id)?.untrack();
769
+ }
770
+ clearAllJobs2(["shell"]);
771
+ clearInterval(sweepTimer);
772
+ sweepTimer = void 0;
773
+ }
774
+ var sweepTimer;
775
+ function sweepIdleShells(now = Date.now()) {
776
+ const reaped = [];
777
+ for (const { sessionId, info } of allJobs("shell")) {
778
+ if (info.status !== "running") continue;
779
+ if (now - info.lastActivity < SHELL_IDLE_MS) continue;
780
+ const detail = jobDetail2(sessionId, info.id);
781
+ if (!detail) continue;
782
+ void stopJob2(sessionId, info.id, "reaped").then(() => afterEnd(detail));
783
+ reaped.push(toShellInfo({ ...info, status: "reaped" }, detail));
784
+ }
785
+ return reaped;
786
+ }
787
+ function armSweep() {
788
+ if (sweepTimer) return;
789
+ sweepTimer = setInterval(() => void sweepIdleShells(), SHELL_SWEEP_MS);
790
+ sweepTimer.unref?.();
791
+ }
792
+ function disarmSweepIfIdle() {
793
+ if (allJobs("shell").some((j) => j.info.status === "running")) return;
794
+ clearInterval(sweepTimer);
795
+ sweepTimer = void 0;
796
+ }
797
+ function sleep(ms) {
798
+ return new Promise((resolve3) => {
799
+ const timer = setTimeout(resolve3, ms);
800
+ timer.unref?.();
801
+ });
802
+ }
803
+ function shellSandbox(sessionId, id) {
804
+ return shellIn(sessionId, id)?.detail.sandbox;
805
+ }
806
+ // src/shell/tools.ts
807
+ var DEFAULT_WAIT_MS = 2e3;
808
+ var MAX_WAIT_MS = 3e4;
594
809
  function ok(output) {
595
810
  return { success: true, output, duration: 0 };
596
811
  }
597
- function notFound(id) {
812
+ function fail(code, message, suggestion) {
598
813
  return {
599
814
  success: false,
600
815
  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"
816
+ error: { code, message, ...suggestion ? { suggestion } : {} },
817
+ duration: 0
818
+ };
819
+ }
820
+ function notFound(id) {
821
+ return fail(2101, t("shell.not_found", { id }), t("shell.not_found_suggestion"));
822
+ }
823
+ function notRunning(info) {
824
+ if (info.status === "reaped") {
825
+ return fail(
826
+ 2102,
827
+ t("shell.reaped", { id: info.id, minutes: SHELL_IDLE_MS / 6e4 }),
828
+ t("shell.reaped_suggestion")
829
+ );
830
+ }
831
+ return fail(2102, t("shell.not_running", { id: info.id }), t("shell.not_running_suggestion"));
832
+ }
833
+ function toolResultFor(id, result, ctx) {
834
+ if (!("output" in result)) {
835
+ return result.info ? notRunning(result.info) : notFound(id);
836
+ }
837
+ const sb = shellSandbox(ctx.sessionId, id);
838
+ const sbErr = sb ? sandboxError(sb) : null;
839
+ const body = readNotice(result);
840
+ return ok(sbErr ? `${body}
841
+ ${sbErr.message}
842
+ ${sbErr.suggestion}` : body);
843
+ }
844
+ var shellOpen = {
845
+ name: "shell_open",
846
+ description: `\u5F00\u4E00\u4E2A**\u6301\u4E45** shell \u4F1A\u8BDD\uFF08PTY\uFF09\uFF0C\u8FD4\u56DE\u4F1A\u8BDD id\u3002\u548C terminal \u7684\u533A\u522B\u662F\u5B83\u8DE8\u591A\u6B21\u5DE5\u5177\u8C03\u7528\u6D3B\u7740\uFF1Acd / export / source venv/bin/activate \u90FD\u4F1A\u7559\u5B58\uFF0CREPL \u8FDB\u53BB\u4E86\u8FD8\u80FD\u51FA\u6765\u3002\u9002\u5408\u300C\u5148\u6FC0\u6D3B\u73AF\u5883\u518D\u8DD1\u51E0\u6761\u547D\u4EE4\u300D\u300C\u591A\u8F6E\u4EA4\u4E92\u7684 npm init\u300D\u300CNode / Python REPL\u300D\u3002\u540C\u65F6\u6700\u591A ${MAX_SHELLS} \u4E2A\uFF1B\u7A7A\u95F2 ${SHELL_IDLE_MS / 6e4} \u5206\u949F\u4F1A\u88AB\u56DE\u6536\uFF1B**\u4F1A\u8BDD\u6D3B\u4E0D\u8FC7 epoch \u8FDB\u7A0B**\uFF08\u9000\u51FA\u65F6\u4E00\u8D77\u6536\u6389\uFF0C\u522B\u6307\u671B\u4E0B\u6B21\u8FD0\u884C\u8FD8\u5728\uFF09\u3002\u4E00\u6B21\u6027\u547D\u4EE4\u4ECD\u7136\u7528 terminal \u2014\u2014 \u90A3\u6761\u4E0D\u5FC5\u5F00\u4F1A\u8BDD\u3002`,
847
+ parameters: {
848
+ type: "object",
849
+ properties: {
850
+ workdir: {
851
+ type: "string",
852
+ description: "\u4F1A\u8BDD\u7684\u521D\u59CB\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"
853
+ }
854
+ },
855
+ required: []
856
+ },
857
+ supportsParallel: false,
858
+ annotations: {
859
+ readOnlyHint: false,
860
+ destructiveHint: true,
861
+ idempotentHint: false,
862
+ openWorldHint: true
863
+ },
864
+ operation: "command",
865
+ describeTarget: (args) => String(args["workdir"] ?? ""),
866
+ execute: (args, ctx) => {
867
+ const workdir = args["workdir"] ? String(args["workdir"]) : void 0;
868
+ const baseDir = ctx.workDir || process.cwd();
869
+ if (workdir && !isInWorkspace(workdir, baseDir, ctx.extraRoots)) {
870
+ return Promise.resolve(
871
+ fail(
872
+ 2006,
873
+ t("shell.workdir_out_of_bounds", { workdir, root: baseDir }),
874
+ t("shell.workdir_suggestion")
875
+ )
876
+ );
877
+ }
878
+ const cwd = workdir ? resolveWorkdir(workdir, baseDir) : baseDir;
879
+ const opened = openShell(ctx.sessionId, cwd, ctx.homeDir, sandboxPolicyFor(ctx, baseDir));
880
+ if ("error" in opened) {
881
+ if (opened.error === "limit") {
882
+ const list = opened.running.map(describeShell).join("\n");
883
+ return Promise.resolve(
884
+ fail(
885
+ 2006,
886
+ t("shell.limit_reached", { limit: opened.limit, list }),
887
+ t("shell.limit_suggestion")
888
+ )
889
+ );
890
+ }
891
+ return Promise.resolve(
892
+ fail(
893
+ 2005,
894
+ t("shell.spawn_failed", { message: opened.message + opened.hint }),
895
+ t("shell.spawn_failed_suggestion")
896
+ )
897
+ );
898
+ }
899
+ const notice = sandboxNotice(opened.sandbox);
900
+ return Promise.resolve(
901
+ ok([...notice ? [notice] : [], openedNotice(opened.shell)].join("\n"))
902
+ );
903
+ }
904
+ };
905
+ function resolveWorkdir(workdir, baseDir) {
906
+ return isAbsolute(workdir) ? resolve(workdir) : resolve(baseDir, workdir);
907
+ }
908
+ var shellSend = {
909
+ name: "shell_send",
910
+ description: '\u5F80\u4E00\u4E2A\u6301\u4E45\u4F1A\u8BDD\u91CC\u53D1\u8F93\u5165\uFF0C\u7B49 wait \u6BEB\u79D2\u4E4B\u540E\u628A\u8FD9\u6BB5\u65F6\u95F4\u7684\u8F93\u51FA\u4EA4\u56DE\u6765\u3002**\u6362\u884C\u8981\u81EA\u5DF1\u5E26**\uFF1Ainput \u662F\u9010\u5B57\u5199\u8FDB\u53BB\u7684\uFF0C"pwd" \u53EA\u662F\u628A\u4E09\u4E2A\u5B57\u7B26\u6253\u4E0A\u53BB\uFF0C"pwd\\n" \u624D\u4F1A\u6267\u884C\u3002Ctrl+C \u5C31\u662F\u53D1 "\\x03"\uFF08\u8FD9\u6BD4\u7ED9\u8FDB\u7A0B\u53D1\u4FE1\u53F7\u66F4\u51C6\uFF0C\u7528\u6237\u5728\u771F\u7EC8\u7AEF\u91CC\u6309\u7684\u6B63\u662F\u8FD9\u6761\u8DEF\uFF09\u3002\u8F93\u51FA\u6CA1\u7B49\u591F\u5C31\u518D\u7528 shell_read \u63A5\u7740\u53D6\uFF08\u6E38\u6807\u63A5\u4E0A\u6B21\u7684 nextCursor\uFF09\u3002\u53D1\u8FDB\u53BB\u7684\u5185\u5BB9**\u548C terminal \u8D70\u540C\u4E00\u6761\u5371\u9669\u547D\u4EE4\u8868\u548C\u6743\u9650\u5224\u5B9A** \u2014\u2014 \u6301\u4E45\u4F1A\u8BDD\u4E0D\u662F\u65C1\u8DEF\u3002',
911
+ parameters: {
912
+ type: "object",
913
+ properties: {
914
+ id: { type: "string", description: "\u4F1A\u8BDD id\uFF0C\u4F8B\u5982 s1" },
915
+ input: { type: "string", description: "\u9010\u5B57\u5199\u8FDB\u4F1A\u8BDD\u7684\u8F93\u5165\uFF0C\u901A\u5E38\u8981\u4EE5 \\n \u7ED3\u5C3E" },
916
+ wait: {
917
+ type: "number",
918
+ description: `\u7B49\u591A\u4E45\u518D\u8FD4\u56DE\u8F93\u51FA\uFF08\u6BEB\u79D2\uFF0C\u9ED8\u8BA4 ${DEFAULT_WAIT_MS}\uFF0C\u6700\u5927 ${MAX_WAIT_MS}\uFF09`
919
+ }
920
+ },
921
+ required: ["id", "input"]
922
+ },
923
+ supportsParallel: false,
924
+ annotations: {
925
+ readOnlyHint: false,
926
+ destructiveHint: true,
927
+ idempotentHint: false,
928
+ openWorldHint: true
929
+ },
930
+ operation: "command",
931
+ describeTarget: (args) => String(args["input"] ?? ""),
932
+ execute: async (args, ctx) => {
933
+ const id = String(args["id"] ?? "");
934
+ const input = String(args["input"] ?? "");
935
+ if (!input) return fail(2004, t("shell.empty_input"), t("shell.empty_input_suggestion"));
936
+ const danger = checkDangerousCommand(input);
937
+ if (danger.dangerous) {
938
+ return fail(
939
+ 2003,
940
+ t("shell.dangerous", { desc: danger.desc ?? "" }),
941
+ t("shell.dangerous_suggestion", { input: input.trim() })
942
+ );
943
+ }
944
+ const requested = Number(args["wait"]);
945
+ const wait = Math.min(
946
+ Number.isFinite(requested) && requested >= 0 ? requested : DEFAULT_WAIT_MS,
947
+ MAX_WAIT_MS
948
+ );
949
+ return toolResultFor(id, await sendToShell(ctx.sessionId, id, input, wait), ctx);
950
+ }
951
+ };
952
+ var shellRead = {
953
+ name: "shell_read",
954
+ description: `\u589E\u91CF\u8BFB\u4E00\u4E2A\u6301\u4E45\u4F1A\u8BDD\u7684\u8F93\u51FA\u3002\u53C2\u6570: id(\u5FC5\u586B), since(\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF0C\u53EA\u53D6\u65B0\u589E\u90E8\u5206)\u3002\u5355\u6B21\u6700\u591A ${MAX_SHELL_CHUNK} \u5B57\u8282\uFF0C\u8F93\u51FA\u5F88\u957F\u65F6\u5206\u6B21\u53D6\uFF1B\u4F1A\u8BDD\u4E0D\u5728\u8DD1\u4E86\u4E5F\u8BFB\u5F97\u5230\uFF08\u7F13\u51B2\u91CC\u90A3\u6BB5\u8F93\u51FA\u8FD8\u5728\uFF09\uFF0C\u6D3B\u4E0D\u6D3B\u7740\u770B\u56DE\u6267\u7B2C\u4E00\u884C\u3002`,
955
+ parameters: {
956
+ type: "object",
957
+ properties: {
958
+ id: { type: "string", description: "\u4F1A\u8BDD id\uFF0C\u4F8B\u5982 s1" },
959
+ since: { type: "number", description: "\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF1B\u4E0D\u7ED9\u5C31\u4ECE\u5934\u53D6" }
605
960
  },
961
+ required: ["id"]
962
+ },
963
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true },
964
+ operation: "file_read",
965
+ describeTarget: (args) => String(args["id"] ?? ""),
966
+ execute: (args, ctx) => {
967
+ const id = String(args["id"] ?? "");
968
+ const since = Number(args["since"]);
969
+ const result = readShell(ctx.sessionId, id, Number.isFinite(since) && since > 0 ? since : 0);
970
+ return Promise.resolve(toolResultFor(id, result, ctx));
971
+ }
972
+ };
973
+ var shellClose = {
974
+ name: "shell_close",
975
+ description: "\u5173\u6389\u4E00\u4E2A\u6301\u4E45\u4F1A\u8BDD\uFF08\u6740\u6574\u68F5\u8FDB\u7A0B\u6811\uFF0C\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002\u53C2\u6570: id(\u5FC5\u586B)\u3002\u540D\u989D\u6EE1\u4E86\u3001\u6216\u8005\u8FD9\u4E2A\u73AF\u5883\u4E0D\u518D\u9700\u8981\u4E86\u5C31\u5173\u6389 \u2014\u2014 \u4F1A\u8BDD\u6D3B\u7740\u5C31\u4E00\u76F4\u5360\u4E00\u4E2A\u540D\u989D\u3002",
976
+ parameters: {
977
+ type: "object",
978
+ properties: { id: { type: "string", description: "\u4F1A\u8BDD id" } },
979
+ required: ["id"]
980
+ },
981
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
982
+ operation: "file_read",
983
+ describeTarget: (args) => String(args["id"] ?? ""),
984
+ execute: async (args, ctx) => {
985
+ const id = String(args["id"] ?? "");
986
+ if (await closeShell(ctx.sessionId, id)) return ok(closedNotice(id));
987
+ const exists = listShells(ctx.sessionId).some((s) => s.id === id);
988
+ return exists ? ok(alreadyGoneNotice(id)) : notFound(id);
989
+ }
990
+ };
991
+ var shellList = {
992
+ name: "shell_list",
993
+ description: "\u5217\u51FA\u672C\u6B21\u4F1A\u8BDD\u5F00\u8FC7\u7684\u6301\u4E45 shell \u4F1A\u8BDD\uFF1Aid\u3001\u5DE5\u4F5C\u76EE\u5F55\u3001\u6D3B\u4E86\u591A\u4E45\u3001\u7A7A\u95F2\u591A\u4E45\u3001\u8F93\u51FA\u5B57\u8282\u6570\u3002\u4E0D\u786E\u5B9A\u624B\u91CC\u6709\u54EA\u4E9B\u4F1A\u8BDD\u3001\u6216\u8005\u649E\u4E0A\u540D\u989D\u4E0A\u9650\u65F6\u5148\u770B\u8FD9\u4E2A\u3002",
994
+ parameters: { type: "object", properties: {}, required: [] },
995
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
996
+ operation: "file_read",
997
+ execute: (_args, ctx) => {
998
+ const shells = listShells(ctx.sessionId);
999
+ if (shells.length === 0) return Promise.resolve(ok(NO_SHELLS));
1000
+ return Promise.resolve(ok([listHead(shells.length), ...shells.map(describeShell)].join("\n")));
1001
+ }
1002
+ };
1003
+ var SHELL_TOOLS = [
1004
+ shellOpen,
1005
+ shellSend,
1006
+ shellRead,
1007
+ shellClose,
1008
+ shellList
1009
+ ];
1010
+ // src/tasks.ts
1011
+ import { getJob as getJob3, listJobs as listJobs3, readJob as readJob3, stopJob as stopJob3, waitJob as waitJob2 } from "@epoch-agent/infra";
1012
+ // src/job-notice.ts
1013
+ var KIND_MARK = {
1014
+ command: "\u547D\u4EE4",
1015
+ shell: "\u4F1A\u8BDD",
1016
+ agent: "\u5B50 agent"
1017
+ };
1018
+ function stateOf2(status, exitCode, seconds) {
1019
+ switch (status) {
1020
+ case "running":
1021
+ return `\u8FD0\u884C\u4E2D ${seconds}s`;
1022
+ case "exited":
1023
+ return `\u5DF2\u7ED3\u675F\uFF0C\u9000\u51FA\u7801 ${exitCode}`;
1024
+ case "killed":
1025
+ return "\u5DF2\u505C\u6B62";
1026
+ case "closed":
1027
+ return "\u5DF2\u5173\u95ED";
1028
+ case "reaped":
1029
+ return "\u7A7A\u95F2\u5DF2\u88AB\u56DE\u6536";
1030
+ case "failed":
1031
+ return "\u542F\u52A8\u5931\u8D25";
1032
+ }
1033
+ }
1034
+ function describeJob(info) {
1035
+ const dur = Math.round(((info.endedAt ?? Date.now()) - info.startedAt) / 1e3);
1036
+ const trunc = info.truncated ? "\uFF0C\u5F00\u5934\u5DF2\u6EDA\u51FA\u7F13\u51B2" : "";
1037
+ return `${info.id} [${KIND_MARK[info.kind]}] ${stateOf2(info.status, info.exitCode, dur)}\uFF08${info.outputBytes} \u5B57\u8282\u8F93\u51FA${trunc}\uFF09 ${info.label}`;
1038
+ }
1039
+ var NO_JOBS = "\u6CA1\u6709\u540E\u53F0\u4F5C\u4E1A\u3002\u7528 terminal \u7684 background: true \u8D77\u4E00\u6761\u540E\u53F0\u547D\u4EE4\uFF0C\u6216\u8005 shell_open \u5F00\u4E00\u4E2A\u6301\u4E45 shell \u4F1A\u8BDD\u3002";
1040
+ function listHead2(count) {
1041
+ return `${count} \u4E2A\u540E\u53F0\u4F5C\u4E1A\uFF08\u540E\u53F0\u547D\u4EE4 + \u6301\u4E45 shell \u4F1A\u8BDD\uFF0C\u4E00\u8D77\u5217\uFF09`;
1042
+ }
1043
+ function notFoundNotice(id) {
1044
+ return `\u6CA1\u6709\u7F16\u53F7\u4E3A ${id} \u7684\u540E\u53F0\u4F5C\u4E1A`;
1045
+ }
1046
+ var NOT_FOUND_SUGGESTION = "\u7528 task_list \u770B\u5F53\u524D\u6709\u54EA\u4E9B\uFF08\u540E\u53F0\u547D\u4EE4\u548C\u6301\u4E45\u4F1A\u8BDD\u90FD\u5728\u91CC\u9762\uFF09";
1047
+ var NO_NEW_OUTPUT2 = "\uFF08\u8FD9\u4E00\u6BB5\u6CA1\u6709\u65B0\u8F93\u51FA\uFF09";
1048
+ function waitTimedOutNotice(seconds) {
1049
+ return `
1050
+ \u7B49\u4E86 ${seconds}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`;
1051
+ }
1052
+ var WAIT_DONE = "\n\u7528 task_output \u53D6\u8F93\u51FA\u3002";
1053
+ function neverEndsNotice(id) {
1054
+ return `
1055
+ ${id} \u662F\u4E00\u4E2A\u6301\u4E45 shell \u4F1A\u8BDD\uFF0C\u5B83\u6CA1\u6709\u300C\u7ED3\u675F\u300D\u8FD9\u4E2A\u72B6\u6001 \u2014\u2014 \u4E00\u76F4\u7B49\u4E0B\u53BB\u4E5F\u4E0D\u4F1A\u6709\u7ED3\u679C\u3002
1056
+ \u8981\u770B\u5B83\u73B0\u5728\u7684\u8F93\u51FA\u7528 shell_read({ id: '${id}' })\uFF0C\u8981\u53D1\u4E0B\u4E00\u53E5\u7528 shell_send\uFF0C\u8981\u6536\u6389\u5B83\u7528 shell_close \u6216 task_stop\u3002`;
1057
+ }
1058
+ function stoppedNotice(id, kind) {
1059
+ return kind === "shell" ? `${id} \u5DF2\u5173\u95ED\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002\u90A3\u4E2A\u4F1A\u8BDD\u91CC\u7684\u73AF\u5883\uFF08cd / venv / REPL\uFF09\u6CA1\u4E86\uFF0C\u8981\u7528\u5F97\u91CD\u5F00\u4E00\u4E2A\u3002` : `${id} \u5DF2\u505C\u6B62\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002`;
1060
+ }
1061
+ function alreadyStoppedNotice(id) {
1062
+ return `${id} \u5DF2\u7ECF\u4E0D\u5728\u8FD0\u884C\u4E2D\u4E86\u3002`;
1063
+ }
1064
+ // src/jobs.ts
1065
+ import { jobDetail as jobDetail3 } from "@epoch-agent/infra";
1066
+ function jobSandbox(sessionId, id) {
1067
+ return jobDetail3(sessionId, id)?.sandbox;
1068
+ }
1069
+ // src/tasks.ts
1070
+ var DEFAULT_WAIT_MS2 = 3e4;
1071
+ var MAX_WAIT_MS2 = 3e5;
1072
+ function ok2(output) {
1073
+ return { success: true, output, duration: 0 };
1074
+ }
1075
+ function notFound2(id) {
1076
+ return {
1077
+ success: false,
1078
+ output: "",
1079
+ error: { code: 2101, message: notFoundNotice(id), suggestion: NOT_FOUND_SUGGESTION },
606
1080
  duration: 0
607
1081
  };
608
1082
  }
609
1083
  var taskList = {
610
1084
  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",
1085
+ description: "\u5217\u51FA\u672C\u6B21\u4F1A\u8BDD\u8D77\u8FC7\u7684**\u540E\u53F0\u4F5C\u4E1A** \u2014\u2014 terminal \u7684 background: true \u8D77\u7684\u540E\u53F0\u547D\u4EE4\uFF0C\u548C shell_open \u5F00\u7684\u6301\u4E45 shell \u4F1A\u8BDD\uFF0C\u4E24\u79CD\u4E00\u8D77\u5217\uFF0C\u6BCF\u884C\u6807\u51FA\u662F\u54EA\u4E00\u79CD\uFF08[\u547D\u4EE4] / [\u4F1A\u8BDD]\uFF09\u3002\u542B\u72B6\u6001\u3001\u9000\u51FA\u7801\u3001\u8F93\u51FA\u5B57\u8282\u6570\u3002\u4E0D\u786E\u5B9A\u624B\u91CC\u6709\u4EC0\u4E48\u5728\u8DD1\u5C31\u5148\u770B\u8FD9\u4E2A\u3002",
612
1086
  parameters: { type: "object", properties: {}, required: [] },
613
1087
  annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
614
1088
  operation: "file_read",
615
1089
  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
- );
1090
+ const jobs = listJobs3(ctx.sessionId);
1091
+ if (jobs.length === 0) return Promise.resolve(ok2(NO_JOBS));
1092
+ return Promise.resolve(ok2([listHead2(jobs.length), ...jobs.map(describeJob)].join("\n")));
623
1093
  }
624
1094
  };
625
1095
  var taskOutputTool = {
626
1096
  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`,
1097
+ description: `\u53D6\u4E00\u4E2A\u540E\u53F0\u4F5C\u4E1A\u7684\u8F93\u51FA\uFF08\u540E\u53F0\u547D\u4EE4 t1 \u548C\u6301\u4E45\u4F1A\u8BDD s1 \u90FD\u884C\uFF09\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
1098
  parameters: {
629
1099
  type: "object",
630
1100
  properties: {
631
- id: { type: "string", description: "\u4EFB\u52A1 id\uFF0C\u4F8B\u5982 t1" },
1101
+ id: { type: "string", description: "\u4F5C\u4E1A id\uFF0C\u4F8B\u5982 t1\uFF08\u540E\u53F0\u547D\u4EE4\uFF09\u6216 s1\uFF08\u6301\u4E45\u4F1A\u8BDD\uFF09" },
632
1102
  since: { type: "number", description: "\u4E0A\u6B21\u8FD4\u56DE\u7684 nextCursor\uFF1B\u4E0D\u7ED9\u5C31\u4ECE\u5934\u53D6" }
633
1103
  },
634
1104
  required: ["id"]
@@ -639,29 +1109,42 @@ var taskOutputTool = {
639
1109
  execute: (args, ctx) => {
640
1110
  const id = String(args["id"] ?? "");
641
1111
  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);
1112
+ const result = readJob3(
1113
+ ctx.sessionId,
1114
+ id,
1115
+ Number.isFinite(since) && since > 0 ? since : 0,
1116
+ MAX_OUTPUT_CHUNK
1117
+ );
1118
+ if (!result) return Promise.resolve(notFound2(id));
1119
+ const head = describeJob(result.info);
645
1120
  const notes = [];
646
1121
  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);
1122
+ if (result.hasMore) notes.push(describeMore(result.nextCursor));
1123
+ const sb = jobSandbox(ctx.sessionId, id);
649
1124
  const sbErr = sb ? sandboxError(sb) : null;
650
1125
  if (sbErr) notes.push(`${sbErr.message}
651
1126
  ${sbErr.suggestion}`);
652
- const body = result.output || "\uFF08\u8FD9\u4E00\u6BB5\u6CA1\u6709\u65B0\u8F93\u51FA\uFF09";
653
1127
  return Promise.resolve(
654
- ok([head, ...notes, "", body, "", `nextCursor: ${result.nextCursor}`].join("\n"))
1128
+ ok2(
1129
+ [
1130
+ head,
1131
+ ...notes,
1132
+ "",
1133
+ result.output || NO_NEW_OUTPUT2,
1134
+ "",
1135
+ `nextCursor: ${result.nextCursor}`
1136
+ ].join("\n")
1137
+ )
655
1138
  );
656
1139
  }
657
1140
  };
658
1141
  var taskWait = {
659
1142
  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",
1143
+ description: "\u963B\u585E\u7B49\u4E00\u4E2A\u540E\u53F0\u4F5C\u4E1A\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\u6301\u4E45 shell \u4F1A\u8BDD\uFF08s1\uFF09\u538B\u6839\u6CA1\u6709\u300C\u7ED3\u675F\u300D\u8FD9\u4E2A\u72B6\u6001\uFF0C\u7B49\u5B83\u4F1A**\u7ACB\u523B**\u8FD4\u56DE\u4E00\u53E5\u8BF4\u660E\u800C\u4E0D\u662F\u5E72\u7B49\u3002",
661
1144
  parameters: {
662
1145
  type: "object",
663
1146
  properties: {
664
- id: { type: "string", description: "\u4EFB\u52A1 id" },
1147
+ id: { type: "string", description: "\u4F5C\u4E1A id" },
665
1148
  timeoutMs: { type: "number", description: "\u6700\u591A\u7B49\u591A\u4E45\uFF08\u6BEB\u79D2\uFF09" }
666
1149
  },
667
1150
  required: ["id"]
@@ -673,41 +1156,37 @@ var taskWait = {
673
1156
  const id = String(args["id"] ?? "");
674
1157
  const requested = Number(args["timeoutMs"]);
675
1158
  const timeout = Math.min(
676
- Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_MS,
677
- MAX_WAIT_MS
1159
+ Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_MS2,
1160
+ MAX_WAIT_MS2
678
1161
  );
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);
1162
+ const result = await waitJob2(ctx.sessionId, id, timeout);
1163
+ if (!result) return notFound2(id);
1164
+ const tail = result.neverEnds ? neverEndsNotice(id) : result.timedOut ? waitTimedOutNotice(Math.round(timeout / 1e3)) : WAIT_DONE;
1165
+ return ok2(describeJob(result.info) + tail);
684
1166
  }
685
1167
  };
686
1168
  var taskStop = {
687
1169
  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",
1170
+ description: "\u505C\u6389\u4E00\u4E2A\u540E\u53F0\u4F5C\u4E1A\uFF08\u6740\u6574\u68F5\u8FDB\u7A0B\u6811\uFF0C\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002\u53C2\u6570: id(\u5FC5\u586B)\u3002\u540E\u53F0\u547D\u4EE4\uFF08t1\uFF09\u548C\u6301\u4E45\u4F1A\u8BDD\uFF08s1\uFF09\u90FD\u80FD\u505C \u2014\u2014 \u505C\u4E00\u4E2A\u6301\u4E45\u4F1A\u8BDD\u7B49\u4EF7\u4E8E shell_close\uFF0C\u90A3\u4E2A\u4F1A\u8BDD\u91CC\u7684\u73AF\u5883\uFF08cd / venv / REPL\uFF09\u4F1A\u4E00\u8D77\u6CA1\u3002",
689
1171
  parameters: {
690
1172
  type: "object",
691
- properties: { id: { type: "string", description: "\u4EFB\u52A1 id" } },
1173
+ properties: { id: { type: "string", description: "\u4F5C\u4E1A id" } },
692
1174
  required: ["id"]
693
1175
  },
694
- // 它真的会杀进程,所以**不是** readOnly
695
1176
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
696
1177
  operation: "file_read",
697
1178
  describeTarget: (args) => String(args["id"] ?? ""),
698
1179
  execute: async (args, ctx) => {
699
1180
  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);
1181
+ const before = getJob3(ctx.sessionId, id);
1182
+ if (!before) return notFound2(id);
1183
+ if (!await stopJob3(ctx.sessionId, id)) {
1184
+ return ok2(alreadyStoppedNotice(id));
705
1185
  }
706
- return ok(`${id} \u5DF2\u505C\u6B62\uFF08\u542B\u5B83\u8D77\u7684\u5B50\u8FDB\u7A0B\uFF09\u3002`);
1186
+ return ok2(stoppedNotice(id, before.kind));
707
1187
  }
708
1188
  };
709
1189
  var TASK_TOOLS = [taskList, taskOutputTool, taskWait, taskStop];
710
-
711
1190
  // src/index.ts
712
1191
  var DEFAULT_TIMEOUT = 6e4;
713
1192
  var PTY_TIMEOUT = 3e4;
@@ -720,14 +1199,6 @@ function detectCd(command) {
720
1199
  if (!rawTarget) return null;
721
1200
  return rawTarget.trim().replace(/^["']|["']$/g, "");
722
1201
  }
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
1202
  function toSandboxFailure(sb, output, duration) {
732
1203
  const err = sandboxError(sb);
733
1204
  if (!err) return null;
@@ -735,8 +1206,6 @@ function toSandboxFailure(sb, output, duration) {
735
1206
  success: false,
736
1207
  output,
737
1208
  error: {
738
- // 2008 = 被沙箱挡下(命令跑了,动作被拒)
739
- // 2009 = 沙箱自己没起来(命令**没有**跑)
740
1209
  code: sb.failure?.kind === "runner-failed" ? 2009 : 2008,
741
1210
  message: err.message,
742
1211
  suggestion: err.suggestion
@@ -775,9 +1244,6 @@ var terminalTool = {
775
1244
  required: ["command"]
776
1245
  },
777
1246
  supportsParallel: false,
778
- // 任意 shell 命令:能删数据、能联网,两个方向都到顶。
779
- // `openWorldHint: true` 同时让 core 把输出包进 `<tool_output untrusted="true">`
780
- // —— `curl` 抓回来的东西和 web_fetch 抓的没有区别。
781
1247
  annotations: {
782
1248
  readOnlyHint: false,
783
1249
  destructiveHint: true,
@@ -785,7 +1251,6 @@ var terminalTool = {
785
1251
  openWorldHint: true
786
1252
  },
787
1253
  operation: "command",
788
- // 审批目标就是完整命令串(五候选里的 command 也能猜到,这里写明确的)
789
1254
  describeTarget: (args) => String(args["command"] ?? ""),
790
1255
  async execute(args, ctx) {
791
1256
  const command = String(args["command"] ?? "");
@@ -801,7 +1266,7 @@ var terminalTool = {
801
1266
  duration: 0
802
1267
  };
803
1268
  }
804
- const danger = checkDangerousCommand(command);
1269
+ const danger = checkDangerousCommand2(command);
805
1270
  if (danger.dangerous) {
806
1271
  return {
807
1272
  success: false,
@@ -817,7 +1282,7 @@ var terminalTool = {
817
1282
  const baseDir = ctx.workDir || process.cwd();
818
1283
  let cwd = baseDir;
819
1284
  if (workdir) {
820
- if (!isInWorkspace(workdir, baseDir, ctx.extraRoots)) {
1285
+ if (!isInWorkspace2(workdir, baseDir, ctx.extraRoots)) {
821
1286
  return {
822
1287
  success: false,
823
1288
  output: "",
@@ -829,7 +1294,7 @@ var terminalTool = {
829
1294
  duration: 0
830
1295
  };
831
1296
  }
832
- cwd = isAbsolute(workdir) ? resolve(workdir) : resolve(baseDir, workdir);
1297
+ cwd = isAbsolute2(workdir) ? resolve2(workdir) : resolve2(baseDir, workdir);
833
1298
  }
834
1299
  if (isBackground) {
835
1300
  const started = startTask(
@@ -867,12 +1332,10 @@ var terminalTool = {
867
1332
  timeout: PTY_TIMEOUT,
868
1333
  cwd,
869
1334
  tag: ctx.sessionId + "::pty",
1335
+ sessionId: ctx.sessionId,
870
1336
  ...stdin ? { stdin } : {},
871
1337
  ...ctx.signal ? { signal: ctx.signal } : {},
872
1338
  ...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
873
- // 方案 46 PR-5:PTY 这条路 2026-08-17 起也有 OS 强制的写入边界。
874
- // tty 分配 / 窗口尺寸 / 信号转发三样在真 Seatbelt 上逐条验过照旧,
875
- // 实测表在 pty.ts 的文件头 —— 那三样正是方案 §三 点名要单独验的
876
1339
  sandbox: sandboxPolicyFor(ctx, baseDir)
877
1340
  });
878
1341
  const duration = Date.now() - startTime;
@@ -920,10 +1383,6 @@ ${CD_NOTICE(cdTarget)}`;
920
1383
  signal: ctx.signal,
921
1384
  cwd,
922
1385
  ...ctx.onOutput ? { onOutput: ctx.onOutput } : {},
923
- // 方案 46 PR-1:`terminal` 的默认路径从这里起有 OS 强制的写入边界。
924
- // **边界按工作区主根算,不按 `cwd` 算** —— `cwd` 可能是工作区的子目录
925
- // (模型传了 workdir),按它算的话「写工作区里另一个目录」会被挡,
926
- // 而那是一次完全正常的操作
927
1386
  sandbox: sandboxPolicyFor(ctx, baseDir)
928
1387
  });
929
1388
  const duration = Date.now() - startTime;
@@ -979,22 +1438,33 @@ ${CD_NOTICE(cdTarget)}`;
979
1438
  var terminalPlugin = {
980
1439
  name: "@epoch-agent/plugin-terminal",
981
1440
  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]
1441
+ description: "\u7EC8\u7AEF\u547D\u4EE4\u6267\u884C + PTY \u4EA4\u4E92 + \u540E\u53F0\u4EFB\u52A1 + \u6301\u4E45 shell \u4F1A\u8BDD [Hermes/Codex]",
1442
+ tools: [terminalTool, ...TASK_TOOLS, ...SHELL_TOOLS]
987
1443
  };
988
1444
  export {
1445
+ MAX_SHELLS,
1446
+ MAX_SHELL_CHUNK,
1447
+ MAX_SHELL_OUTPUT,
989
1448
  MAX_TASKS,
1449
+ SHELL_IDLE_MS,
1450
+ SHELL_SWEEP_MS,
1451
+ SHELL_TOOLS,
990
1452
  TASK_TOOLS,
991
1453
  cleanupBackgroundProcesses,
1454
+ clearAllShells,
992
1455
  clearAllTasks,
1456
+ closeShell,
1457
+ describeShell,
993
1458
  describeTask,
1459
+ listShells,
994
1460
  listTasks,
1461
+ openShell,
1462
+ readShell,
995
1463
  rekeyTasks,
1464
+ sendToShell,
996
1465
  startTask,
997
1466
  stopTask,
1467
+ sweepIdleShells,
998
1468
  taskOutput,
999
1469
  taskSandbox,
1000
1470
  terminalPlugin,