@yagni-app/code-staging 0.3.0-staging.1081.1 → 0.3.0-staging.1082.1

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/cli.js CHANGED
@@ -28,6 +28,7 @@ import { runDoctor } from "./doctor.js";
28
28
  import { installProcessCrashHandlers } from "./crashReport.js";
29
29
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
30
30
  import { maybeRefreshAtLaunch } from "./refresh.js";
31
+ import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
31
32
  import { PAD_X } from "./padding.js";
32
33
  import { ensureShadowPiPackage } from "./piPackage.js";
33
34
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
@@ -262,7 +263,12 @@ async function runDefault(passthroughArgs) {
262
263
  stdio: "inherit",
263
264
  env,
264
265
  });
265
- child.on("exit", (code) => resolve(code ?? 0));
266
+ // Forward termination signals to pi instead of dying around it (see
267
+ // signalForward.ts for the policy: first signal graceful, second tree-kill).
268
+ installSignalForwarding(child);
269
+ // 128+n for a signal death (bash parity), so a cancelled/killed run never
270
+ // reads as success to scripts or CI.
271
+ child.on("exit", (code, signal) => resolve(exitCodeFor(code, signal)));
266
272
  child.on("error", (err) => {
267
273
  process.stderr.write(`Failed to start YAGNI Code: ${err.message}\n`);
268
274
  resolve(1);
@@ -84,6 +84,15 @@ export declare class ChipEditor extends CustomEditor {
84
84
  private stashed;
85
85
  private chipCounter;
86
86
  private readImage;
87
+ /**
88
+ * Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
89
+ * clear-editor (Esc is the interrupt key), but with nothing typed that is a
90
+ * no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
91
+ * single Ctrl+C as "stop what's happening". The hook lets the extension wire
92
+ * exactly that; the key still falls through to pi afterwards, so clear and
93
+ * the 500ms double-press exit window are untouched.
94
+ */
95
+ onEmptyCtrlC?: () => void;
87
96
  constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], readImage?: ClipboardImageReader, options?: EditorOptions);
88
97
  /**
89
98
  * Paste-image must fire on Cmd+V too (Claude Code parity on macOS). Claude
@@ -29,6 +29,7 @@ import { tmpdir } from "node:os";
29
29
  import { join, basename, isAbsolute } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { logImagePaste } from "./diagnostics.js";
32
+ import { cancelActiveRuns } from "./pipeline/runRegistry.js";
32
33
  /** Matches the `[Image #N]` chip token in the editor text. */
33
34
  const CHIP_RE = /\[Image #(\d+)\]/g;
34
35
  /** Inverse-video wrap so the chip token reads as a chip, not plain text. */
@@ -285,6 +286,15 @@ export class ChipEditor extends CustomEditor {
285
286
  stashed = [];
286
287
  chipCounter = 0;
287
288
  readImage;
289
+ /**
290
+ * Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
291
+ * clear-editor (Esc is the interrupt key), but with nothing typed that is a
292
+ * no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
293
+ * single Ctrl+C as "stop what's happening". The hook lets the extension wire
294
+ * exactly that; the key still falls through to pi afterwards, so clear and
295
+ * the 500ms double-press exit window are untouched.
296
+ */
297
+ onEmptyCtrlC;
288
298
  constructor(tui, theme, keybindings, readImage = defaultClipboardImageReader, options) {
289
299
  super(tui, theme, keybindings, options);
290
300
  this.readImage = readImage;
@@ -333,6 +343,14 @@ export class ChipEditor extends CustomEditor {
333
343
  this.onPasteImage?.();
334
344
  return;
335
345
  }
346
+ // Ctrl+C on an empty prompt: fire the stop hook, then STILL hand the key to
347
+ // pi so its clear + double-press-exit tracking behave exactly as before.
348
+ if (matchesKey(data, "ctrl+c")) {
349
+ if (this.getText().length === 0)
350
+ this.onEmptyCtrlC?.();
351
+ super.handleInput(data);
352
+ return;
353
+ }
336
354
  super.handleInput(data);
337
355
  }
338
356
  async pasteChip() {
@@ -428,6 +446,20 @@ export function registerChipEditor(pi) {
428
446
  return;
429
447
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
430
448
  const editor = new ChipEditor(tui, theme, keybindings);
449
+ // Single Ctrl+C on an empty prompt = "stop what's happening" (peer-harness
450
+ // parity; pi's clear binding is a no-op with nothing typed): abort a
451
+ // streaming turn and cancel every in-flight /go run. pi still sees the
452
+ // key afterwards, so Ctrl+C twice within 500ms exits as always.
453
+ editor.onEmptyCtrlC = () => {
454
+ if (!ctx.isIdle())
455
+ ctx.abort();
456
+ const cancelled = cancelActiveRuns();
457
+ if (cancelled.length > 0) {
458
+ ctx.ui.notify(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
459
+ .map((r) => `[${r.runId.slice(0, 8)}] ${r.ticket}`)
460
+ .join(", ")}. Work so far lands as WIP commits (see /go-status).`, "warning");
461
+ }
462
+ };
431
463
  liveChipEditors.add(new WeakRef(editor));
432
464
  return editor;
433
465
  });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Live child-process registry + exit sweep (the "no orphaned workers" invariant).
3
+ *
4
+ * /go stage children and verify children are spawned with raw `node:child_process`
5
+ * and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
6
+ * (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
7
+ * orphan them into the background, still editing the repo and burning tokens
8
+ * until their wall-clock timeout fires.
9
+ *
10
+ * Every long-lived child is registered here instead. A single `process.on("exit")`
11
+ * hook (installed lazily on first track) kills whatever is still alive —
12
+ * `"exit"` handlers must be synchronous, and the whole sweep (including the
13
+ * `ps`-based descendant walk) is, so this holds on every exit path short of the
14
+ * pi process itself being SIGKILLed.
15
+ *
16
+ * Kills are TREE kills: a verify child like `pnpm test` fans out its own
17
+ * grandchildren, and killing only the direct pid would orphan those instead.
18
+ */
19
+ import { type ChildProcess } from "node:child_process";
20
+ /**
21
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
22
+ * the given roots (children, grandchildren, ...), breadth-first.
23
+ */
24
+ export declare function descendantsOf(roots: number[], psLines: string[]): number[];
25
+ /**
26
+ * Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
27
+ * snapshot (the tree-kill pattern); Windows has real tree kill via
28
+ * `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
29
+ * Fail-soft: with no readable snapshot the direct pid is still killed.
30
+ */
31
+ export declare function killTreeSync(pid: number): void;
32
+ /**
33
+ * Register a spawned child so the exit sweep covers it. Deregisters itself on
34
+ * the child's real `close`/`error`, so the set only ever holds live processes.
35
+ */
36
+ export declare function trackChild(proc: ChildProcess): void;
37
+ /** Test seam: how many children are currently registered. */
38
+ export declare function _liveChildCountForTest(): number;
39
+ /** Test seam: run the sweep as the exit hook would. */
40
+ export declare function _sweepForTest(): void;
41
+ //# sourceMappingURL=childRegistry.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Live child-process registry + exit sweep (the "no orphaned workers" invariant).
3
+ *
4
+ * /go stage children and verify children are spawned with raw `node:child_process`
5
+ * and piped stdio, so nothing in pi tracks them: pi's own shutdown paths
6
+ * (`/exit`, double Ctrl+C, SIGTERM/SIGHUP) call `process.exit(0)` and would
7
+ * orphan them into the background, still editing the repo and burning tokens
8
+ * until their wall-clock timeout fires.
9
+ *
10
+ * Every long-lived child is registered here instead. A single `process.on("exit")`
11
+ * hook (installed lazily on first track) kills whatever is still alive —
12
+ * `"exit"` handlers must be synchronous, and the whole sweep (including the
13
+ * `ps`-based descendant walk) is, so this holds on every exit path short of the
14
+ * pi process itself being SIGKILLed.
15
+ *
16
+ * Kills are TREE kills: a verify child like `pnpm test` fans out its own
17
+ * grandchildren, and killing only the direct pid would orphan those instead.
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ const live = new Set();
21
+ let sweepInstalled = false;
22
+ /**
23
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
24
+ * the given roots (children, grandchildren, ...), breadth-first.
25
+ */
26
+ export function descendantsOf(roots, psLines) {
27
+ const childrenByParent = new Map();
28
+ for (const line of psLines) {
29
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
30
+ if (!m)
31
+ continue;
32
+ const pid = Number(m[1]);
33
+ const ppid = Number(m[2]);
34
+ const list = childrenByParent.get(ppid) ?? [];
35
+ list.push(pid);
36
+ childrenByParent.set(ppid, list);
37
+ }
38
+ const found = [];
39
+ const queue = [...roots];
40
+ while (queue.length > 0) {
41
+ const next = queue.shift();
42
+ for (const child of childrenByParent.get(next) ?? []) {
43
+ found.push(child);
44
+ queue.push(child);
45
+ }
46
+ }
47
+ return found;
48
+ }
49
+ /**
50
+ * Synchronously SIGKILL a process AND its descendants. POSIX walks a `ps`
51
+ * snapshot (the tree-kill pattern); Windows has real tree kill via
52
+ * `taskkill /T /F`. Throw-proof and synchronous, so it is exit-hook safe.
53
+ * Fail-soft: with no readable snapshot the direct pid is still killed.
54
+ */
55
+ export function killTreeSync(pid) {
56
+ if (process.platform === "win32") {
57
+ try {
58
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
59
+ }
60
+ catch {
61
+ /* best effort */
62
+ }
63
+ return;
64
+ }
65
+ let descendants = [];
66
+ try {
67
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
68
+ if (ps.status === 0 && typeof ps.stdout === "string") {
69
+ descendants = descendantsOf([pid], ps.stdout.split("\n"));
70
+ }
71
+ }
72
+ catch {
73
+ /* no snapshot: fall through to the direct kill */
74
+ }
75
+ // Root first so it cannot spawn replacements for the descendants we listed.
76
+ for (const target of [pid, ...descendants]) {
77
+ try {
78
+ process.kill(target, "SIGKILL");
79
+ }
80
+ catch {
81
+ /* already dead */
82
+ }
83
+ }
84
+ }
85
+ /** Kill every still-registered child's tree. Synchronous and throw-proof (exit-hook safe). */
86
+ function sweep() {
87
+ for (const proc of live) {
88
+ // pid guard: a child whose spawn FAILED has no pid; kill() would resolve
89
+ // that to pid 0 (the whole process group) and take the parent down too.
90
+ if (!proc.pid)
91
+ continue;
92
+ killTreeSync(proc.pid);
93
+ }
94
+ live.clear();
95
+ }
96
+ /**
97
+ * Register a spawned child so the exit sweep covers it. Deregisters itself on
98
+ * the child's real `close`/`error`, so the set only ever holds live processes.
99
+ */
100
+ export function trackChild(proc) {
101
+ if (!sweepInstalled) {
102
+ sweepInstalled = true;
103
+ process.on("exit", sweep);
104
+ }
105
+ live.add(proc);
106
+ const drop = () => live.delete(proc);
107
+ proc.once("close", drop);
108
+ proc.once("error", drop);
109
+ }
110
+ /** Test seam: how many children are currently registered. */
111
+ export function _liveChildCountForTest() {
112
+ return live.size;
113
+ }
114
+ /** Test seam: run the sweep as the exit hook would. */
115
+ export function _sweepForTest() {
116
+ sweep();
117
+ }
118
+ //# sourceMappingURL=childRegistry.js.map
@@ -28,6 +28,7 @@
28
28
  * does not apply.
29
29
  */
30
30
  import { execFile } from "node:child_process";
31
+ import { trackChild } from "./childRegistry.js";
31
32
  import { scrubSecrets } from "./scrubSecrets.js";
32
33
  import { parseChangedPaths } from "./verify.js";
33
34
  /** Cap on the commit subject's title half (keeps `git log --oneline` readable). */
@@ -42,7 +43,7 @@ const NOTE_TAIL_MAX = 200;
42
43
  * a spawn-level error (ENOENT / abort), which callers catch into a note.
43
44
  */
44
45
  const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
45
- execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
46
+ const child = execFile(argv[0], argv.slice(1), { cwd, signal, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
46
47
  const output = `${stdout ?? ""}${stderr ?? ""}`;
47
48
  if (err) {
48
49
  const code = err.code;
@@ -52,6 +53,9 @@ const defaultExec = (argv, cwd, signal) => new Promise((resolve, reject) => {
52
53
  }
53
54
  resolve({ code: 0, output });
54
55
  });
56
+ // A push or PR create can run for a while; register it with the exit sweep
57
+ // so quitting pi mid-FINISH never leaves it running in the background.
58
+ trackChild(child);
55
59
  });
56
60
  // ---------------------------------------------------------------------------
57
61
  // Pure message derivation
@@ -88,7 +88,7 @@ export interface RegisterGoDeps {
88
88
  * without a network; a non-identifier arg short-circuits to null (raw arg passes
89
89
  * through). Returns null on any miss / transport error (the honest blind fallback).
90
90
  */
91
- resolveTicketBrief?: (rawArg: string) => Promise<string | null>;
91
+ resolveTicketBrief?: (rawArg: string, signal?: AbortSignal) => Promise<string | null>;
92
92
  /** Injectable worktree creation (default: real `git worktree add -b … HEAD`). */
93
93
  createWorktree?: typeof defaultCreateRunWorktree;
94
94
  /** Injectable worktree bootstrap (default: best-effort `<pm> install --prefer-offline`). */
@@ -75,8 +75,9 @@ import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js"
75
75
  import { GO_USAGE, parseGoArgs } from "./goFlags.js";
76
76
  import { registerGoStatusCommands } from "./goStatusCommands.js";
77
77
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
78
+ import { composeAbortSignal } from "./resilience.js";
78
79
  import { planResume } from "./resume.js";
79
- import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
+ import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, MAX_CONCURRENT_RUNS, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
80
81
  import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
81
82
  import { recordSessionRun } from "../sessionRuns.js";
82
83
  import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
@@ -426,7 +427,11 @@ export function registerGoCommand(pi, deps = {}) {
426
427
  // back to the local client estimate).
427
428
  const fetchRunSpend = deps.fetchRunSpend;
428
429
  const resolveTicketBrief = deps.resolveTicketBrief ??
429
- ((rawArg) => defaultResolveTicketBrief({ baseUrl: deps.baseUrl ?? resolveBaseUrl(), getToken: deps.getToken ?? defaultGetToken }, rawArg));
430
+ ((rawArg, signal) => defaultResolveTicketBrief({
431
+ baseUrl: deps.baseUrl ?? resolveBaseUrl(),
432
+ getToken: deps.getToken ?? defaultGetToken,
433
+ ...(signal ? { signal } : {}),
434
+ }, rawArg));
430
435
  const reportCrash = deps.reportCrash ??
431
436
  makeCrashReporter({
432
437
  baseUrl: deps.baseUrl ?? resolveBaseUrl(),
@@ -830,6 +835,13 @@ export function registerGoCommand(pi, deps = {}) {
830
835
  return undefined;
831
836
  }
832
837
  };
838
+ // The run's OWN abort controller. `ctx.signal` is undefined here (/go only
839
+ // starts while the agent is idle, and pi's signal is the active turn's), so
840
+ // without this the pipeline runs unstoppable until its wall-clock timeout.
841
+ // /stop fires it via the registry (trackRunAbort below); composed with
842
+ // ctx.signal for the resumed-command case where one does exist.
843
+ const runAbort = new AbortController();
844
+ const runSignal = composeAbortSignal(ctx.signal, runAbort.signal);
833
845
  const runToCompletion = async () => {
834
846
  try {
835
847
  // Steady animation ticker: keeps the spinner + elapsed clock live between
@@ -843,7 +855,9 @@ export function registerGoCommand(pi, deps = {}) {
843
855
  // the agents plan on the actual ticket, not the bare "YAG-234". Fail-soft:
844
856
  // null keeps the raw arg (the honest blind fallback). The raw `ticket` stays
845
857
  // the session key, the run row's arg, and the handoff/re-run copy.
846
- const ticketBrief = await resolveTicketBrief(ticket);
858
+ // Signalled so /stop lands immediately instead of waiting out the
859
+ // resolver's retry/timeout budget (up to ~90s of network patience).
860
+ const ticketBrief = await resolveTicketBrief(ticket, runSignal);
847
861
  // The plan stage's output, captured off the onStage boundary for the
848
862
  // FINISH commit message's 2-3 sentence summary (absent on a resume).
849
863
  let planText;
@@ -861,9 +875,9 @@ export function registerGoCommand(pi, deps = {}) {
861
875
  catch {
862
876
  /* fail-soft: leave baseline empty */
863
877
  }
864
- const result = await runPipeline(ticket, {
878
+ let result = await runPipeline(ticket, {
865
879
  cwd: runCwd,
866
- signal: ctx.signal,
880
+ signal: runSignal,
867
881
  ...(ticketBrief ? { ticketBrief } : {}),
868
882
  onProgress: (p) => {
869
883
  feed?.applyProgress(p);
@@ -909,6 +923,12 @@ export function registerGoCommand(pi, deps = {}) {
909
923
  checkpointMeta,
910
924
  ...(resumeFrom ? { resumeFrom } : {}),
911
925
  });
926
+ // /stop can land in the race between the pipeline's last abort check
927
+ // and its clean return: honor it BEFORE FINISH, so a stopped run never
928
+ // starts committing and the promised WIP-preserve path runs instead.
929
+ if (result.stopReason === "clean" && runSignal.aborted) {
930
+ result = { ...result, stopReason: "aborted" };
931
+ }
912
932
  // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
913
933
  // (worktree always; --here only over a clean pre-run baseline) with the
914
934
  // provenance trailer, and push + PR when --pr asked for it. Iron
@@ -939,7 +959,7 @@ export function registerGoCommand(pi, deps = {}) {
939
959
  openPr: parsed.flags.pr,
940
960
  remainingFindings: result.findings.filter((f) => f.severity === "medium" || f.severity === "low"),
941
961
  ...(finishBaselinePaths.length > 0 ? { baselinePaths: finishBaselinePaths } : {}),
942
- ...(ctx.signal ? { signal: ctx.signal } : {}),
962
+ signal: runSignal,
943
963
  });
944
964
  }
945
965
  catch (err) {
@@ -972,6 +992,14 @@ export function registerGoCommand(pi, deps = {}) {
972
992
  }
973
993
  }
974
994
  clearUI();
995
+ // /stop DURING FINISH: the abort degrades FINISH mid-way (its execs
996
+ // share runSignal). If no commit landed, the aborted status + WIP
997
+ // preservation the user was promised win over "clean"; a commit that
998
+ // DID land means the work is durable and the clean report stays honest.
999
+ if (result.stopReason === "clean" && runSignal.aborted && !finish?.commitSha) {
1000
+ result = { ...result, stopReason: "aborted" };
1001
+ finish = undefined;
1002
+ }
975
1003
  // Fold what FINISH landed onto a NEW result (never mutate the pipeline's
976
1004
  // return). `finish` is present ONLY when a commit really happened (§0.1).
977
1005
  const finishBranch = finish?.branch ?? repoCtx.branch;
@@ -1049,6 +1077,7 @@ export function registerGoCommand(pi, deps = {}) {
1049
1077
  }
1050
1078
  };
1051
1079
  beginRun(registryRow);
1080
+ trackRunAbort(runId, runAbort);
1052
1081
  if (parsed.flags.fg) {
1053
1082
  // --fg: the legacy blocking behavior — the prompt returns when the run does.
1054
1083
  await runToCompletion();
@@ -44,7 +44,17 @@ export interface RegisterGoStatusDeps {
44
44
  resolveRepo?: (cwd: string, signal?: AbortSignal) => Promise<string | undefined>;
45
45
  exists?: (path: string) => boolean;
46
46
  now?: () => number;
47
+ /** Injectable cancel seams for /stop (default: the module-scoped registry). */
48
+ activeRows?: () => RunRegistryRow[];
49
+ cancelOne?: (runId: string) => RunRegistryRow | undefined;
50
+ cancelAll?: () => RunRegistryRow[];
47
51
  }
52
+ /**
53
+ * PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
54
+ * (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
55
+ * short id /go-status prints is 8).
56
+ */
57
+ export declare function matchStopTarget(rows: RunRegistryRow[], arg: string): RunRegistryRow | undefined;
48
58
  /**
49
59
  * PURE: where an in-flight/interrupted run got to, read off its checkpoint
50
60
  * journal. The journal is append-only, so the latest applicable boundary wins.
@@ -22,9 +22,25 @@
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
24
  import { makeFileCheckpointStore } from "./checkpoint.js";
25
- import { classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
25
+ import { activeRunRows, cancelActiveRuns, cancelRun, classifyRunLiveness, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, settleRun, } from "./runRegistry.js";
26
26
  import { removeWorktree as defaultRemoveWorktree } from "./worktree.js";
27
27
  import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
28
+ /**
29
+ * PURE: resolve a /stop argument against the in-flight rows. Matches the ticket
30
+ * (case-insensitive exact) first, then a runId prefix of at least 4 chars (the
31
+ * short id /go-status prints is 8).
32
+ */
33
+ export function matchStopTarget(rows, arg) {
34
+ const needle = arg.trim().toLowerCase();
35
+ if (!needle)
36
+ return undefined;
37
+ const byTicket = rows.find((r) => r.ticket.toLowerCase() === needle);
38
+ if (byTicket)
39
+ return byTicket;
40
+ if (needle.length < 4)
41
+ return undefined;
42
+ return rows.find((r) => r.runId.toLowerCase().startsWith(needle));
43
+ }
28
44
  /**
29
45
  * PURE: where an in-flight/interrupted run got to, read off its checkpoint
30
46
  * journal. The journal is append-only, so the latest applicable boundary wins.
@@ -90,6 +106,50 @@ export function registerGoStatusCommands(pi, deps = {}) {
90
106
  const resolveRepo = deps.resolveRepo ?? (async () => undefined);
91
107
  const exists = deps.exists ?? existsSync;
92
108
  const now = deps.now ?? Date.now;
109
+ const activeRows = deps.activeRows ?? activeRunRows;
110
+ const cancelOne = deps.cancelOne ?? cancelRun;
111
+ const cancelAll = deps.cancelAll ?? cancelActiveRuns;
112
+ pi.registerCommand("stop", {
113
+ description: "Hard-stop /go runs in this session: /stop cancels them all, /stop <ticket|run id> cancels one. Work so far is preserved as a WIP commit.",
114
+ handler: async (args, ctx) => {
115
+ const say = async (message, type = "info") => {
116
+ if (ctx.hasUI)
117
+ ctx.ui.notify(message, type);
118
+ else
119
+ await pi.sendUserMessage(message);
120
+ };
121
+ const label = (row) => `[${row.runId.slice(0, 8)}] ${row.ticket}`;
122
+ const rows = activeRows();
123
+ if (rows.length === 0) {
124
+ await say("No /go runs are in flight in this session. (Esc interrupts the interactive agent; /go-status lists runs from other sessions.)");
125
+ return;
126
+ }
127
+ const arg = args.trim();
128
+ if (arg) {
129
+ const target = matchStopTarget(rows, arg);
130
+ if (!target) {
131
+ await say(`No in-flight run matches "${arg}". Running here: ${rows.map(label).join(", ")}.`, "warning");
132
+ return;
133
+ }
134
+ if (!cancelOne(target.runId)) {
135
+ await say(`${label(target)} is still starting and cannot be signalled yet; try /stop again.`, "warning");
136
+ return;
137
+ }
138
+ await say(`Stopping ${label(target)}. Its workers are being killed; any work lands as a WIP commit and the handoff will report the aborted run.`);
139
+ return;
140
+ }
141
+ const cancelled = cancelAll();
142
+ if (cancelled.length === 0) {
143
+ // The window between beginRun and trackRunAbort is one tick wide, but be
144
+ // honest if we hit it rather than claiming a stop that did not fire.
145
+ await say("Could not signal any run yet (they are still starting); try /stop again.", "warning");
146
+ return;
147
+ }
148
+ await say(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
149
+ .map(label)
150
+ .join(", ")}. Workers are being killed; work so far lands as WIP commits (see /go-status).`);
151
+ },
152
+ });
93
153
  pi.registerCommand("go-status", {
94
154
  description: "List /go runs: active (stage/round), finished (outcome + branch/commit/PR), and interrupted (resumable).",
95
155
  handler: async (_args, ctx) => {
@@ -85,6 +85,20 @@ export declare function _resetRunRegistryForTest(): void;
85
85
  export declare function beginRun(row: RunRegistryRow): void;
86
86
  /** Attach the detached completion promise to an in-flight run (fail-soft no-op when absent). */
87
87
  export declare function trackRunPromise(runId: string, promise: Promise<void>): void;
88
+ /** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
89
+ export declare function trackRunAbort(runId: string, abort: AbortController): void;
90
+ /**
91
+ * Cancel one in-flight run in THIS process: fire its abort controller and return
92
+ * its row. The run itself settles through the normal pipeline path (the abort
93
+ * propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
94
+ * an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
95
+ * Returns undefined for an unknown/foreign runId or a run with no controller yet.
96
+ */
97
+ export declare function cancelRun(runId: string): RunRegistryRow | undefined;
98
+ /** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
99
+ export declare function cancelActiveRuns(): RunRegistryRow[];
100
+ /** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
101
+ export declare function activeRunRows(): RunRegistryRow[];
88
102
  /**
89
103
  * Settle an in-flight run: merge the terminal patch, drop it from the in-memory
90
104
  * active set, and mirror the final row. Safe to call for an unknown runId: the
@@ -137,6 +137,41 @@ export function trackRunPromise(runId, promise) {
137
137
  if (entry)
138
138
  entry.promise = promise;
139
139
  }
140
+ /** Attach the run's abort controller so /stop can cancel it (fail-soft no-op when absent). */
141
+ export function trackRunAbort(runId, abort) {
142
+ const entry = active.get(runId);
143
+ if (entry)
144
+ entry.abort = abort;
145
+ }
146
+ /**
147
+ * Cancel one in-flight run in THIS process: fire its abort controller and return
148
+ * its row. The run itself settles through the normal pipeline path (the abort
149
+ * propagates to every stage child as SIGTERM→SIGKILL, the orchestrator stops with
150
+ * an honest `aborted`, and runToCompletion preserves WIP + settles the registry).
151
+ * Returns undefined for an unknown/foreign runId or a run with no controller yet.
152
+ */
153
+ export function cancelRun(runId) {
154
+ const entry = active.get(runId);
155
+ if (!entry?.abort)
156
+ return undefined;
157
+ entry.abort.abort();
158
+ return entry.row;
159
+ }
160
+ /** Cancel EVERY in-flight run in this process (the /stop no-arg path). Returns the rows fired. */
161
+ export function cancelActiveRuns() {
162
+ const cancelled = [];
163
+ for (const entry of active.values()) {
164
+ if (!entry.abort)
165
+ continue;
166
+ entry.abort.abort();
167
+ cancelled.push(entry.row);
168
+ }
169
+ return cancelled;
170
+ }
171
+ /** The in-flight rows in THIS process (newest first), for /stop's matching + messaging. */
172
+ export function activeRunRows() {
173
+ return [...active.values()].map((e) => e.row).sort((a, b) => b.startedAt - a.startedAt);
174
+ }
140
175
  /**
141
176
  * Settle an in-flight run: merge the terminal patch, drop it from the in-memory
142
177
  * active set, and mirror the final row. Safe to call for an unknown runId: the
@@ -23,6 +23,7 @@ import * as fs from "node:fs";
23
23
  import * as os from "node:os";
24
24
  import * as path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { trackChild } from "./childRegistry.js";
26
27
  import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
27
28
  import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
28
29
  import { personaBody } from "./personas.js";
@@ -151,6 +152,9 @@ export async function runStage(stage, ctx, deps) {
151
152
  shell: false,
152
153
  stdio: ["ignore", "pipe", "pipe"],
153
154
  });
155
+ // Register with the exit sweep so quitting pi (/exit, double Ctrl+C,
156
+ // SIGTERM) can never orphan a stage child into the background.
157
+ trackChild(proc);
154
158
  let buffer = "";
155
159
  // `overlong` = we are mid-way through a line that already blew the cap; we
156
160
  // discard incoming bytes until its terminating newline, then drop the line.
@@ -239,6 +239,10 @@ export declare function buildVerifyEnv(source?: NodeJS.ProcessEnv): NodeJS.Proce
239
239
  * once instead of parking in watch mode until the 10-min cap kills it.
240
240
  */
241
241
  export declare function buildTestEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
242
+ export declare function makeDefaultExec(opts?: {
243
+ graceMs?: number;
244
+ killTree?: (pid: number) => void;
245
+ }): VerifyExec;
242
246
  export interface MakeRunVerifyOpts {
243
247
  detect?: (cwd: string, changedDirs: string[]) => VerifyCommand[];
244
248
  /**
@@ -52,6 +52,7 @@ import { readFileSync } from "node:fs";
52
52
  // backslashed strings that no longer compare equal to the git-derived repoRoot
53
53
  // and would break the walk-up termination check.
54
54
  import { basename, dirname, join } from "node:path/posix";
55
+ import { killTreeSync, trackChild } from "./childRegistry.js";
55
56
  import { composeAbortSignal } from "./resilience.js";
56
57
  import { scrubSecrets } from "./scrubSecrets.js";
57
58
  import { snapshotWorkspace } from "./workspace.js";
@@ -595,33 +596,54 @@ export function buildTestEnv(source = process.env) {
595
596
  // — signalling the WHOLE process group and killing the parent. Abort is wired
596
597
  // manually with an explicit pid guard; a killed child reaches the callback with
597
598
  // a code-less error, which stays the same fail-open rejection as before.
598
- const defaultExec = (argv, cwd, signal, env) => new Promise((resolve, reject) => {
599
- if (signal.aborted)
600
- return reject(new Error("verify aborted before start"));
601
- const child = execFile(argv[0], argv.slice(1), { cwd, env: env ?? buildVerifyEnv(), maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
602
- signal.removeEventListener("abort", onAbort);
603
- const output = `${stdout ?? ""}${stderr ?? ""}`;
604
- if (err) {
605
- // A non-zero EXIT is a verify failure (carries a numeric code); a spawn
606
- // error (ENOENT) or an abort has no numeric code and is a fail-open.
607
- const code = err.code;
608
- if (typeof code === "number")
609
- return resolve({ code, output });
610
- return reject(err);
611
- }
612
- resolve({ code: 0, output });
599
+ //
600
+ // Abort escalates: SIGTERM first (a test runner gets its grace to flush), then
601
+ // after `graceMs` a TREE SIGKILL — a stubborn runner (or a descendant it
602
+ // spawned that holds the stdio pipes open) would otherwise leave the promise
603
+ // pending forever, hanging /stop and the run with it.
604
+ export function makeDefaultExec(opts = {}) {
605
+ const graceMs = opts.graceMs ?? 5000;
606
+ const killTree = opts.killTree ?? killTreeSync;
607
+ return (argv, cwd, signal, env) => new Promise((resolve, reject) => {
608
+ if (signal.aborted)
609
+ return reject(new Error("verify aborted before start"));
610
+ let settled = false;
611
+ let killTimer;
612
+ const child = execFile(argv[0], argv.slice(1), { cwd, env: env ?? buildVerifyEnv(), maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
613
+ settled = true;
614
+ if (killTimer)
615
+ clearTimeout(killTimer);
616
+ signal.removeEventListener("abort", onAbort);
617
+ const output = `${stdout ?? ""}${stderr ?? ""}`;
618
+ if (err) {
619
+ // A non-zero EXIT is a verify failure (carries a numeric code); a spawn
620
+ // error (ENOENT) or an abort has no numeric code and is a fail-open.
621
+ const code = err.code;
622
+ if (typeof code === "number")
623
+ return resolve({ code, output });
624
+ return reject(err);
625
+ }
626
+ resolve({ code: 0, output });
627
+ });
628
+ trackChild(child);
629
+ const onAbort = () => {
630
+ try {
631
+ if (child.pid)
632
+ child.kill("SIGTERM");
633
+ }
634
+ catch {
635
+ /* ignore */
636
+ }
637
+ killTimer = setTimeout(() => {
638
+ if (!settled && child.pid)
639
+ killTree(child.pid);
640
+ }, graceMs);
641
+ killTimer.unref?.();
642
+ };
643
+ signal.addEventListener("abort", onAbort, { once: true });
613
644
  });
614
- const onAbort = () => {
615
- try {
616
- if (child.pid)
617
- child.kill("SIGTERM");
618
- }
619
- catch {
620
- /* ignore */
621
- }
622
- };
623
- signal.addEventListener("abort", onAbort, { once: true });
624
- });
645
+ }
646
+ const defaultExec = makeDefaultExec();
625
647
  /**
626
648
  * Build the runVerify function injected into the orchestrator. It discovers the
627
649
  * changed package(s) (scoped to this run's diff via `baselinePaths`), detects a
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Termination-signal forwarding: launcher → pi child.
3
+ *
4
+ * In the interactive TUI the tty is in raw mode, so Ctrl+C never raises
5
+ * SIGINT; this path covers `yagni -p`/piped/CI runs and an external
6
+ * `kill <launcher pid>`. Policy:
7
+ *
8
+ * - The FIRST signal asks pi to shut down cleanly. SIGINT is translated to
9
+ * SIGTERM because pi installs a graceful handler for SIGTERM (it kills its
10
+ * tracked children) but none for SIGINT — a raw SIGINT would drop pi on the
11
+ * default disposition with no cleanup.
12
+ * - Any SECOND signal hard-kills pi's whole process TREE. A bare SIGKILL to
13
+ * pi's pid would bypass its exit sweep (SIGKILL runs no handlers) and leave
14
+ * /go workers alive, so the launcher enumerates and kills the descendants
15
+ * itself: `ps` walk on POSIX, `taskkill /T /F` on Windows.
16
+ */
17
+ /** The minimal child surface the forwarder needs (ChildProcess satisfies it). */
18
+ export interface KillableChild {
19
+ pid?: number | undefined;
20
+ kill(signal: NodeJS.Signals): boolean;
21
+ }
22
+ /** The launcher-terminating signals that are forwarded rather than obeyed. */
23
+ export declare const FORWARDED_SIGNALS: readonly NodeJS.Signals[];
24
+ /**
25
+ * PURE policy for the FIRST signal: which signal the child receives when the
26
+ * launcher gets `received` (SIGINT is translated, the rest pass through).
27
+ * Escalation is not expressed here — a second signal goes through the tree
28
+ * kill, not a forwarded signal.
29
+ */
30
+ export declare function forwardedSignal(received: NodeJS.Signals): NodeJS.Signals;
31
+ /**
32
+ * PURE: the launcher's own exit code for the child's (code, signal) exit tuple.
33
+ * A signal-terminated child maps to the conventional 128+n (bash parity:
34
+ * SIGTERM→143, SIGKILL→137, SIGINT→130), so a cancelled run never reads as
35
+ * success to scripts or CI.
36
+ */
37
+ export declare function exitCodeFor(code: number | null, signal: NodeJS.Signals | null): number;
38
+ /**
39
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
40
+ * the given roots (children, grandchildren, ...), breadth-first. Mirrored in
41
+ * pi-extension-yagni's childRegistry (the packages are intentionally
42
+ * independent); keep the two in sync.
43
+ */
44
+ export declare function descendantsOf(roots: number[], psLines: string[]): number[];
45
+ /**
46
+ * Synchronously SIGKILL a process AND its descendants (POSIX `ps` walk /
47
+ * Windows `taskkill /T /F`). Throw-proof; fail-soft to a direct kill when no
48
+ * process snapshot is readable.
49
+ */
50
+ export declare function killTreeSync(pid: number): void;
51
+ /**
52
+ * Subscribe the forwarding policy for every signal in {@link FORWARDED_SIGNALS}.
53
+ * `subscribe` and `killTree` default to the real process surfaces and are
54
+ * injectable so tests never install real handlers, raise real signals, or kill
55
+ * real processes. Returns the forward function itself (also for tests).
56
+ * pid guard: a child whose spawn failed has no pid, and `kill()` would resolve
57
+ * that to the whole process group.
58
+ */
59
+ export declare function installSignalForwarding(child: KillableChild, subscribe?: (sig: NodeJS.Signals, handler: () => void) => void, killTree?: (pid: number) => void): (sig: NodeJS.Signals) => void;
60
+ //# sourceMappingURL=signalForward.d.ts.map
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Termination-signal forwarding: launcher → pi child.
3
+ *
4
+ * In the interactive TUI the tty is in raw mode, so Ctrl+C never raises
5
+ * SIGINT; this path covers `yagni -p`/piped/CI runs and an external
6
+ * `kill <launcher pid>`. Policy:
7
+ *
8
+ * - The FIRST signal asks pi to shut down cleanly. SIGINT is translated to
9
+ * SIGTERM because pi installs a graceful handler for SIGTERM (it kills its
10
+ * tracked children) but none for SIGINT — a raw SIGINT would drop pi on the
11
+ * default disposition with no cleanup.
12
+ * - Any SECOND signal hard-kills pi's whole process TREE. A bare SIGKILL to
13
+ * pi's pid would bypass its exit sweep (SIGKILL runs no handlers) and leave
14
+ * /go workers alive, so the launcher enumerates and kills the descendants
15
+ * itself: `ps` walk on POSIX, `taskkill /T /F` on Windows.
16
+ */
17
+ import { spawnSync } from "node:child_process";
18
+ import { constants as osConstants } from "node:os";
19
+ /** The launcher-terminating signals that are forwarded rather than obeyed. */
20
+ export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
21
+ /**
22
+ * PURE policy for the FIRST signal: which signal the child receives when the
23
+ * launcher gets `received` (SIGINT is translated, the rest pass through).
24
+ * Escalation is not expressed here — a second signal goes through the tree
25
+ * kill, not a forwarded signal.
26
+ */
27
+ export function forwardedSignal(received) {
28
+ return received === "SIGINT" ? "SIGTERM" : received;
29
+ }
30
+ /**
31
+ * PURE: the launcher's own exit code for the child's (code, signal) exit tuple.
32
+ * A signal-terminated child maps to the conventional 128+n (bash parity:
33
+ * SIGTERM→143, SIGKILL→137, SIGINT→130), so a cancelled run never reads as
34
+ * success to scripts or CI.
35
+ */
36
+ export function exitCodeFor(code, signal) {
37
+ if (code != null)
38
+ return code;
39
+ if (signal)
40
+ return 128 + (osConstants.signals[signal] ?? 15);
41
+ return 0;
42
+ }
43
+ /**
44
+ * PURE: given `ps -A -o pid=,ppid=` output lines, collect every descendant of
45
+ * the given roots (children, grandchildren, ...), breadth-first. Mirrored in
46
+ * pi-extension-yagni's childRegistry (the packages are intentionally
47
+ * independent); keep the two in sync.
48
+ */
49
+ export function descendantsOf(roots, psLines) {
50
+ const childrenByParent = new Map();
51
+ for (const line of psLines) {
52
+ const m = line.trim().match(/^(\d+)\s+(\d+)$/);
53
+ if (!m)
54
+ continue;
55
+ const pid = Number(m[1]);
56
+ const ppid = Number(m[2]);
57
+ const list = childrenByParent.get(ppid) ?? [];
58
+ list.push(pid);
59
+ childrenByParent.set(ppid, list);
60
+ }
61
+ const found = [];
62
+ const queue = [...roots];
63
+ while (queue.length > 0) {
64
+ const next = queue.shift();
65
+ for (const child of childrenByParent.get(next) ?? []) {
66
+ found.push(child);
67
+ queue.push(child);
68
+ }
69
+ }
70
+ return found;
71
+ }
72
+ /**
73
+ * Synchronously SIGKILL a process AND its descendants (POSIX `ps` walk /
74
+ * Windows `taskkill /T /F`). Throw-proof; fail-soft to a direct kill when no
75
+ * process snapshot is readable.
76
+ */
77
+ export function killTreeSync(pid) {
78
+ if (process.platform === "win32") {
79
+ try {
80
+ spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
81
+ }
82
+ catch {
83
+ /* best effort */
84
+ }
85
+ return;
86
+ }
87
+ let descendants = [];
88
+ try {
89
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" });
90
+ if (ps.status === 0 && typeof ps.stdout === "string") {
91
+ descendants = descendantsOf([pid], ps.stdout.split("\n"));
92
+ }
93
+ }
94
+ catch {
95
+ /* no snapshot: fall through to the direct kill */
96
+ }
97
+ for (const target of [pid, ...descendants]) {
98
+ try {
99
+ process.kill(target, "SIGKILL");
100
+ }
101
+ catch {
102
+ /* already dead */
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Subscribe the forwarding policy for every signal in {@link FORWARDED_SIGNALS}.
108
+ * `subscribe` and `killTree` default to the real process surfaces and are
109
+ * injectable so tests never install real handlers, raise real signals, or kill
110
+ * real processes. Returns the forward function itself (also for tests).
111
+ * pid guard: a child whose spawn failed has no pid, and `kill()` would resolve
112
+ * that to the whole process group.
113
+ */
114
+ export function installSignalForwarding(child, subscribe = (sig, handler) => process.on(sig, handler), killTree = killTreeSync) {
115
+ let received = 0;
116
+ const forward = (sig) => {
117
+ const prior = received;
118
+ received += 1;
119
+ if (!child.pid)
120
+ return;
121
+ if (prior > 0)
122
+ killTree(child.pid);
123
+ else
124
+ child.kill(forwardedSignal(sig));
125
+ };
126
+ for (const sig of FORWARDED_SIGNALS)
127
+ subscribe(sig, () => forward(sig));
128
+ return forward;
129
+ }
130
+ //# sourceMappingURL=signalForward.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1081.1",
3
+ "version": "0.3.0-staging.1082.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "dd068fdb7b8bcb1207ff4f2fe803bf26b2e3eec3"
41
+ "yagniSourceSha": "a1967e7624f741b033c68d83acd29b3a47262b7c"
42
42
  }