@yagni-app/code-staging 0.3.0-staging.1081.1 → 0.3.0-staging.1085.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);
@@ -30,6 +30,14 @@ export declare const YAGNI_IDENTITY: string;
30
30
  * exist.
31
31
  */
32
32
  export declare const DRIVER_DELEGATION_PARAGRAPH: string;
33
+ /**
34
+ * The ultra-mode delegation directive (/ultra): the user has opted into
35
+ * aggressive multi-agent orchestration, so the driver is told to structure
36
+ * meaningful work as a diamond — split, fan out workers, fan out refuting
37
+ * checkers, synthesize — instead of delegating only when convenient.
38
+ * DRIVER-ONLY for the same reason as {@link DRIVER_DELEGATION_PARAGRAPH}.
39
+ */
40
+ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
33
41
  /**
34
42
  * The identity used for the interactive DRIVER session ONLY: {@link
35
43
  * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
@@ -37,7 +45,9 @@ export declare const DRIVER_DELEGATION_PARAGRAPH: string;
37
45
  * effective `x-yagni-caller` attribution (config.ts's `isDriverCaller`) — this
38
46
  * module stays a pure string, with no env dependency of its own.
39
47
  */
40
- export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
48
+ export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified, `verification` for an adversarial pass that tries to break completed work before you rely on it. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
49
+ /** The driver identity while /ultra is on: base identity + the diamond directive. */
50
+ export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond.";
41
51
  export declare const PI_IDENTITY_RE: RegExp;
42
52
  /**
43
53
  * Env switch that bypasses the system-prompt rewrite entirely, so pi's
@@ -42,10 +42,29 @@ export const YAGNI_IDENTITY = "You are YAGNI Code, an autonomous terminal coding
42
42
  export const DRIVER_DELEGATION_PARAGRAPH = "Delegation: fan codebase mapping, wide searches, and mechanical multi-file " +
43
43
  "work out to subagents (they run on cheaper tiers). Reach for the stock " +
44
44
  "agents by name: `searcher` for read-only reconnaissance and summarizing, " +
45
- "`implementer` for executing a change you have already fully specified. " +
46
- "Keep judgment, synthesis, and the conversation with the user in this " +
47
- "session. Do not spawn a subagent for work you can finish in a couple of " +
48
- "tool calls.";
45
+ "`implementer` for executing a change you have already fully specified, " +
46
+ "`verification` for an adversarial pass that tries to break completed work " +
47
+ "before you rely on it. Keep judgment, synthesis, and the conversation " +
48
+ "with the user in this session. Do not spawn a subagent for work you can " +
49
+ "finish in a couple of tool calls.";
50
+ /**
51
+ * The ultra-mode delegation directive (/ultra): the user has opted into
52
+ * aggressive multi-agent orchestration, so the driver is told to structure
53
+ * meaningful work as a diamond — split, fan out workers, fan out refuting
54
+ * checkers, synthesize — instead of delegating only when convenient.
55
+ * DRIVER-ONLY for the same reason as {@link DRIVER_DELEGATION_PARAGRAPH}.
56
+ */
57
+ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has switched this session to ultra mode " +
58
+ "— aggressive multi-agent orchestration. Structure any meaningful task as " +
59
+ "a diamond: SPLIT the job into independent pieces; FAN OUT parallel " +
60
+ "subagents on cheaper tiers (`searcher` to scout, `implementer` or " +
61
+ "`general` to execute); CHECK by fanning out `verification` subagents told " +
62
+ "to refute the work, each through a different lens (correctness, edge cases, " +
63
+ "fit with this codebase); then SYNTHESIZE the results yourself. Treat " +
64
+ "agreement between checkers — not a single pass — as confirmation, and " +
65
+ "surface what they could not verify. Delegate by default and reserve this " +
66
+ "session for splitting, judging, and synthesis; only trivial work you can " +
67
+ "finish in a couple of tool calls skips the diamond.";
49
68
  /**
50
69
  * The identity used for the interactive DRIVER session ONLY: {@link
51
70
  * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
@@ -54,6 +73,14 @@ export const DRIVER_DELEGATION_PARAGRAPH = "Delegation: fan codebase mapping, wi
54
73
  * module stays a pure string, with no env dependency of its own.
55
74
  */
56
75
  export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
76
+ /** The driver identity while /ultra is on: base identity + the diamond directive. */
77
+ export const YAGNI_IDENTITY_ULTRA = `${YAGNI_IDENTITY}\n\n${ULTRA_DELEGATION_PARAGRAPH}`;
78
+ /**
79
+ * Every identity variant this module can install, longest-composite-first, so
80
+ * the re-brand swap below never leaves a shorter variant's orphaned delegation
81
+ * paragraph behind (driver/ultra both start with the base identity).
82
+ */
83
+ const KNOWN_IDENTITIES = [YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY];
57
84
  // pi 0.84.1's exact identity sentence (dist/core/system-prompt.js). Exported as
58
85
  // the identity anchor the CLI's pi-contract tripwire test reads back from pi's
59
86
  // built system prompt, so a pi bump that reworded the opener (silently defeating
@@ -112,15 +139,26 @@ export function brandSystemPrompt(original, opts = {}) {
112
139
  let s = original;
113
140
  // 1. Drop pi's self-referential documentation block.
114
141
  s = s.replace(PI_DOCS_BLOCK_RE, "");
115
- // 2. Own the identity.
142
+ // 2. Own the identity. A previously-branded prompt may open with a DIFFERENT
143
+ // variant than the one now requested (/ultra toggles the driver between the
144
+ // delegation and diamond paragraphs mid-session) — swap it in place rather
145
+ // than prepending a second identity block.
116
146
  if (PI_IDENTITY_RE.test(s)) {
117
147
  s = s.replace(PI_IDENTITY_RE, identity);
118
148
  }
119
149
  else if (PI_IDENTITY_LOOSE_RE.test(s)) {
120
150
  s = s.replace(PI_IDENTITY_LOOSE_RE, identity);
121
151
  }
122
- else if (!s.trimStart().startsWith(identity)) {
123
- s = `${identity}\n\n${s}`;
152
+ else {
153
+ const trimmed = s.trimStart();
154
+ const lead = s.slice(0, s.length - trimmed.length);
155
+ const existing = KNOWN_IDENTITIES.find((k) => trimmed.startsWith(k));
156
+ if (existing && existing !== identity) {
157
+ s = `${lead}${identity}${trimmed.slice(existing.length)}`;
158
+ }
159
+ else if (!existing && !trimmed.startsWith(identity)) {
160
+ s = `${identity}\n\n${s}`;
161
+ }
124
162
  }
125
163
  // 3. Safety net for residual brand tokens (never inside user content).
126
164
  s = scrubOutsideProjectContext(s);
@@ -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
  });
@@ -122,7 +122,9 @@ export type { RecordDecisionParams } from "./recordDecisionTool.js";
122
122
  export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
123
123
  export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutcome, RepoIntake, RepoIntakeFs, EngineeringDraft, DraftTeam, } from "./initPass.js";
124
124
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
125
- export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
125
+ export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
126
+ export { createUltraHolder, registerUltraCommand } from "./ultra.js";
127
+ export type { UltraHolder } from "./ultra.js";
126
128
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
127
129
  export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
128
130
  export { buildYagniProvider } from "./provider.js";
@@ -139,7 +141,7 @@ export type { VerifyCommand, VerifyOutcome } from "./pipeline/verify.js";
139
141
  export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
140
142
  export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js";
141
143
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
142
- export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
144
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
143
145
  export type { SubagentDef, SubagentSource } from "./subagents.js";
144
146
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
145
147
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
@@ -14,7 +14,7 @@ import { registerCmuxBridge } from "./cmux/index.js";
14
14
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
15
15
  import { makeRecordDecisionTool } from "./recordDecisionTool.js";
16
16
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
17
- import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER } from "./branding.js";
17
+ import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.js";
18
18
  import { claudeRulesSection } from "./claudeRules.js";
19
19
  import { registerCostCommand } from "./costHud.js";
20
20
  import { isDebug } from "./diagnostics.js";
@@ -29,6 +29,7 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
29
29
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
30
30
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission.js";
31
31
  import { registerSubagents } from "./subagents.js";
32
+ import { createUltraHolder, registerUltraCommand } from "./ultra.js";
32
33
  import { registerTodos } from "./todos.js";
33
34
  import { registerDecisionCommands } from "./decisions.js";
34
35
  import { makeDecisionCapture } from "./decisionCapture.js";
@@ -185,7 +186,12 @@ export async function registerYagni(pi, deps = {}) {
185
186
  // The general subagent tool: delegate self-contained tasks (optionally in
186
187
  // parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
187
188
  // riding the /go pipeline's child runner. /agents lists what's available.
188
- registerSubagents(pi);
189
+ // Ultra mode (/ultra) is an explicit orchestration dial: it swaps the
190
+ // driver's delegation identity for the diamond directive (see the
191
+ // before_agent_start handler below) and widens the tool's fan-out ceiling.
192
+ const ultraHolder = createUltraHolder();
193
+ registerSubagents(pi, { isUltra: () => ultraHolder.get() });
194
+ registerUltraCommand(pi, ultraHolder);
189
195
  // Shared fetch timeout for the small, interactive display-path reads below
190
196
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
191
197
  // 5s, not the 10s other boot/grounding fetches use, because the user is
@@ -517,13 +523,19 @@ export async function registerYagni(pi, deps = {}) {
517
523
  // assembled prompt passes through byte-exact (no rewrite, no brief
518
524
  // injection, no rules section, no delegation identity).
519
525
  const noBranding = brandingDisabled(env);
520
- const identity = isDriverCaller(env) ? YAGNI_IDENTITY_DRIVER : undefined;
526
+ // Resolved per turn (not once at activation): /ultra can flip the driver
527
+ // between the delegation and diamond identities mid-session.
528
+ const isDriver = isDriverCaller(env);
521
529
  pi.on("before_agent_start", (event) => noBranding
522
530
  ? undefined
523
531
  : {
524
532
  systemPrompt: brandSystemPrompt(event.systemPrompt, {
525
533
  contextBrief,
526
- identity,
534
+ identity: isDriver
535
+ ? ultraHolder.get()
536
+ ? YAGNI_IDENTITY_ULTRA
537
+ : YAGNI_IDENTITY_DRIVER
538
+ : undefined,
527
539
  rulesSection,
528
540
  }),
529
541
  });
@@ -781,7 +793,9 @@ export { recordDecision } from "./recordDecisionTool.js";
781
793
  export { runInitPass, runTeamSetup, registerTeamSetupCommand, isFreshWorkspace, readRepoIntake, draftEngineering, summarizeDraft, FRESH_BRIEF_MIN_CHARS, } from "./initPass.js";
782
794
  // Onramp Door B (F2a): the one-time init-pass idempotency marker.
783
795
  export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
784
- export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
796
+ export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA, BRAND_NAME } from "./branding.js";
797
+ // Ultra mode (/ultra): the aggressive fan-out/verify/synthesize dial.
798
+ export { createUltraHolder, registerUltraCommand } from "./ultra.js";
785
799
  export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
786
800
  export { buildYagniProvider } from "./provider.js";
787
801
  export { registerGoCommand } from "./pipeline/goCommand.js";
@@ -796,7 +810,7 @@ export { buildVerifyEnv, detectVerifyCommand, makeRunVerify, parseVerifyFailures
796
810
  export { blindStages, reportOnlyStages, makeGroundedVsBlindEval, formatComparisonReport } from "./pipeline/eval.js";
797
811
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
798
812
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
799
- export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
813
+ export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
800
814
  // The session todo checklist: todo_write tool, widget renderer, /todos.
801
815
  export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
802
816
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
@@ -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.