@vincemakes/kiso-tools-node 0.45.3 → 0.46.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 CHANGED
@@ -17,11 +17,12 @@
17
17
  * states what was dropped (deterministic per file state), so the model
18
18
  * always has a path to the full content.
19
19
  */
20
- import { execFile, execFileSync, spawn } from "node:child_process";
20
+ import { execFile } from "node:child_process";
21
21
  import { promisify } from "node:util";
22
- import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
23
  import { Worker } from "node:worker_threads";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { killTree, NO_BASH, processStartTime, RotatingOutput, startCommand } from "./process.js";
25
26
  import { createHash } from "node:crypto";
26
27
  import { tmpdir } from "node:os";
27
28
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -78,6 +79,16 @@ const ACI2_LINES_SHOWN = 5;
78
79
  const ACI2_OFFSETS_KEPT = 500;
79
80
  const OUTPUT_CAP = 100_000; // chars of output a tool result may carry
80
81
  const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
82
+ /** ADR-0058 §3: with tasks, how long the shell waits before a command
83
+ * continues as a task (the model's own value wins). */
84
+ const DEFAULT_FOREGROUND_MS = 60_000;
85
+ /** A promoted task's output file rotates here, as a runner's does. */
86
+ const TASK_OUTPUT_CAP = 64 * 1024 * 1024;
87
+ /** A task's stop: SIGTERM, this long, then the confirmed sweep. */
88
+ const TASK_STOP_GRACE_MS = 5_000;
89
+ /** ADR-0058 Amendment 7: how long task_stop waits for the end it reports —
90
+ * the runner's journal poll, TERM, the grace, KILL and the sweep. */
91
+ const TASK_STOP_WAIT_MS = 8_000;
81
92
  // the token round: the scoped-read defaults — read_file shows the head 200 lines
82
93
  // of a large file (with an actionable continuation note, never a silent
83
94
  // drop), search_text caps at 50 excerpts, list_dir at 200 entries. The
@@ -167,6 +178,25 @@ export class PathEscapeError extends Error {
167
178
  this.name = "PathEscapeError";
168
179
  }
169
180
  }
181
+ /** ADR-0058 §4: read_file's resolution — the workspace, or an absolute path
182
+ * inside one of the host-granted read-only roots. The containment check is
183
+ * the workspace's own, applied to that root. */
184
+ function resolveReadable(opts, input, sessionId) {
185
+ if (isAbsolute(input)) {
186
+ // 3b: the session's own task directory, when the host wires tasks
187
+ const taskRoot = opts.tasks?.(sessionId)?.root;
188
+ for (const root of [...(opts.extraReadRoots ?? []), ...(taskRoot !== undefined ? [taskRoot] : [])]) {
189
+ if (!existsSync(root))
190
+ continue;
191
+ for (const base of [root, realpathSync(root)]) {
192
+ const rel = relative(base, input);
193
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel))
194
+ return { full: resolveWithinRoot(root, rel), root };
195
+ }
196
+ }
197
+ }
198
+ return { full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot };
199
+ }
170
200
  export function resolveWithinRoot(root, input) {
171
201
  if (isAbsolute(input)) {
172
202
  throw new PathEscapeError(`absolute paths are not allowed — use workspace-relative paths: ${input}`);
@@ -264,6 +294,10 @@ async function inodeReadPolicy(root, full) {
264
294
  return `not a regular file — refusing to read (${full})`;
265
295
  if (st.nlink <= 1)
266
296
  return null;
297
+ // Windows: no find can list a file's links (find there is the system's
298
+ // text search) — refused without a scan, and the refusal says why
299
+ if (process.platform === "win32")
300
+ return `file has ${st.nlink} hard links, and on Windows kiso cannot check where they all are — refusing to read (${full})`;
267
301
  const key = `${st.dev}:${st.ino}`;
268
302
  const cached = inodeVerdict.get(key);
269
303
  if (cached !== undefined)
@@ -350,16 +384,16 @@ export function readFileTool(opts) {
350
384
  effects: { precommitSafe: true, concurrency: "shared" },
351
385
  promptSnippet: "read_file — whole files or offset/limit ranges, workspace-relative paths",
352
386
  promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
353
- execute: async ({ path, offset, limit }) => {
387
+ execute: async ({ path, offset, limit }, ctx) => {
354
388
  const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
355
389
  try {
356
- const full = resolveWithinRoot(opts.workspaceRoot, path);
390
+ const { full, root } = resolveReadable(opts, path, ctx?.sessionId);
357
391
  // the credential store is never served (protected.ts) — checked
358
392
  // by the disk's own resolution and by inode, before any read
359
393
  const guard = protectedIdentity(opts.protectedFiles);
360
394
  if (isProtectedPath(full, guard))
361
395
  return precondition(protectedRefusalText("read_file", path));
362
- const denied = await inodeReadPolicy(opts.workspaceRoot, full);
396
+ const denied = await inodeReadPolicy(root, full);
363
397
  if (denied !== null)
364
398
  return escapeResult(denied);
365
399
  // DC-54 — the ceiling. `read_file` had the same unbounded
@@ -1133,9 +1167,15 @@ export function editFileTool(opts) {
1133
1167
  // against the first one's output. Still all or nothing: nothing is
1134
1168
  // written unless every hunk resolves, and each must match exactly
1135
1169
  // once in the text it meets (ACI-2).
1136
- let edited = text;
1170
+ // Windows P3: a uniformly CRLF file is matched on its LF form and
1171
+ // written back CRLF — the model's search text uses LF, and an edit
1172
+ // never changes the file's endings. A file mixing the two keeps
1173
+ // exact matching: normalizing it would rewrite untouched lines.
1174
+ const crlf = text.includes("\r\n") && !/(^|[^\r])\n/.test(text);
1175
+ const lf = (t) => (crlf ? t.replace(/\r\n/g, "\n") : t);
1176
+ let edited = lf(text);
1137
1177
  for (let i = 0; i < hunks.length; i += 1) {
1138
- const h = hunks[i];
1178
+ const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
1139
1179
  const { count, offsets } = occurrencesOf(edited, h.search);
1140
1180
  const which = hunks.length === 1 && edits === undefined ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1–${i}`} applied)`;
1141
1181
  if (count === 0) {
@@ -1165,7 +1205,10 @@ export function editFileTool(opts) {
1165
1205
  // E group: safe replacement — never rewrite a shared external inode via a hard link.
1166
1206
  // round 8: the edited file keeps its mode.
1167
1207
  preservedMode = statSync(full).mode & 0o7777;
1168
- writeFileSync(tmp, edited, "utf8");
1208
+ // ONE buffer is written and hashed: the revision returned is the
1209
+ // file's on disk, CRLF or not (the review's finding)
1210
+ const bytesOut = Buffer.from(crlf ? edited.replace(/\n/g, "\r\n") : edited, "utf8");
1211
+ writeFileSync(tmp, bytesOut);
1169
1212
  chmodSync(tmp, preservedMode);
1170
1213
  // WR-1A ③: revalidate against the citation right before the
1171
1214
  // replacement commits.
@@ -1180,7 +1223,7 @@ export function editFileTool(opts) {
1180
1223
  // WR-1A ①: post-effect — fatal, the rename already landed.
1181
1224
  return postEffectEscape("edit", path);
1182
1225
  }
1183
- return { content: `edited ${path}\n[${contentRevision(Buffer.from(edited, "utf8"))}]`, isError: false };
1226
+ return { content: `edited ${path}\n[${contentRevision(bytesOut)}]`, isError: false };
1184
1227
  }
1185
1228
  catch (err) {
1186
1229
  // round 8: a failed edit never leaves a temp file behind.
@@ -1205,51 +1248,154 @@ export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protec
1205
1248
  * read-only shell allow holds a shell read to the same definition rather
1206
1249
  * than a copy of it. */
1207
1250
  export { isCredentialName, isCredentialPath } from "./corpus.js";
1251
+ // ADR-0058: the process task backend (the runner ships beside it)
1252
+ export { processTaskBackend } from "./process-backend.js";
1253
+ /** How a task ended, in a few words: its signal, its exit code, or why it
1254
+ * never ran. */
1255
+ function howEnded(task) {
1256
+ const s = task.state;
1257
+ if (s.kind === "unknown")
1258
+ return "its runner is gone and its end is unknown";
1259
+ if (s.error !== undefined)
1260
+ return `it never started: ${s.error}`;
1261
+ if (s.signal !== undefined && s.signal !== null)
1262
+ return s.signal;
1263
+ if (s.exitCode !== undefined && s.exitCode !== null)
1264
+ return `exit code ${s.exitCode}`;
1265
+ return "no exit code";
1266
+ }
1267
+ /** The last `bytes` of a task's output file ("" when there is none). */
1268
+ function outputTail(path, bytes = 2_048) {
1269
+ try {
1270
+ const fd = openSync(path, "r");
1271
+ try {
1272
+ const size = fstatSync(fd).size;
1273
+ const len = Math.min(size, bytes);
1274
+ const buf = Buffer.alloc(len);
1275
+ readSync(fd, buf, 0, len, size - len);
1276
+ return `${size > len ? "…" : ""}${buf.toString("utf8")}`.trim();
1277
+ }
1278
+ finally {
1279
+ closeSync(fd);
1280
+ }
1281
+ }
1282
+ catch {
1283
+ return "";
1284
+ }
1285
+ }
1286
+ /** This process as a promoted task's owner — read once. */
1287
+ let selfIdentity;
1288
+ function ownerIdentity() {
1289
+ if (selfIdentity === undefined) {
1290
+ const id = processStartTime(process.pid);
1291
+ selfIdentity = { pid: process.pid, startedAt: id.kind === "running" ? id.startedAt : "" };
1292
+ }
1293
+ return selfIdentity;
1294
+ }
1208
1295
  export function shellTool(opts) {
1296
+ // ADR-0058 (3b): the task-aware contract exists only where tasks are
1297
+ // wired; a host without them keeps today's schema byte for byte.
1298
+ const withTasks = opts.tasks !== undefined;
1299
+ // Windows P1: commands run through Git Bash there, /bin/sh elsewhere
1300
+ const sh = process.platform === "win32" ? "bash" : "/bin/sh";
1209
1301
  return defineTool({
1210
1302
  name: "shell",
1211
- description: "Run a shell command through /bin/sh with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. Fails loudly on timeout or non-zero exit.",
1212
- parameters: {
1213
- type: "object",
1214
- properties: {
1215
- command: { type: "string", description: "The command to run" },
1216
- timeoutMs: { type: "number", description: "Timeout in ms (default 30000)" },
1303
+ description: withTasks
1304
+ ? `Run a shell command through ${sh} with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. A command still running after foregroundMs is never killed: it continues as a background task, the result gives its id and output path, and you are notified when it ends. Fails loudly on a non-zero exit.`
1305
+ : `Run a shell command through ${sh} with the workspace root as the working directory: builds, tests, git, package managers, curl for HTTP APIs, system queries. Side effects are real; the human may be asked to approve the run. Fails loudly on timeout or non-zero exit.`,
1306
+ parameters: withTasks
1307
+ ? {
1308
+ type: "object",
1309
+ properties: {
1310
+ command: { type: "string", description: "The command to run" },
1311
+ foregroundMs: { type: "number", description: "How long to wait for the result, in ms (default 60000); if you need the result to go on, allow enough time" },
1312
+ background: {
1313
+ type: "boolean",
1314
+ description: "Run independently as a task. Use for services/watchers or work whose exit result is not needed next. If you need the result, keep it foreground and raise foregroundMs. You are notified when it ends; do not sleep/poll.",
1315
+ },
1316
+ readyWhen: { type: "string", description: "For a continuing service, wait up to foregroundMs for this literal output before returning; the task keeps running afterward." },
1317
+ timeoutMs: { type: "number", description: "Deprecated: use foregroundMs" },
1318
+ },
1319
+ required: ["command"],
1320
+ additionalProperties: false,
1321
+ }
1322
+ : {
1323
+ type: "object",
1324
+ properties: {
1325
+ command: { type: "string", description: "The command to run" },
1326
+ timeoutMs: { type: "number", description: "Timeout in ms (default 30000)" },
1327
+ },
1328
+ required: ["command"],
1329
+ additionalProperties: false,
1217
1330
  },
1218
- required: ["command"],
1219
- additionalProperties: false,
1220
- },
1221
1331
  promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
1222
1332
  promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
1223
- execute: async ({ command, timeoutMs }, ctx) => {
1224
- const timeout = timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
1333
+ execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
1334
+ // the session's tasks, when wired and this call has a session
1335
+ const tasks = opts.tasks?.(ctx.sessionId);
1336
+ const timeout = foregroundMs ?? timeoutMs ?? (tasks !== undefined ? DEFAULT_FOREGROUND_MS : DEFAULT_SHELL_TIMEOUT_MS);
1225
1337
  // E group: a PRE-aborted signal never spawns the command.
1226
1338
  if (ctx.signal.aborted) {
1227
1339
  return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
1228
1340
  }
1341
+ // bootstrap #3 (finding #7): the shell child NEVER inherits kiso's own
1342
+ // provider credentials by default — only the explicit
1343
+ // shellEnv: "inherit" opt-in keeps them. A record (ADR-0031
1344
+ // Amendment 1) rides on the STRIPPED base and wins per key.
1345
+ const env = opts.shellEnv === "inherit" ? process.env : { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) };
1346
+ if (background === true) {
1347
+ if (tasks === undefined)
1348
+ return { content: "background tasks are not available here — run the command in the foreground", isError: true, errorKind: "precondition" };
1349
+ const task = await tasks.start({
1350
+ command,
1351
+ cwd: opts.workspaceRoot,
1352
+ env,
1353
+ ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
1354
+ ...(readyWhen !== undefined ? { readyWhen } : {}),
1355
+ profile: readyWhen !== undefined ? "service" : "oneshot",
1356
+ });
1357
+ const settle = tasks.awaitSettled?.bind(tasks);
1358
+ if (readyWhen === undefined || settle === undefined)
1359
+ return { content: `started background task ${task.id}. ${taskNote(task.outputPath)}`, isError: false };
1360
+ return waitForReady(tasks, settle, task, readyWhen, timeout, ctx);
1361
+ }
1229
1362
  return new Promise((resolvePromise) => {
1230
1363
  // detached: the command gets its OWN process group, so a
1231
1364
  // timeout/abort can kill the WHOLE TREE (children included),
1232
1365
  // not just the outer shell (Area 4). cwd is the workspace.
1233
- // bootstrap #3 (finding #7): the shell child NEVER inherits kiso's own
1234
- // provider credentials by default — only the explicit
1235
- // shellEnv: "inherit" opt-in keeps them. A record (ADR-0031
1236
- // Amendment 1) rides on the STRIPPED base and wins per key.
1237
- const child = spawn(command, {
1238
- shell: true,
1239
- detached: true,
1240
- cwd: opts.workspaceRoot,
1241
- stdio: ["ignore", "pipe", "pipe"],
1242
- env: opts.shellEnv === "inherit"
1243
- ? process.env
1244
- : { ...strippedShellEnv(process.env, opts.secretEnvNames), ...(opts.shellEnv ?? {}) },
1245
- });
1366
+ let child;
1367
+ try {
1368
+ child = startCommand(command, { cwd: opts.workspaceRoot, env });
1369
+ }
1370
+ catch (err) {
1371
+ // win32 with no usable bash: an answer the model can act on
1372
+ if (err.code !== NO_BASH)
1373
+ throw err;
1374
+ resolvePromise({ content: `shell failed: ${err.message}`, isError: true, errorKind: "fatal" });
1375
+ return;
1376
+ }
1246
1377
  let stdout = "";
1247
1378
  let stderr = "";
1248
1379
  let stdoutDropped = 0;
1249
1380
  let stderrDropped = 0;
1250
- let exited = false;
1251
1381
  let settled = false;
1252
1382
  let killing = false;
1383
+ // ADR-0058 (3b): once promoted, the child's output goes to its task
1384
+ let promoted = null;
1385
+ let stopping = false;
1386
+ let readySeen = false;
1387
+ /** 3e: the unregister of this command's detach — while it runs in the foreground */
1388
+ let undetachable = () => { };
1389
+ let recent = ""; // the tail readyWhen is matched against
1390
+ const matchReady = (text) => {
1391
+ if (readyWhen === undefined || readySeen)
1392
+ return false;
1393
+ recent = (recent + text).slice(-(readyWhen.length + 65_536));
1394
+ if (!recent.includes(readyWhen))
1395
+ return false;
1396
+ readySeen = true;
1397
+ return true;
1398
+ };
1253
1399
  // TUI2-R1 (C): the progress sidecar — truncated at the start
1254
1400
  // (a kill -9 leftover is cleared, never appended to), appended
1255
1401
  // per chunk, removed at settle. Every operation best-effort:
@@ -1284,6 +1430,7 @@ export function shellTool(opts) {
1284
1430
  if (settled)
1285
1431
  return;
1286
1432
  settled = true;
1433
+ undetachable();
1287
1434
  dropProgress();
1288
1435
  clearTimeout(timer);
1289
1436
  // E group: the abort listener is removed once settled — it
@@ -1291,134 +1438,52 @@ export function shellTool(opts) {
1291
1438
  ctx.signal.removeEventListener("abort", onAbort);
1292
1439
  resolvePromise(result);
1293
1440
  };
1294
- /**
1295
- * Kill the whole tree and CONFIRM it exited (rounds 8/11):
1296
- *
1297
- * 1. FREEZE the root (SIGSTOP) FIRST — a stopped shell cannot
1298
- * fork new descendants while we enumerate;
1299
- * 2. repeatedly discover AND freeze descendants (pid-table
1300
- * sweep — the only way to see a setsid()-escaped process)
1301
- * until the set is STABLE (two identical scans), so the
1302
- * enumeration cannot miss a mid-sweep fork;
1303
- * 3. SIGKILL the process group and every tracked pid;
1304
- * 4. poll every tracked pid to death. If ANY tracked pid is
1305
- * still alive at the deadline, the verdict is NOT
1306
- * "aborted"/"timed out" — it is an explicit UNCERTAIN
1307
- * error naming the survivors: the side effect may have
1308
- * outlived the tool, and the caller must not assume it
1309
- * was killed.
1310
- */
1311
- const killTree = () => new Promise((resolveKill) => {
1312
- const tracked = new Set();
1313
- // round 11 (adversarial): the ROOT itself is tracked too — the
1314
- // verdict must not read "aborted" while the root
1315
- // survives. DOCUMENTED LIMITS: (1) a process that
1316
- // forks between SIGSTOP delivery and the next scan,
1317
- // setsids, and is then reparented when its parent is
1318
- // killed can escape the enumeration entirely — it is
1319
- // untracked and unknowable from the pid table; the
1320
- // platform cannot confirm it. (2) if THIS process is
1321
- // killed between the first SIGSTOP and the SIGKILL
1322
- // sweep, the stopped descendants stay permanently
1323
- // stopped (nobody SIGCONTs orphans) — the inherent
1324
- // cost of freeze-first. Both limits are recorded here
1325
- // so no claim of "the whole tree is gone" is ever
1326
- // stronger than what the platform can prove.
1327
- if (child.pid !== undefined && child.pid > 0) {
1328
- tracked.add(child.pid);
1329
- try {
1330
- process.kill(child.pid, "SIGSTOP"); // freeze the root
1331
- }
1332
- catch {
1333
- // already gone
1334
- }
1335
- }
1336
- // Stable discovery: freeze as we go; stop when two
1337
- // consecutive scans are identical.
1338
- let previous = new Set();
1339
- for (let i = 0; i < 10; i++) {
1340
- const current = new Set(descendantsOf(child.pid ?? 0));
1341
- for (const pid of current) {
1342
- tracked.add(pid);
1343
- try {
1344
- process.kill(pid, "SIGSTOP"); // freeze each descendant
1345
- }
1346
- catch {
1347
- // already gone
1348
- }
1349
- }
1350
- if (current.size === previous.size && [...current].every((pid) => previous.has(pid))) {
1351
- break;
1352
- }
1353
- previous = current;
1354
- }
1355
- // The process group (E group: never kill an undefined/0
1356
- // pid), which also takes the frozen root down.
1357
- if (child.pid !== undefined && child.pid > 0) {
1358
- try {
1359
- process.kill(-child.pid, "SIGKILL");
1360
- }
1361
- catch {
1362
- try {
1363
- child.kill("SIGKILL");
1364
- }
1365
- catch {
1366
- // already gone
1367
- }
1368
- }
1369
- }
1370
- for (const pid of tracked) {
1371
- try {
1372
- process.kill(pid, "SIGKILL");
1373
- }
1374
- catch {
1375
- // already gone
1376
- }
1377
- }
1378
- const confirm = () => {
1379
- void waitAllDead([...tracked]).then((unconfirmed) => resolveKill({ unconfirmed }));
1380
- };
1381
- if (exited) {
1382
- confirm();
1383
- return;
1384
- }
1385
- const fallback = setTimeout(confirm, 2000);
1386
- child.once("close", () => {
1387
- clearTimeout(fallback);
1388
- confirm();
1389
- });
1390
- });
1391
1441
  child.stdout?.on("data", (d) => {
1442
+ if (promoted !== null)
1443
+ return feedTask(d);
1392
1444
  const text = d.toString();
1393
1445
  progress(text);
1394
1446
  const r = capAccumulate(stdout, text);
1395
1447
  stdout = r.text;
1396
1448
  stdoutDropped += r.dropped;
1449
+ if (tasks !== undefined && matchReady(text))
1450
+ promote("ready");
1397
1451
  });
1398
1452
  child.stderr?.on("data", (d) => {
1453
+ if (promoted !== null)
1454
+ return feedTask(d);
1399
1455
  const text = d.toString();
1400
1456
  progress(text);
1401
1457
  const r = capAccumulate(stderr, text);
1402
1458
  stderr = r.text;
1403
1459
  stderrDropped += r.dropped;
1460
+ if (tasks !== undefined && matchReady(text))
1461
+ promote("ready");
1404
1462
  });
1405
1463
  child.on("error", (err) => {
1406
1464
  settle({ content: `shell failed: ${err.message}`, isError: true, errorKind: "fatal" });
1407
1465
  });
1408
- child.on("close", (code) => {
1409
- exited = true;
1466
+ // R-C item 2: the overflow note names WHAT was dropped and
1467
+ // the recovery path — a silent tail-cut would be the exact
1468
+ // destructive class this round kills (W10 made model-facing).
1469
+ const overflowNote = (dropped, stream) => dropped === 0
1470
+ ? ""
1471
+ : `\n… [${stream} capped at ${OUTPUT_CAP} chars — ${dropped} more chars dropped; capture to a file and read it with read_file, or narrow the command]`;
1472
+ const combinedOutput = () => (stdout + overflowNote(stdoutDropped, "stdout") + (stderr ? `\n[stderr] ${stderr}` : "") + overflowNote(stderrDropped, "stderr")).trim();
1473
+ let exitInfo = null;
1474
+ child.on("close", (code, signal) => {
1475
+ if (promoted !== null) {
1476
+ // a stop's verdict owns the task's last record
1477
+ exitInfo = { code, signal };
1478
+ if (!stopping) {
1479
+ promoted.out.close();
1480
+ promoted.task.ended(code, signal);
1481
+ }
1482
+ return;
1483
+ }
1410
1484
  if (killing)
1411
1485
  return; // the timeout/abort verdict owns the result
1412
- // R-C item 2: the overflow note names WHAT was dropped and
1413
- // the recovery path — a silent tail-cut would be the exact
1414
- // destructive class this round kills (W10 made model-facing).
1415
- const overflowNote = (dropped, stream) => dropped === 0
1416
- ? ""
1417
- : `\n… [${stream} capped at ${OUTPUT_CAP} chars — ${dropped} more chars dropped; capture to a file and read it with read_file, or narrow the command]`;
1418
- const combined = (stdout +
1419
- overflowNote(stdoutDropped, "stdout") +
1420
- (stderr ? `\n[stderr] ${stderr}` : "") +
1421
- overflowNote(stderrDropped, "stderr")).trim();
1486
+ const combined = combinedOutput();
1422
1487
  settle(code === 0
1423
1488
  ? { content: combined || "(no output)", isError: false }
1424
1489
  : { content: `exit ${code}: ${combined}`, isError: true, errorKind: "fatal" });
@@ -1430,16 +1495,86 @@ export function shellTool(opts) {
1430
1495
  : "";
1431
1496
  const onAbort = () => {
1432
1497
  killing = true;
1433
- void killTree().then(({ unconfirmed }) => settle({
1498
+ void killTree(child).then(({ unconfirmed }) => settle({
1434
1499
  content: `shell aborted${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
1435
1500
  isError: true,
1436
1501
  errorKind: "fatal",
1437
1502
  }));
1438
1503
  };
1439
1504
  ctx.signal.addEventListener("abort", onAbort);
1505
+ /** ADR-0058 §3: the wait is over — the command continues as a
1506
+ * task this process owns; nothing is killed. */
1507
+ const promote = (reason) => {
1508
+ if (settled || killing || promoted !== null || tasks === undefined)
1509
+ return;
1510
+ const task = tasks.adopt({
1511
+ command,
1512
+ cwd: opts.workspaceRoot,
1513
+ ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
1514
+ ...(readyWhen !== undefined ? { readyWhen } : {}),
1515
+ runner: ownerIdentity(),
1516
+ ready: reason === "ready",
1517
+ stop: () => stopPromoted(),
1518
+ });
1519
+ const out = new RotatingOutput(task.outputPath, TASK_OUTPUT_CAP);
1520
+ const sofar = combinedOutput();
1521
+ if (sofar !== "")
1522
+ out.write(Buffer.from(`${sofar}\n`));
1523
+ promoted = { task, out };
1524
+ const tail = sofar.length > 2048 ? `…${sofar.slice(-2048)}` : sofar;
1525
+ settle({
1526
+ content: (reason === "ready"
1527
+ ? `ready — the output contains "${readyWhen}"; `
1528
+ : reason === "person"
1529
+ ? "moved to the background by the person; "
1530
+ : reason === "steer"
1531
+ ? "moved to the background so the person's message could land; "
1532
+ : `still running after ${timeout} ms; `) +
1533
+ `continued as background task ${task.id} (not killed — it keeps running). ${taskNote(task.outputPath)}` +
1534
+ (tail !== "" ? `\nOutput so far:\n${tail}` : ""),
1535
+ isError: false,
1536
+ });
1537
+ };
1538
+ const feedTask = (d) => {
1539
+ if (promoted === null)
1540
+ return;
1541
+ promoted.out.write(d);
1542
+ if (matchReady(d.toString()))
1543
+ promoted.task.ready(readyWhen);
1544
+ };
1545
+ /** task_stop, or a clean exit: TERM, a grace, the confirmed
1546
+ * sweep; survivors mean no terminal — the task stays unknown. */
1547
+ const stopPromoted = () => {
1548
+ if (stopping)
1549
+ return;
1550
+ stopping = true;
1551
+ void killTree(child, { graceMs: TASK_STOP_GRACE_MS }).then(async ({ unconfirmed }) => {
1552
+ if (promoted === null)
1553
+ return;
1554
+ if (unconfirmed.length > 0) {
1555
+ promoted.task.unconfirmed(unconfirmed);
1556
+ return;
1557
+ }
1558
+ const waitClose = Date.now() + 1_000;
1559
+ while (exitInfo === null && Date.now() < waitClose)
1560
+ await new Promise((r) => setTimeout(r, 25));
1561
+ promoted.out.close();
1562
+ const e = exitInfo;
1563
+ promoted.task.ended(e?.code ?? null, e?.signal ?? null);
1564
+ });
1565
+ };
1566
+ // 3e: while it runs in the foreground, the person (ctrl+b) or a
1567
+ // steer may move it to the background — through the manager
1568
+ if (tasks?.registerDetachable !== undefined && ctx.executionId !== undefined) {
1569
+ undetachable = tasks.registerDetachable(ctx.executionId, { startedAt: Date.now(), detach: (by) => promote(by) });
1570
+ }
1440
1571
  const timer = setTimeout(() => {
1572
+ if (tasks !== undefined) {
1573
+ promote("waited");
1574
+ return;
1575
+ }
1441
1576
  killing = true;
1442
- void killTree().then(({ unconfirmed }) => settle({
1577
+ void killTree(child).then(({ unconfirmed }) => settle({
1443
1578
  content: `shell timed out after ${timeout}ms${uncertainVerdict(unconfirmed) ? ` — ${uncertainVerdict(unconfirmed)}` : ""}`,
1444
1579
  isError: true,
1445
1580
  errorKind: "fatal",
@@ -1450,71 +1585,89 @@ export function shellTool(opts) {
1450
1585
  },
1451
1586
  });
1452
1587
  }
1453
- /**
1454
- * All live pids whose ancestor chain includes `pid`, from the pid table
1455
- * (round 8: `ps -axo pid=,ppid=` — the ONLY way to see a setsid()-escaped
1456
- * process, which is in its own group and invisible to a group kill).
1457
- */
1458
- function descendantsOf(pid) {
1459
- if (pid <= 0)
1460
- return [];
1461
- let table;
1588
+ /** ADR-0058 Amendment 7: `background` with `readyWhen` — the task is the
1589
+ * runner's from the start; this call waits for its ready line (up to the
1590
+ * foreground wait) and reports what it saw. The ready (or an end before
1591
+ * it) is claimed for this call, so no notice repeats it. The person
1592
+ * (ctrl+b), a steer or Esc RELEASES the wait: the work is already a task,
1593
+ * so nothing is promoted and no second task is made. */
1594
+ async function waitForReady(tasks, settle, task, readyWhen, timeout, ctx) {
1595
+ const release = new AbortController();
1596
+ let reason = null;
1597
+ const onEsc = () => {
1598
+ reason ??= "interrupted";
1599
+ release.abort();
1600
+ };
1601
+ if (ctx.signal.aborted)
1602
+ onEsc();
1603
+ else
1604
+ ctx.signal.addEventListener("abort", onEsc);
1605
+ const undetach = tasks.registerDetachable !== undefined && ctx.executionId !== undefined
1606
+ ? tasks.registerDetachable(ctx.executionId, {
1607
+ startedAt: Date.now(),
1608
+ detach: (by) => {
1609
+ reason ??= by;
1610
+ release.abort();
1611
+ },
1612
+ })
1613
+ : () => { };
1462
1614
  try {
1463
- table = execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", maxBuffer: 1 << 20 });
1464
- }
1465
- catch {
1466
- return [];
1467
- }
1468
- const children = new Map();
1469
- for (const line of table.split("\n")) {
1470
- const m = line.trim().match(/^(\d+)\s+(\d+)$/);
1471
- if (m === null)
1472
- continue;
1473
- const child = Number(m[1]);
1474
- const parent = Number(m[2]);
1475
- if (!children.has(parent))
1476
- children.set(parent, []);
1477
- children.get(parent).push(child);
1478
- }
1479
- const out = [];
1480
- const queue = [pid];
1481
- while (queue.length > 0) {
1482
- const current = queue.shift();
1483
- for (const c of children.get(current) ?? []) {
1484
- out.push(c);
1485
- queue.push(c);
1615
+ const w = await settle(task.id, "ready", timeout, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: release.signal });
1616
+ const where = `Output: ${task.outputPath} (read it with read_file); task_stop stops it.`;
1617
+ if (w.settled && w.info.state.kind === "running")
1618
+ return { content: `started background task ${task.id}; ready — the output contains "${readyWhen}". ${taskNote(task.outputPath)}`, isError: false };
1619
+ if (w.settled) {
1620
+ const tail = outputTail(task.outputPath);
1621
+ return { content: `background task ${task.id} ended before it was ready (${howEnded(w.info)})${tail !== "" ? `\n${tail}` : ""}`, isError: true, errorKind: "fatal" };
1486
1622
  }
1623
+ if (reason === null)
1624
+ return { content: `not ready after ${timeout} ms — no "${readyWhen}" in the output yet; it keeps running as background task ${task.id}, and you will be notified when it is ready. ${where}`, isError: false };
1625
+ const why = reason === "person" ? "moved on by the person" : reason === "steer" ? "so the person's message could land" : "interrupted";
1626
+ return { content: `started background task ${task.id}; stopped waiting for "${readyWhen}" (${why}) — not ready yet; you will be notified when it is ready. ${where}`, isError: false };
1627
+ }
1628
+ finally {
1629
+ undetach();
1630
+ ctx.signal.removeEventListener("abort", onEsc);
1487
1631
  }
1488
- return out;
1489
1632
  }
1490
- /**
1491
- * Poll the pid table until NONE of the tracked pids is alive (bounded).
1492
- * Returns the pids still alive at the deadline — the caller MUST NOT
1493
- * report "aborted"/"timed out" while any tracked pid survives (round 11).
1494
- */
1495
- function waitAllDead(pids) {
1496
- if (pids.length === 0)
1497
- return Promise.resolve([]);
1498
- return new Promise((resolve) => {
1499
- const deadline = Date.now() + 2000;
1500
- const poll = () => {
1501
- const alive = [];
1502
- for (const pid of pids) {
1503
- try {
1504
- process.kill(pid, 0);
1505
- alive.push(pid);
1506
- }
1507
- catch (err) {
1508
- if (err.code === "EPERM")
1509
- alive.push(pid);
1510
- // ESRCH — gone
1511
- }
1512
- }
1513
- if (alive.length === 0 || Date.now() > deadline)
1514
- return resolve(alive);
1515
- setTimeout(poll, 50);
1516
- };
1517
- poll();
1633
+ /** Where a task's output is, and what happens next — one wording for the
1634
+ * shell's results. */
1635
+ function taskNote(outputPath) {
1636
+ return `Output: ${outputPath} (read it with read_file). You will be notified when it ends; task_stop stops it.`;
1637
+ }
1638
+ /** ADR-0058 §4: stop a task this session started — its whole process group. */
1639
+ export function taskStopTool(opts) {
1640
+ return defineTool({
1641
+ name: "task_stop",
1642
+ description: "Stop a task, wait briefly for its terminal state, and report how it ended.",
1643
+ parameters: {
1644
+ type: "object",
1645
+ properties: { id: { type: "string", description: "The task id, as the shell reported it (t1, t2, …)" } },
1646
+ required: ["id"],
1647
+ additionalProperties: false,
1648
+ },
1649
+ promptSnippet: "task_stop — stop a background task by id",
1650
+ execute: async ({ id }, ctx) => {
1651
+ const tasks = opts.tasks?.(ctx.sessionId);
1652
+ const info = tasks?.get(id);
1653
+ if (tasks === undefined || info === undefined)
1654
+ return { content: `no task ${id} in this session`, isError: true, errorKind: "precondition" };
1655
+ if (info.state.kind === "ended")
1656
+ return { content: `task ${id} had already ended`, isError: false };
1657
+ if (!tasks.stop(id, "model"))
1658
+ return { content: `task ${id} is not running (${info.state.kind}); nothing to stop`, isError: false };
1659
+ if (tasks.awaitSettled === undefined)
1660
+ return { content: `stopping task ${id}; you will be notified when it has ended`, isError: false };
1661
+ // Amendment 7: wait for the end this result reports — claimed for
1662
+ // THIS call, so no stopped notice follows (Esc: nothing claimed)
1663
+ const w = await tasks.awaitSettled(id, "end", TASK_STOP_WAIT_MS, { ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}), signal: ctx.signal });
1664
+ if (!w.settled)
1665
+ return { content: `stop requested for task ${id}; its end is not confirmed yet — you will be notified when it ends`, isError: false };
1666
+ if (w.info.state.kind === "unknown")
1667
+ return { content: `task ${id} could not be confirmed stopped — some of its processes may still be running`, isError: true, errorKind: "fatal" };
1668
+ const ran = w.info.endedAt !== undefined ? `; it ran ${Math.round((w.info.endedAt - w.info.startedAt) / 100) / 10}s` : "";
1669
+ return { content: `stopped task ${id} (${howEnded(w.info)}${ran})`, isError: false };
1670
+ },
1518
1671
  });
1519
1672
  }
1520
1673
  /** The full coding toolset, bound to one workspace root (Area 5). */
@@ -1526,5 +1679,6 @@ export function createCodingTools(opts) {
1526
1679
  writeFileTool(opts),
1527
1680
  editFileTool(opts),
1528
1681
  shellTool(opts),
1682
+ ...(opts.tasks !== undefined ? [taskStopTool(opts)] : []),
1529
1683
  ];
1530
1684
  }