gnhf 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +43 -16
  2. package/dist/cli.mjs +748 -61
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -15,7 +15,9 @@ const AGENT_NAMES = [
15
15
  "claude",
16
16
  "codex",
17
17
  "rovodev",
18
- "opencode"
18
+ "opencode",
19
+ "copilot",
20
+ "pi"
19
21
  ];
20
22
  const DEFAULT_CONFIG = {
21
23
  agent: "claude",
@@ -25,6 +27,10 @@ const DEFAULT_CONFIG = {
25
27
  preventSleep: true
26
28
  };
27
29
  var InvalidConfigError = class extends Error {};
30
+ function formatAgentNameList() {
31
+ const quoted = AGENT_NAMES.map((name) => `"${name}"`);
32
+ return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`;
33
+ }
28
34
  function normalizePreventSleep(value) {
29
35
  if (typeof value === "boolean") return value;
30
36
  if (typeof value !== "string") return void 0;
@@ -39,6 +45,8 @@ function isReservedAgentArg(agent, arg) {
39
45
  case "codex": return arg === "exec" || arg === "--json" || arg === "--output-schema" || arg.startsWith("--output-schema=") || arg === "--color" || arg.startsWith("--color=");
40
46
  case "opencode": return arg === "serve" || arg === "--hostname" || arg.startsWith("--hostname=") || arg === "--port" || arg.startsWith("--port=") || arg === "--print-logs";
41
47
  case "rovodev": return arg === "rovodev" || arg === "serve" || arg === "--disable-session-token";
48
+ case "copilot": return arg === "-p" || arg === "--prompt" || arg.startsWith("--prompt=") || arg === "-i" || arg === "--interactive" || arg.startsWith("--interactive=") || arg === "-s" || arg === "--silent" || arg === "--output-format" || arg.startsWith("--output-format=") || arg === "--stream" || arg.startsWith("--stream=") || arg === "--no-color" || arg === "--share" || arg.startsWith("--share=") || arg === "--share-gist";
49
+ case "pi": return arg === "--mode" || arg.startsWith("--mode=") || arg === "--print" || arg === "-p" || arg === "--continue" || arg === "-c" || arg === "--resume" || arg === "-r" || arg === "--session" || arg.startsWith("--session=") || arg === "--fork" || arg.startsWith("--fork=") || arg === "--session-dir" || arg.startsWith("--session-dir=") || arg === "--no-session" || arg === "--export" || arg.startsWith("--export=") || arg === "--list-models" || arg.startsWith("--list-models=") || arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v" || arg === "--api-key" || arg.startsWith("--api-key=");
42
50
  }
43
51
  }
44
52
  /**
@@ -62,7 +70,7 @@ function normalizeAgentPathOverride(value, configDir) {
62
70
  const validNames = new Set(AGENT_NAMES);
63
71
  const result = {};
64
72
  for (const [key, val] of Object.entries(value)) {
65
- if (!validNames.has(key)) throw new InvalidConfigError(`Invalid agent name in agentPathOverride: "${key}". Use "claude", "codex", "rovodev", or "opencode".`);
73
+ if (!validNames.has(key)) throw new InvalidConfigError(`Invalid agent name in agentPathOverride: "${key}". Use ${formatAgentNameList()}.`);
66
74
  if (typeof val !== "string") throw new InvalidConfigError(`Invalid path for agentPathOverride.${key}: expected a string`);
67
75
  if (val.trim() === "") throw new InvalidConfigError(`Invalid path for agentPathOverride.${key}: expected a non-empty string`);
68
76
  result[key] = resolveConfigPath(val, configDir);
@@ -86,7 +94,7 @@ function normalizeAgentArgsOverride(value) {
86
94
  const validNames = new Set(AGENT_NAMES);
87
95
  const result = {};
88
96
  for (const [key, rawConfig] of Object.entries(value)) {
89
- if (!validNames.has(key)) throw new InvalidConfigError(`Invalid agent name in agentArgsOverride: "${key}". Use "claude", "codex", "rovodev", or "opencode".`);
97
+ if (!validNames.has(key)) throw new InvalidConfigError(`Invalid agent name in agentArgsOverride: "${key}". Use ${formatAgentNameList()}.`);
90
98
  const args = normalizeAgentExtraArgs(rawConfig, `agentArgsOverride.${key}`, key);
91
99
  if (args !== void 0) result[key] = args;
92
100
  }
@@ -151,6 +159,8 @@ function serializeConfig(config) {
151
159
  "# agentPathOverride:",
152
160
  "# claude: /path/to/custom-claude",
153
161
  "# codex: /path/to/custom-codex",
162
+ "# copilot: /path/to/custom-copilot",
163
+ "# pi: /path/to/custom-pi",
154
164
  "",
155
165
  "# Per-agent CLI arg overrides (optional)",
156
166
  "# agentArgsOverride:",
@@ -159,7 +169,17 @@ function serializeConfig(config) {
159
169
  "# - gpt-5.4",
160
170
  "# - -c",
161
171
  "# - model_reasoning_effort=\"high\"",
162
- "# - --full-auto"
172
+ "# - --full-auto",
173
+ "# copilot:",
174
+ "# - --model",
175
+ "# - gpt-5.4",
176
+ "# pi:",
177
+ "# - --provider",
178
+ "# - openai-codex",
179
+ "# - --model",
180
+ "# - gpt-5.5",
181
+ "# - --thinking",
182
+ "# - high"
163
183
  ];
164
184
  if (agentPathOverrideSection) lines.push(...agentPathOverrideSection.split("\n"));
165
185
  if (agentArgsOverrideSection) lines.push(...agentArgsOverrideSection.split("\n"));
@@ -425,6 +445,21 @@ function removeWorktree(baseCwd, worktreePath) {
425
445
  worktreePath
426
446
  ], baseCwd);
427
447
  }
448
+ function worktreeExists(baseCwd, worktreePath) {
449
+ let output;
450
+ try {
451
+ output = git([
452
+ "worktree",
453
+ "list",
454
+ "--porcelain"
455
+ ], baseCwd);
456
+ } catch {
457
+ return false;
458
+ }
459
+ const target = resolve(worktreePath);
460
+ for (const line of output.split("\n")) if (line.startsWith("worktree ") && resolve(line.slice(9)) === target) return true;
461
+ return false;
462
+ }
428
463
  //#endregion
429
464
  //#region src/core/agents/types.ts
430
465
  function buildAgentOutputSchema(opts) {
@@ -460,9 +495,15 @@ function buildAgentOutputSchema(opts) {
460
495
  //#endregion
461
496
  //#region src/core/run.ts
462
497
  const LOG_FILENAME = "gnhf.log";
498
+ const STOP_WHEN_FILENAME = "stop-when";
463
499
  function writeSchemaFile(schemaPath, includeStopField) {
464
500
  writeFileSync(schemaPath, JSON.stringify(buildAgentOutputSchema({ includeStopField }), null, 2), "utf-8");
465
501
  }
502
+ function readStopWhen(stopWhenPath) {
503
+ if (!existsSync(stopWhenPath)) return void 0;
504
+ const stopWhen = readFileSync(stopWhenPath, "utf-8").trim();
505
+ return stopWhen.length > 0 ? stopWhen : void 0;
506
+ }
466
507
  function ensureRunMetadataIgnored(cwd) {
467
508
  const excludePath = execFileSync("git", [
468
509
  "rev-parse",
@@ -496,6 +537,9 @@ function setupRun(runId, prompt, baseCommit, cwd, schemaOptions) {
496
537
  const hasStoredBaseCommit = existsSync(baseCommitPath);
497
538
  const resolvedBaseCommit = hasStoredBaseCommit ? readFileSync(baseCommitPath, "utf-8").trim() : baseCommit;
498
539
  if (!hasStoredBaseCommit) writeFileSync(baseCommitPath, `${baseCommit}\n`, "utf-8");
540
+ const stopWhenPath = join(runDir, STOP_WHEN_FILENAME);
541
+ const stopWhen = schemaOptions.stopWhen;
542
+ if (stopWhen !== void 0) writeFileSync(stopWhenPath, `${stopWhen}\n`, "utf-8");
499
543
  return {
500
544
  runId,
501
545
  runDir,
@@ -504,7 +548,9 @@ function setupRun(runId, prompt, baseCommit, cwd, schemaOptions) {
504
548
  schemaPath,
505
549
  logPath,
506
550
  baseCommit: resolvedBaseCommit,
507
- baseCommitPath
551
+ baseCommitPath,
552
+ stopWhenPath,
553
+ stopWhen
508
554
  };
509
555
  }
510
556
  function resumeRun(runId, cwd, schemaOptions) {
@@ -513,9 +559,19 @@ function resumeRun(runId, cwd, schemaOptions) {
513
559
  const promptPath = join(runDir, "prompt.md");
514
560
  const notesPath = join(runDir, "notes.md");
515
561
  const schemaPath = join(runDir, "output-schema.json");
516
- writeSchemaFile(schemaPath, schemaOptions.includeStopField);
517
562
  const logPath = join(runDir, LOG_FILENAME);
518
563
  const baseCommitPath = join(runDir, "base-commit");
564
+ const baseCommit = existsSync(baseCommitPath) ? readFileSync(baseCommitPath, "utf-8").trim() : backfillLegacyBaseCommit(runId, baseCommitPath, cwd);
565
+ const stopWhenPath = join(runDir, STOP_WHEN_FILENAME);
566
+ let stopWhen = readStopWhen(stopWhenPath);
567
+ if (schemaOptions.clearStopWhen) {
568
+ rmSync(stopWhenPath, { force: true });
569
+ stopWhen = void 0;
570
+ } else if (schemaOptions.stopWhen !== void 0) {
571
+ stopWhen = schemaOptions.stopWhen;
572
+ writeFileSync(stopWhenPath, `${stopWhen}\n`, "utf-8");
573
+ }
574
+ writeSchemaFile(schemaPath, schemaOptions.includeStopField || stopWhen !== void 0);
519
575
  return {
520
576
  runId,
521
577
  runDir,
@@ -523,8 +579,10 @@ function resumeRun(runId, cwd, schemaOptions) {
523
579
  notesPath,
524
580
  schemaPath,
525
581
  logPath,
526
- baseCommit: existsSync(baseCommitPath) ? readFileSync(baseCommitPath, "utf-8").trim() : backfillLegacyBaseCommit(runId, baseCommitPath, cwd),
527
- baseCommitPath
582
+ baseCommit,
583
+ baseCommitPath,
584
+ stopWhenPath,
585
+ stopWhen
528
586
  };
529
587
  }
530
588
  function backfillLegacyBaseCommit(runId, baseCommitPath, cwd) {
@@ -1044,7 +1102,7 @@ function setupAbortHandler(signal, child, reject, abortChild = () => {
1044
1102
  //#endregion
1045
1103
  //#region src/core/agents/claude.ts
1046
1104
  const DEFAULT_FINAL_RESULT_EXIT_GRACE_MS = 15e3;
1047
- function shouldUseWindowsShell$2(bin, platform) {
1105
+ function shouldUseWindowsShell$4(bin, platform) {
1048
1106
  if (platform !== "win32") return false;
1049
1107
  if (/\.(cmd|bat)$/i.test(bin)) return true;
1050
1108
  if (/[\\/]/.test(bin)) return false;
@@ -1105,7 +1163,7 @@ function buildClaudeArgs(prompt, schema, extraArgs) {
1105
1163
  ...userSpecifiedPermissionMode ? [] : ["--dangerously-skip-permissions"]
1106
1164
  ];
1107
1165
  }
1108
- function toTokenUsage(usage) {
1166
+ function toTokenUsage$1(usage) {
1109
1167
  return {
1110
1168
  inputTokens: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
1111
1169
  outputTokens: usage.output_tokens ?? 0,
@@ -1113,11 +1171,11 @@ function toTokenUsage(usage) {
1113
1171
  cacheCreationTokens: usage.cache_creation_input_tokens ?? 0
1114
1172
  };
1115
1173
  }
1116
- function isSameUsage(a, b) {
1174
+ function isSameUsage$1(a, b) {
1117
1175
  return a.inputTokens === b.inputTokens && a.outputTokens === b.outputTokens && a.cacheReadTokens === b.cacheReadTokens && a.cacheCreationTokens === b.cacheCreationTokens;
1118
1176
  }
1119
1177
  function extendsUsage(next, previous) {
1120
- return next.inputTokens >= previous.inputTokens && next.outputTokens >= previous.outputTokens && next.cacheReadTokens >= previous.cacheReadTokens && next.cacheCreationTokens >= previous.cacheCreationTokens && !isSameUsage(next, previous);
1178
+ return next.inputTokens >= previous.inputTokens && next.outputTokens >= previous.outputTokens && next.cacheReadTokens >= previous.cacheReadTokens && next.cacheCreationTokens >= previous.cacheCreationTokens && !isSameUsage$1(next, previous);
1121
1179
  }
1122
1180
  var ClaudeAgent = class {
1123
1181
  name = "claude";
@@ -1141,7 +1199,7 @@ var ClaudeAgent = class {
1141
1199
  const child = spawn(this.bin, buildClaudeArgs(prompt, this.schema, this.extraArgs), {
1142
1200
  cwd,
1143
1201
  detached: this.platform !== "win32",
1144
- shell: shouldUseWindowsShell$2(this.bin, this.platform),
1202
+ shell: shouldUseWindowsShell$4(this.bin, this.platform),
1145
1203
  stdio: [
1146
1204
  "ignore",
1147
1205
  "pipe",
@@ -1176,7 +1234,7 @@ var ClaudeAgent = class {
1176
1234
  parseJSONLStream(child.stdout, logStream, (event) => {
1177
1235
  if (event.type === "assistant") {
1178
1236
  const msg = event.message;
1179
- const nextUsage = toTokenUsage(msg.usage);
1237
+ const nextUsage = toTokenUsage$1(msg.usage);
1180
1238
  let messageId = msg.id;
1181
1239
  let previousUsage;
1182
1240
  if (messageId) {
@@ -1200,7 +1258,7 @@ var ClaudeAgent = class {
1200
1258
  previousUsage = usageByMessageId.get(messageId);
1201
1259
  pendingAnonymousAssistantUsage = null;
1202
1260
  lastAnonymousAssistantUsage = nextUsage;
1203
- } else if (lastAnonymousAssistantId && lastAnonymousAssistantUsage && isSameUsage(nextUsage, lastAnonymousAssistantUsage)) {
1261
+ } else if (lastAnonymousAssistantId && lastAnonymousAssistantUsage && isSameUsage$1(nextUsage, lastAnonymousAssistantUsage)) {
1204
1262
  messageId = lastAnonymousAssistantId;
1205
1263
  previousUsage = usageByMessageId.get(messageId);
1206
1264
  pendingAnonymousAssistantUsage ??= nextUsage;
@@ -1264,7 +1322,7 @@ var ClaudeAgent = class {
1264
1322
  return;
1265
1323
  }
1266
1324
  const output = terminalResultEvent.structured_output;
1267
- const usage = toTokenUsage(latestResultUsage ?? terminalResultEvent.usage);
1325
+ const usage = toTokenUsage$1(latestResultUsage ?? terminalResultEvent.usage);
1268
1326
  onUsage?.(usage);
1269
1327
  resolve({
1270
1328
  output,
@@ -1275,8 +1333,180 @@ var ClaudeAgent = class {
1275
1333
  }
1276
1334
  };
1277
1335
  //#endregion
1336
+ //#region src/core/agents/copilot.ts
1337
+ function shouldUseWindowsShell$3(bin, platform) {
1338
+ if (platform !== "win32") return false;
1339
+ if (/\.(cmd|bat)$/i.test(bin)) return true;
1340
+ if (/[\\/]/.test(bin)) return false;
1341
+ try {
1342
+ const firstMatch = execFileSync("where", [bin], {
1343
+ encoding: "utf8",
1344
+ stdio: [
1345
+ "ignore",
1346
+ "pipe",
1347
+ "ignore"
1348
+ ]
1349
+ }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
1350
+ return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false;
1351
+ } catch {
1352
+ return false;
1353
+ }
1354
+ }
1355
+ function terminateCopilotProcess(child, platform) {
1356
+ if (platform === "win32" && child.pid) {
1357
+ try {
1358
+ execFileSync("taskkill", [
1359
+ "/T",
1360
+ "/F",
1361
+ "/PID",
1362
+ String(child.pid)
1363
+ ], { stdio: "ignore" });
1364
+ } catch {}
1365
+ return;
1366
+ }
1367
+ child.kill("SIGTERM");
1368
+ }
1369
+ function userSpecifiedPermissionMode(userArgs) {
1370
+ return userArgs.some((arg) => arg === "--allow-all" || arg === "--yolo" || arg === "--allow-all-tools" || arg === "--allow-all-paths" || arg === "--allow-all-urls" || arg === "--allow-tool" || arg.startsWith("--allow-tool=") || arg === "--allow-url" || arg.startsWith("--allow-url=") || arg === "--deny-tool" || arg.startsWith("--deny-tool=") || arg === "--deny-url" || arg.startsWith("--deny-url=") || arg === "--available-tools" || arg.startsWith("--available-tools=") || arg === "--excluded-tools" || arg.startsWith("--excluded-tools="));
1371
+ }
1372
+ function buildCopilotPrompt(prompt, schema) {
1373
+ return `${prompt}
1374
+
1375
+ ## gnhf final output contract
1376
+
1377
+ When the iteration is complete, your final answer must be a single JSON object that matches this JSON Schema:
1378
+
1379
+ \`\`\`json
1380
+ ${JSON.stringify(schema, null, 2)}
1381
+ \`\`\`
1382
+
1383
+ Return only the JSON object in the final answer. Do not wrap it in Markdown. Do not include explanatory prose outside the JSON object.`;
1384
+ }
1385
+ function buildCopilotArgs(prompt, schema, extraArgs) {
1386
+ const userArgs = extraArgs ?? [];
1387
+ return [
1388
+ ...userArgs,
1389
+ "-p",
1390
+ buildCopilotPrompt(prompt, schema),
1391
+ "--output-format",
1392
+ "json",
1393
+ "--stream",
1394
+ "off",
1395
+ "--no-color",
1396
+ ...userSpecifiedPermissionMode(userArgs) ? [] : ["--allow-all"]
1397
+ ];
1398
+ }
1399
+ function stripJsonFence(text) {
1400
+ const trimmed = text.trim();
1401
+ return trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i)?.[1]?.trim() ?? trimmed;
1402
+ }
1403
+ function numberField$1(usage, names) {
1404
+ for (const name of names) {
1405
+ const value = usage[name];
1406
+ if (typeof value === "number") return value;
1407
+ }
1408
+ }
1409
+ function usageFromRecord(usage) {
1410
+ const inputTokens = numberField$1(usage, ["inputTokens", "input_tokens"]);
1411
+ const outputTokens = numberField$1(usage, ["outputTokens", "output_tokens"]);
1412
+ const cacheReadTokens = numberField$1(usage, [
1413
+ "cacheReadTokens",
1414
+ "cache_read_tokens",
1415
+ "cache_read_input_tokens"
1416
+ ]);
1417
+ const cacheCreationTokens = numberField$1(usage, [
1418
+ "cacheCreationTokens",
1419
+ "cacheWriteTokens",
1420
+ "cache_creation_tokens",
1421
+ "cache_creation_input_tokens",
1422
+ "cache_write_tokens"
1423
+ ]);
1424
+ if (inputTokens === void 0 && outputTokens === void 0 && cacheReadTokens === void 0 && cacheCreationTokens === void 0) return null;
1425
+ return {
1426
+ inputTokens: inputTokens ?? 0,
1427
+ outputTokens: outputTokens ?? 0,
1428
+ cacheReadTokens: cacheReadTokens ?? 0,
1429
+ cacheCreationTokens: cacheCreationTokens ?? 0
1430
+ };
1431
+ }
1432
+ var CopilotAgent = class {
1433
+ name = "copilot";
1434
+ bin;
1435
+ extraArgs;
1436
+ platform;
1437
+ schema;
1438
+ constructor(binOrDeps = {}) {
1439
+ const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
1440
+ this.bin = deps.bin ?? "copilot";
1441
+ this.extraArgs = deps.extraArgs;
1442
+ this.platform = deps.platform ?? process.platform;
1443
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1444
+ }
1445
+ run(prompt, cwd, options) {
1446
+ const { onUsage, onMessage, signal, logPath } = options ?? {};
1447
+ return new Promise((resolve, reject) => {
1448
+ const logStream = logPath ? createWriteStream(logPath) : null;
1449
+ const child = spawn(this.bin, buildCopilotArgs(prompt, this.schema, this.extraArgs), {
1450
+ cwd,
1451
+ shell: shouldUseWindowsShell$3(this.bin, this.platform),
1452
+ stdio: [
1453
+ "ignore",
1454
+ "pipe",
1455
+ "pipe"
1456
+ ],
1457
+ env: process.env
1458
+ });
1459
+ if (setupAbortHandler(signal, child, reject, () => terminateCopilotProcess(child, this.platform))) return;
1460
+ let lastAgentMessage = null;
1461
+ const cumulative = {
1462
+ inputTokens: 0,
1463
+ outputTokens: 0,
1464
+ cacheReadTokens: 0,
1465
+ cacheCreationTokens: 0
1466
+ };
1467
+ parseJSONLStream(child.stdout, logStream, (event) => {
1468
+ if (event.type === "assistant.message") {
1469
+ const data = event.data;
1470
+ if (typeof data.content === "string") {
1471
+ lastAgentMessage = data.content;
1472
+ onMessage?.(data.content);
1473
+ }
1474
+ if (typeof data.outputTokens === "number") {
1475
+ cumulative.outputTokens += data.outputTokens;
1476
+ onUsage?.({ ...cumulative });
1477
+ }
1478
+ }
1479
+ if ("usage" in event && event.usage) {
1480
+ const usage = usageFromRecord(event.usage);
1481
+ if (usage) {
1482
+ cumulative.inputTokens = usage.inputTokens;
1483
+ cumulative.outputTokens = Math.max(cumulative.outputTokens, usage.outputTokens);
1484
+ cumulative.cacheReadTokens = usage.cacheReadTokens;
1485
+ cumulative.cacheCreationTokens = usage.cacheCreationTokens;
1486
+ onUsage?.({ ...cumulative });
1487
+ }
1488
+ }
1489
+ });
1490
+ setupChildProcessHandlers(child, "copilot", logStream, reject, () => {
1491
+ if (!lastAgentMessage) {
1492
+ reject(/* @__PURE__ */ new Error("copilot returned no agent message"));
1493
+ return;
1494
+ }
1495
+ try {
1496
+ resolve({
1497
+ output: JSON.parse(stripJsonFence(lastAgentMessage)),
1498
+ usage: cumulative
1499
+ });
1500
+ } catch (err) {
1501
+ reject(/* @__PURE__ */ new Error(`Failed to parse copilot output: ${err instanceof Error ? err.message : err}`));
1502
+ }
1503
+ });
1504
+ });
1505
+ }
1506
+ };
1507
+ //#endregion
1278
1508
  //#region src/core/agents/codex.ts
1279
- function shouldUseWindowsShell$1(bin, platform) {
1509
+ function shouldUseWindowsShell$2(bin, platform) {
1280
1510
  if (platform !== "win32") return false;
1281
1511
  if (/\.(cmd|bat)$/i.test(bin)) return true;
1282
1512
  if (/[\\/]/.test(bin)) return false;
@@ -1342,7 +1572,7 @@ var CodexAgent = class {
1342
1572
  const logStream = logPath ? createWriteStream(logPath) : null;
1343
1573
  const child = spawn(this.bin, buildCodexArgs(prompt, this.schemaPath, this.extraArgs), {
1344
1574
  cwd,
1345
- shell: shouldUseWindowsShell$1(this.bin, this.platform),
1575
+ shell: shouldUseWindowsShell$2(this.bin, this.platform),
1346
1576
  stdio: [
1347
1577
  "ignore",
1348
1578
  "pipe",
@@ -2204,6 +2434,287 @@ var OpenCodeAgent = class {
2204
2434
  }
2205
2435
  };
2206
2436
  //#endregion
2437
+ //#region src/core/agents/pi.ts
2438
+ function shouldUseWindowsShell$1(bin, platform) {
2439
+ if (platform !== "win32") return false;
2440
+ if (/\.(cmd|bat)$/i.test(bin)) return true;
2441
+ if (/[\\/]/.test(bin)) return false;
2442
+ try {
2443
+ const firstMatch = execFileSync("where", [bin], {
2444
+ encoding: "utf8",
2445
+ stdio: [
2446
+ "ignore",
2447
+ "pipe",
2448
+ "ignore"
2449
+ ]
2450
+ }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
2451
+ return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false;
2452
+ } catch {
2453
+ return false;
2454
+ }
2455
+ }
2456
+ function terminatePiProcess(child, platform) {
2457
+ if (platform === "win32" && child.pid) {
2458
+ try {
2459
+ execFileSync("taskkill", [
2460
+ "/T",
2461
+ "/F",
2462
+ "/PID",
2463
+ String(child.pid)
2464
+ ], { stdio: "ignore" });
2465
+ } catch {}
2466
+ return;
2467
+ }
2468
+ if (child.pid) try {
2469
+ process.kill(-child.pid, "SIGTERM");
2470
+ return;
2471
+ } catch {}
2472
+ child.kill("SIGTERM");
2473
+ }
2474
+ function buildPiPrompt(prompt, schema) {
2475
+ return `${prompt}
2476
+
2477
+ ## gnhf final output contract
2478
+
2479
+ When the iteration is complete, your final assistant response must be only valid JSON matching this JSON Schema. Do not wrap it in Markdown fences. Do not include prose before or after the JSON object.
2480
+
2481
+ ${JSON.stringify(schema, null, 2)}`;
2482
+ }
2483
+ function buildPiArgs(extraArgs) {
2484
+ return [
2485
+ ...extraArgs ?? [],
2486
+ "--mode",
2487
+ "json",
2488
+ "--no-session"
2489
+ ];
2490
+ }
2491
+ function isRecord(value) {
2492
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2493
+ }
2494
+ function stringField(record, names) {
2495
+ for (const name of names) {
2496
+ const value = record[name];
2497
+ if (typeof value === "string") return value;
2498
+ }
2499
+ }
2500
+ function numberField(record, names) {
2501
+ for (const name of names) {
2502
+ const value = record[name];
2503
+ if (typeof value === "number") return value;
2504
+ }
2505
+ }
2506
+ function toTokenUsage(usage) {
2507
+ if (!usage) return null;
2508
+ return {
2509
+ inputTokens: numberField(usage, ["input"]) ?? 0,
2510
+ outputTokens: numberField(usage, ["output"]) ?? 0,
2511
+ cacheReadTokens: numberField(usage, ["cacheRead"]) ?? 0,
2512
+ cacheCreationTokens: numberField(usage, ["cacheWrite"]) ?? 0
2513
+ };
2514
+ }
2515
+ function isSameUsage(a, b) {
2516
+ return a.inputTokens === b.inputTokens && a.outputTokens === b.outputTokens && a.cacheReadTokens === b.cacheReadTokens && a.cacheCreationTokens === b.cacheCreationTokens;
2517
+ }
2518
+ function messageKey(message) {
2519
+ const responseId = stringField(message, ["responseId", "id"]);
2520
+ if (responseId) return responseId;
2521
+ const timestamp = message.timestamp;
2522
+ if (typeof timestamp === "string" || typeof timestamp === "number") return `timestamp:${timestamp}`;
2523
+ return null;
2524
+ }
2525
+ function roleOf(message) {
2526
+ return isRecord(message) && typeof message.role === "string" ? message.role : void 0;
2527
+ }
2528
+ function textFromContentBlock(block) {
2529
+ if (typeof block === "string") return block;
2530
+ if (!isRecord(block)) return null;
2531
+ if (typeof block.text === "string") return block.text;
2532
+ if (typeof block.content === "string") return block.content;
2533
+ return null;
2534
+ }
2535
+ function textFromAssistantMessage(message) {
2536
+ if (!message) return "";
2537
+ if (typeof message.text === "string") return message.text;
2538
+ if (typeof message.content === "string") return message.content;
2539
+ if (Array.isArray(message.content)) return message.content.map(textFromContentBlock).filter((text) => text !== null).join("");
2540
+ return "";
2541
+ }
2542
+ function compactJson(value) {
2543
+ try {
2544
+ return JSON.stringify(value);
2545
+ } catch {
2546
+ return String(value);
2547
+ }
2548
+ }
2549
+ function validatePiOutput(value, schema) {
2550
+ if (!isRecord(value)) throw new Error("expected an object");
2551
+ if (typeof value.success !== "boolean") throw new Error("success must be a boolean");
2552
+ if (typeof value.summary !== "string") throw new Error("summary must be a string");
2553
+ if (!Array.isArray(value.key_changes_made) || !value.key_changes_made.every((item) => typeof item === "string")) throw new Error("key_changes_made must be an array of strings");
2554
+ if (!Array.isArray(value.key_learnings) || !value.key_learnings.every((item) => typeof item === "string")) throw new Error("key_learnings must be an array of strings");
2555
+ if (schema.required.includes("should_fully_stop") && typeof value.should_fully_stop !== "boolean") throw new Error("should_fully_stop must be a boolean");
2556
+ if (schema.additionalProperties === false) {
2557
+ const allowed = new Set(Object.keys(schema.properties));
2558
+ const extraKey = Object.keys(value).find((key) => !allowed.has(key));
2559
+ if (extraKey) throw new Error(`unexpected property ${extraKey}`);
2560
+ }
2561
+ return value;
2562
+ }
2563
+ function textByIndexToString(textByIndex) {
2564
+ return [...textByIndex.entries()].sort(([a], [b]) => a - b).map(([, text]) => text).join("");
2565
+ }
2566
+ var PiAgent = class {
2567
+ name = "pi";
2568
+ bin;
2569
+ extraArgs;
2570
+ platform;
2571
+ schema;
2572
+ constructor(deps = {}) {
2573
+ this.bin = deps.bin ?? "pi";
2574
+ this.extraArgs = deps.extraArgs;
2575
+ this.platform = deps.platform ?? process.platform;
2576
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
2577
+ }
2578
+ run(prompt, cwd, options) {
2579
+ const { onUsage, onMessage, signal, logPath } = options ?? {};
2580
+ return new Promise((resolve, reject) => {
2581
+ const logStream = logPath ? createWriteStream(logPath) : null;
2582
+ const child = spawn(this.bin, buildPiArgs(this.extraArgs), {
2583
+ cwd,
2584
+ detached: this.platform !== "win32",
2585
+ shell: shouldUseWindowsShell$1(this.bin, this.platform),
2586
+ stdio: [
2587
+ "pipe",
2588
+ "pipe",
2589
+ "pipe"
2590
+ ],
2591
+ env: process.env
2592
+ });
2593
+ child.stdin?.write(buildPiPrompt(prompt, this.schema));
2594
+ child.stdin?.end();
2595
+ if (setupAbortHandler(signal, child, reject, () => terminatePiProcess(child, this.platform))) return;
2596
+ let latestAssistantMessage = null;
2597
+ const streamTextByIndex = /* @__PURE__ */ new Map();
2598
+ const completeTextByIndex = /* @__PURE__ */ new Map();
2599
+ const usageByMessageKey = /* @__PURE__ */ new Map();
2600
+ let lastEmittedUsage = {
2601
+ inputTokens: 0,
2602
+ outputTokens: 0,
2603
+ cacheReadTokens: 0,
2604
+ cacheCreationTokens: 0
2605
+ };
2606
+ let anonymousKeySeq = 0;
2607
+ let currentStreamingMessageKey = null;
2608
+ const updateUsage = (message, streaming = false) => {
2609
+ const usage = isRecord(message.usage) ? toTokenUsage(message.usage) : null;
2610
+ if (!usage) return;
2611
+ let key = messageKey(message);
2612
+ if (key === null) if (streaming && currentStreamingMessageKey !== null) key = currentStreamingMessageKey;
2613
+ else {
2614
+ key = `assistant-anonymous-${anonymousKeySeq++}`;
2615
+ if (streaming) currentStreamingMessageKey = key;
2616
+ }
2617
+ usageByMessageKey.set(key, usage);
2618
+ const cumulative = {
2619
+ inputTokens: 0,
2620
+ outputTokens: 0,
2621
+ cacheReadTokens: 0,
2622
+ cacheCreationTokens: 0
2623
+ };
2624
+ for (const entry of usageByMessageKey.values()) {
2625
+ cumulative.inputTokens += entry.inputTokens;
2626
+ cumulative.outputTokens += entry.outputTokens;
2627
+ cumulative.cacheReadTokens += entry.cacheReadTokens;
2628
+ cumulative.cacheCreationTokens += entry.cacheCreationTokens;
2629
+ }
2630
+ if (!isSameUsage(cumulative, lastEmittedUsage)) {
2631
+ lastEmittedUsage = cumulative;
2632
+ onUsage?.({ ...cumulative });
2633
+ }
2634
+ };
2635
+ const rememberAssistantMessage = (message, streaming = false) => {
2636
+ if (!isRecord(message) || roleOf(message) !== "assistant") return;
2637
+ latestAssistantMessage = message;
2638
+ updateUsage(message, streaming);
2639
+ };
2640
+ parseJSONLStream(child.stdout, logStream, (event) => {
2641
+ if (!isRecord(event)) return;
2642
+ if (event.type === "message_update") {
2643
+ rememberAssistantMessage(event.message, true);
2644
+ if (isRecord(event.assistantMessageEvent)) {
2645
+ const assistantEvent = event.assistantMessageEvent;
2646
+ const contentIndex = numberField(assistantEvent, ["contentIndex", "content_index"]) ?? 0;
2647
+ if (assistantEvent.type === "text_delta") {
2648
+ const delta = stringField(assistantEvent, [
2649
+ "delta",
2650
+ "text",
2651
+ "content"
2652
+ ]);
2653
+ if (delta) {
2654
+ const next = (streamTextByIndex.get(contentIndex) ?? "") + delta;
2655
+ streamTextByIndex.set(contentIndex, next);
2656
+ const visible = next.trim();
2657
+ if (visible) onMessage?.(visible);
2658
+ }
2659
+ }
2660
+ if (assistantEvent.type === "text_end") {
2661
+ const text = stringField(assistantEvent, ["text", "content"]) ?? streamTextByIndex.get(contentIndex) ?? "";
2662
+ completeTextByIndex.set(contentIndex, text);
2663
+ const visible = text.trim();
2664
+ if (visible) onMessage?.(visible);
2665
+ }
2666
+ }
2667
+ }
2668
+ if (event.type === "message_end" || event.type === "turn_end") {
2669
+ rememberAssistantMessage(event.message, true);
2670
+ currentStreamingMessageKey = null;
2671
+ }
2672
+ if (event.type === "agent_end" && Array.isArray(event.messages) && !latestAssistantMessage) for (let i = event.messages.length - 1; i >= 0; i -= 1) {
2673
+ const message = event.messages[i];
2674
+ if (roleOf(message) === "assistant") {
2675
+ rememberAssistantMessage(message);
2676
+ break;
2677
+ }
2678
+ }
2679
+ });
2680
+ setupChildProcessHandlers(child, "pi", logStream, reject, () => {
2681
+ if (latestAssistantMessage) {
2682
+ const stopReason = latestAssistantMessage.stopReason;
2683
+ if (stopReason === "error" || stopReason === "aborted") {
2684
+ const errorMessage = stringField(latestAssistantMessage, [
2685
+ "errorMessage",
2686
+ "error",
2687
+ "message"
2688
+ ]) ?? compactJson(latestAssistantMessage);
2689
+ reject(/* @__PURE__ */ new Error(`pi reported error: ${errorMessage}`));
2690
+ return;
2691
+ }
2692
+ }
2693
+ const finalText = textFromAssistantMessage(latestAssistantMessage).trim() || textByIndexToString(completeTextByIndex).trim() || textByIndexToString(streamTextByIndex).trim();
2694
+ if (!finalText) {
2695
+ reject(/* @__PURE__ */ new Error("pi returned no text output"));
2696
+ return;
2697
+ }
2698
+ let parsed;
2699
+ try {
2700
+ parsed = JSON.parse(finalText);
2701
+ } catch (err) {
2702
+ reject(/* @__PURE__ */ new Error(`Failed to parse pi output: ${err instanceof Error ? err.message : err}`));
2703
+ return;
2704
+ }
2705
+ try {
2706
+ resolve({
2707
+ output: validatePiOutput(parsed, this.schema),
2708
+ usage: lastEmittedUsage
2709
+ });
2710
+ } catch (err) {
2711
+ reject(/* @__PURE__ */ new Error(`Invalid pi output: ${err instanceof Error ? err.message : err}`));
2712
+ }
2713
+ });
2714
+ });
2715
+ }
2716
+ };
2717
+ //#endregion
2207
2718
  //#region src/core/agents/rovodev.ts
2208
2719
  function buildSystemPrompt(schema) {
2209
2720
  return [
@@ -2829,11 +3340,21 @@ function createAgent(name, runInfo, pathOverride, agentArgsOverride, options) {
2829
3340
  bin: pathOverride,
2830
3341
  extraArgs: agentArgsOverride
2831
3342
  });
3343
+ case "copilot": return new CopilotAgent({
3344
+ bin: pathOverride,
3345
+ extraArgs: agentArgsOverride,
3346
+ schema
3347
+ });
2832
3348
  case "opencode": return new OpenCodeAgent({
2833
3349
  bin: pathOverride,
2834
3350
  extraArgs: agentArgsOverride,
2835
3351
  schema
2836
3352
  });
3353
+ case "pi": return new PiAgent({
3354
+ bin: pathOverride,
3355
+ extraArgs: agentArgsOverride,
3356
+ schema
3357
+ });
2837
3358
  case "rovodev": return new RovoDevAgent(runInfo.schemaPath, {
2838
3359
  bin: pathOverride,
2839
3360
  extraArgs: agentArgsOverride
@@ -2841,6 +3362,19 @@ function createAgent(name, runInfo, pathOverride, agentArgsOverride, options) {
2841
3362
  }
2842
3363
  }
2843
3364
  //#endregion
3365
+ //#region src/core/interrupt-state.ts
3366
+ function getInterruptDisposition(state) {
3367
+ if (state.status === "aborted") return "exit";
3368
+ if (state.gracefulStopRequested || state.status === "stopped") return "force-stop";
3369
+ return "request-graceful-stop";
3370
+ }
3371
+ function getInterruptHint(state) {
3372
+ const disposition = getInterruptDisposition(state);
3373
+ if (disposition === "exit") return "exit";
3374
+ if (disposition === "force-stop") return "force-stop";
3375
+ return "resume";
3376
+ }
3377
+ //#endregion
2844
3378
  //#region src/templates/iteration-prompt.ts
2845
3379
  function buildIterationPrompt(params) {
2846
3380
  const outputFields = [
@@ -2887,8 +3421,10 @@ var Orchestrator = class extends EventEmitter {
2887
3421
  activeAbortController = null;
2888
3422
  pendingAbortReason = null;
2889
3423
  loopDone = false;
3424
+ stoppedEventEmitted = false;
2890
3425
  state = {
2891
3426
  status: "running",
3427
+ gracefulStopRequested: false,
2892
3428
  currentIteration: 0,
2893
3429
  totalInputTokens: 0,
2894
3430
  totalOutputTokens: 0,
@@ -2914,7 +3450,27 @@ var Orchestrator = class extends EventEmitter {
2914
3450
  this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
2915
3451
  }
2916
3452
  getState() {
2917
- return { ...this.state };
3453
+ return {
3454
+ ...this.state,
3455
+ interruptHint: getInterruptHint(this.state)
3456
+ };
3457
+ }
3458
+ requestGracefulStop() {
3459
+ if (this.stopRequested || this.state.gracefulStopRequested || this.loopDone) return;
3460
+ this.state.gracefulStopRequested = true;
3461
+ appendDebugLog("orchestrator:graceful-stop-requested", {
3462
+ iteration: this.state.currentIteration,
3463
+ hasActiveIteration: this.activeIterationPromise !== null,
3464
+ status: this.state.status
3465
+ });
3466
+ this.emit("state", this.getState());
3467
+ if (this.state.status === "waiting") this.activeAbortController?.abort();
3468
+ }
3469
+ handleInterrupt() {
3470
+ const disposition = getInterruptDisposition(this.state);
3471
+ if (disposition === "request-graceful-stop") this.requestGracefulStop();
3472
+ else if (disposition === "force-stop") this.stop();
3473
+ return disposition;
2918
3474
  }
2919
3475
  stop() {
2920
3476
  this.stopRequested = true;
@@ -2924,8 +3480,9 @@ var Orchestrator = class extends EventEmitter {
2924
3480
  loopDone: this.loopDone
2925
3481
  });
2926
3482
  this.activeAbortController?.abort();
3483
+ this.state.gracefulStopRequested = false;
2927
3484
  if (this.loopDone) {
2928
- this.emit("stopped");
3485
+ this.emitStopped();
2929
3486
  return;
2930
3487
  }
2931
3488
  if (this.stopPromise) return;
@@ -2950,7 +3507,7 @@ var Orchestrator = class extends EventEmitter {
2950
3507
  resetHard(this.cwd);
2951
3508
  this.state.status = "stopped";
2952
3509
  this.emit("state", this.getState());
2953
- this.emit("stopped");
3510
+ this.emitStopped();
2954
3511
  })();
2955
3512
  }
2956
3513
  async start() {
@@ -2974,6 +3531,7 @@ var Orchestrator = class extends EventEmitter {
2974
3531
  this.abort(preIterationAbortReason);
2975
3532
  break;
2976
3533
  }
3534
+ if (this.stopForGracefulShutdown()) break;
2977
3535
  this.state.currentIteration++;
2978
3536
  this.state.status = "running";
2979
3537
  this.emit("iteration:start", this.state.currentIteration);
@@ -3029,6 +3587,7 @@ var Orchestrator = class extends EventEmitter {
3029
3587
  totalOutputTokens: this.state.totalOutputTokens,
3030
3588
  commitCount: this.state.commitCount
3031
3589
  });
3590
+ if (this.stopForGracefulShutdown()) break;
3032
3591
  if (this.limits.stopWhen !== void 0 && result.shouldFullyStop) {
3033
3592
  this.abort("stop condition met");
3034
3593
  break;
@@ -3059,6 +3618,7 @@ var Orchestrator = class extends EventEmitter {
3059
3618
  });
3060
3619
  this.state.waitingUntil = null;
3061
3620
  if (!this.stopRequested) {
3621
+ if (this.stopForGracefulShutdown()) break;
3062
3622
  this.state.status = "running";
3063
3623
  this.emit("state", this.getState());
3064
3624
  }
@@ -3075,6 +3635,7 @@ var Orchestrator = class extends EventEmitter {
3075
3635
  if (this.stopPromise) await this.stopPromise;
3076
3636
  else await this.closeAgent();
3077
3637
  this.loopDone = true;
3638
+ if (this.didStopWithoutForce()) this.emitStopped();
3078
3639
  appendDebugLog("orchestrator:end", {
3079
3640
  status: this.state.status,
3080
3641
  iterations: this.state.currentIteration,
@@ -3237,8 +3798,27 @@ var Orchestrator = class extends EventEmitter {
3237
3798
  if (totalTokens < this.limits.maxTokens) return null;
3238
3799
  return `max tokens reached (${totalTokens}/${this.limits.maxTokens})`;
3239
3800
  }
3801
+ finishGracefulStop() {
3802
+ this.state.status = "stopped";
3803
+ this.state.gracefulStopRequested = false;
3804
+ this.state.waitingUntil = null;
3805
+ appendDebugLog("orchestrator:graceful-stop-complete", {
3806
+ iteration: this.state.currentIteration,
3807
+ consecutiveFailures: this.state.consecutiveFailures
3808
+ });
3809
+ this.emit("state", this.getState());
3810
+ }
3811
+ stopForGracefulShutdown() {
3812
+ if (!this.state.gracefulStopRequested) return false;
3813
+ this.finishGracefulStop();
3814
+ return true;
3815
+ }
3816
+ didStopWithoutForce() {
3817
+ return this.stopPromise === null && this.state.status === "stopped";
3818
+ }
3240
3819
  abort(reason) {
3241
3820
  this.state.status = "aborted";
3821
+ this.state.gracefulStopRequested = false;
3242
3822
  this.state.lastMessage = reason;
3243
3823
  this.state.waitingUntil = null;
3244
3824
  appendDebugLog("orchestrator:abort", {
@@ -3256,6 +3836,11 @@ var Orchestrator = class extends EventEmitter {
3256
3836
  appendDebugLog("agent:close:error", { error: serializeError(err) });
3257
3837
  }
3258
3838
  }
3839
+ emitStopped() {
3840
+ if (this.stoppedEventEmitted) return;
3841
+ this.stoppedEventEmitted = true;
3842
+ this.emit("stopped");
3843
+ }
3259
3844
  snapshotGitState() {
3260
3845
  try {
3261
3846
  return {
@@ -3324,6 +3909,7 @@ const INITIAL_ELAPSED_MS = 29237e3;
3324
3909
  var MockOrchestrator = class extends EventEmitter {
3325
3910
  state = {
3326
3911
  status: "running",
3912
+ gracefulStopRequested: false,
3327
3913
  currentIteration: 14,
3328
3914
  totalInputTokens: 873e5,
3329
3915
  totalOutputTokens: 86e4,
@@ -3340,20 +3926,35 @@ var MockOrchestrator = class extends EventEmitter {
3340
3926
  tokenTimer = null;
3341
3927
  messageTimer = null;
3342
3928
  messageIndex = 0;
3929
+ stoppedEventEmitted = false;
3343
3930
  getState() {
3344
3931
  return {
3345
3932
  ...this.state,
3933
+ interruptHint: getInterruptHint(this.state),
3346
3934
  iterations: [...this.state.iterations]
3347
3935
  };
3348
3936
  }
3349
3937
  stop() {
3938
+ if (this.state.status === "stopped") return;
3350
3939
  if (this.tokenTimer) clearTimeout(this.tokenTimer);
3351
3940
  if (this.messageTimer) clearTimeout(this.messageTimer);
3352
3941
  this.tokenTimer = null;
3353
3942
  this.messageTimer = null;
3354
3943
  this.state.status = "stopped";
3944
+ this.state.gracefulStopRequested = false;
3945
+ this.emit("state", this.getState());
3946
+ this.emitStopped();
3947
+ }
3948
+ requestGracefulStop() {
3949
+ if (this.state.gracefulStopRequested || this.state.status !== "running") return;
3950
+ this.state.gracefulStopRequested = true;
3355
3951
  this.emit("state", this.getState());
3356
- this.emit("stopped");
3952
+ }
3953
+ handleInterrupt() {
3954
+ const disposition = getInterruptDisposition(this.state);
3955
+ if (disposition === "request-graceful-stop") this.requestGracefulStop();
3956
+ else if (disposition === "force-stop") this.stop();
3957
+ return disposition;
3357
3958
  }
3358
3959
  start() {
3359
3960
  this.emit("state", this.getState());
@@ -3362,6 +3963,10 @@ var MockOrchestrator = class extends EventEmitter {
3362
3963
  }
3363
3964
  scheduleTokenBump() {
3364
3965
  this.tokenTimer = setTimeout(() => {
3966
+ if (this.state.gracefulStopRequested) {
3967
+ this.stop();
3968
+ return;
3969
+ }
3365
3970
  this.state.totalInputTokens += randInt(4e4, 18e4);
3366
3971
  this.state.totalOutputTokens += randInt(200, 2e3);
3367
3972
  this.emit("state", this.getState());
@@ -3371,12 +3976,21 @@ var MockOrchestrator = class extends EventEmitter {
3371
3976
  scheduleNextMessage() {
3372
3977
  const delay = randInt(3e3, 7e3);
3373
3978
  this.messageTimer = setTimeout(() => {
3979
+ if (this.state.gracefulStopRequested) {
3980
+ this.stop();
3981
+ return;
3982
+ }
3374
3983
  this.messageIndex = (this.messageIndex + 1) % AGENT_MESSAGES.length;
3375
3984
  this.state.lastMessage = AGENT_MESSAGES[this.messageIndex];
3376
3985
  this.emit("state", this.getState());
3377
3986
  this.scheduleNextMessage();
3378
3987
  }, delay);
3379
3988
  }
3989
+ emitStopped() {
3990
+ if (this.stoppedEventEmitted) return;
3991
+ this.stoppedEventEmitted = true;
3992
+ this.emit("stopped");
3993
+ }
3380
3994
  };
3381
3995
  //#endregion
3382
3996
  //#region src/utils/stars.ts
@@ -3666,6 +4280,7 @@ const MOON_PHASE_PERIOD = 1600;
3666
4280
  const MAX_MSG_LINES = 3;
3667
4281
  const MAX_MSG_LINE_LEN = CONTENT_WIDTH;
3668
4282
  const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
4283
+ const GRACEFUL_STOP_HINT = "[graceful stop requested, ctrl+c again to force stop, gnhf again to resume]";
3669
4284
  const DONE_HINT = "[ctrl+c to exit]";
3670
4285
  function spacedLabel(text) {
3671
4286
  return text.split("").join(" ");
@@ -3804,8 +4419,8 @@ function centerLineCells(content, width) {
3804
4419
  ...emptyCells(rightPad)
3805
4420
  ];
3806
4421
  }
3807
- function renderResumeHintCells(width, done) {
3808
- return centerLineCells(textToCells(done ? DONE_HINT : RESUME_HINT, "dim"), width);
4422
+ function renderResumeHintCells(width, interruptHint) {
4423
+ return centerLineCells(textToCells(interruptHint === "exit" ? DONE_HINT : interruptHint === "force-stop" ? GRACEFUL_STOP_HINT : RESUME_HINT, "dim"), width);
3809
4424
  }
3810
4425
  /**
3811
4426
  * Builds the centered content viewport for the renderer.
@@ -3913,8 +4528,7 @@ function buildFrameCells(prompt, agentName, state, topStars, bottomStars, sideSt
3913
4528
  ]);
3914
4529
  }
3915
4530
  for (let y = 0; y < bottomHeight; y++) frame.push(renderStarLineCells(bottomStars, terminalWidth, y, now));
3916
- const isDone = state.status === "aborted";
3917
- frame.push(renderResumeHintCells(terminalWidth, isDone));
4531
+ frame.push(renderResumeHintCells(terminalWidth, state.interruptHint));
3918
4532
  frame.push(emptyCells(terminalWidth));
3919
4533
  return frame;
3920
4534
  }
@@ -3938,6 +4552,7 @@ var Renderer = class {
3938
4552
  seedTop;
3939
4553
  seedBottom;
3940
4554
  seedSide;
4555
+ onInterrupt;
3941
4556
  handleState = (newState) => {
3942
4557
  this.state = {
3943
4558
  ...newState,
@@ -3948,10 +4563,11 @@ var Renderer = class {
3948
4563
  handleStopped = () => {
3949
4564
  this.stop("stopped");
3950
4565
  };
3951
- constructor(orchestrator, prompt, agentName) {
4566
+ constructor(orchestrator, prompt, agentName, onInterrupt) {
3952
4567
  this.orchestrator = orchestrator;
3953
4568
  this.prompt = prompt;
3954
4569
  this.agentName = agentName;
4570
+ this.onInterrupt = onInterrupt;
3955
4571
  this.state = orchestrator.getState();
3956
4572
  this.seedTop = Math.floor(Math.random() * 2147483646) + 1;
3957
4573
  this.seedBottom = Math.floor(Math.random() * 2147483646) + 1;
@@ -3967,10 +4583,7 @@ var Renderer = class {
3967
4583
  process$1.stdin.setRawMode(true);
3968
4584
  process$1.stdin.resume();
3969
4585
  process$1.stdin.on("data", (data) => {
3970
- if (data[0] === 3) {
3971
- this.stop("interrupted");
3972
- this.orchestrator.stop();
3973
- }
4586
+ if (data[0] === 3) this.onInterrupt();
3974
4587
  });
3975
4588
  }
3976
4589
  this.interval = setInterval(() => this.render(), TICK_MS);
@@ -4067,6 +4680,8 @@ const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
4067
4680
  const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
4068
4681
  const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
4069
4682
  const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
4683
+ const AGENT_NAME_SET = new Set(AGENT_NAMES);
4684
+ const AGENT_NAME_LIST = `"${AGENT_NAMES.slice(0, -1).join("\", \"")}", or "${AGENT_NAMES[AGENT_NAMES.length - 1]}"`;
4070
4685
  var PromptSignalError = class extends Error {
4071
4686
  constructor(signal) {
4072
4687
  super(signal);
@@ -4088,6 +4703,22 @@ function humanizeErrorMessage(message) {
4088
4703
  if (message.includes("not a git repository")) return "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
4089
4704
  return message;
4090
4705
  }
4706
+ function isAgentName(name) {
4707
+ return AGENT_NAME_SET.has(name);
4708
+ }
4709
+ function buildSchemaOptions(stopWhen) {
4710
+ return stopWhen === void 0 ? { includeStopField: false } : {
4711
+ includeStopField: true,
4712
+ stopWhen
4713
+ };
4714
+ }
4715
+ function buildResumeSchemaOptions(stopWhen) {
4716
+ if (stopWhen === "") return {
4717
+ includeStopField: false,
4718
+ clearStopWhen: true
4719
+ };
4720
+ return buildSchemaOptions(stopWhen);
4721
+ }
4091
4722
  function initializeNewBranch(prompt, cwd, schemaOptions) {
4092
4723
  ensureCleanWorkingTree(cwd);
4093
4724
  const baseCommit = getHeadCommit(cwd);
@@ -4102,11 +4733,27 @@ function initializeWorktreeRun(prompt, cwd, schemaOptions) {
4102
4733
  const branchName = slugifyPrompt(prompt);
4103
4734
  const runId = branchName.split("/")[1];
4104
4735
  const worktreePath = join(dirname(repoRoot), `${basename(repoRoot)}-gnhf-worktrees`, runId);
4736
+ if (worktreeExists(repoRoot, worktreePath) && existsSync(join(worktreePath, ".gnhf", "runs", runId))) {
4737
+ let worktreeBranch;
4738
+ try {
4739
+ worktreeBranch = getCurrentBranch(worktreePath);
4740
+ } catch (error) {
4741
+ throw new Error(`Preserved worktree at ${worktreePath} is in an unexpected state (${error instanceof Error ? error.message : String(error)}). Fix the worktree manually or remove it with "git worktree remove ${worktreePath}" before re-running.`);
4742
+ }
4743
+ if (worktreeBranch !== branchName) throw new Error(`Preserved worktree at ${worktreePath} is on branch "${worktreeBranch}" rather than "${branchName}". Restore it to "${branchName}" with "git -C ${worktreePath} checkout ${branchName}", or remove the worktree with "git worktree remove ${worktreePath}" to start fresh.`);
4744
+ return {
4745
+ runInfo: resumeRun(runId, worktreePath, schemaOptions),
4746
+ worktreePath,
4747
+ effectiveCwd: worktreePath,
4748
+ resumed: true
4749
+ };
4750
+ }
4105
4751
  createWorktree(repoRoot, worktreePath, branchName);
4106
4752
  return {
4107
4753
  runInfo: setupRun(runId, prompt, baseCommit, worktreePath, schemaOptions),
4108
4754
  worktreePath,
4109
- effectiveCwd: worktreePath
4755
+ effectiveCwd: worktreePath,
4756
+ resumed: false
4110
4757
  };
4111
4758
  }
4112
4759
  function openPromptTerminal() {
@@ -4232,11 +4879,13 @@ function readReexecStdinPrompt(env) {
4232
4879
  }
4233
4880
  }
4234
4881
  const program = new Command();
4235
- program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude, codex, rovodev, or opencode)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--stop-when <condition>", "End the loop when the agent reports this natural-language condition is met").option("--prevent-sleep <mode>", "Prevent system sleep during the run (\"on\" or \"off\")", parseOnOffBoolean).option("--worktree", "Run in a separate git worktree (enables multiple agents on the same repo)", false).option("--mock", "", false).action(async (promptArg, options) => {
4882
+ program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", `Agent to use (${AGENT_NAMES.join(", ")})`).option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--stop-when <condition>", "End when the agent reports this condition; resumes reuse it, pass a new value to overwrite or \"\" to clear").option("--prevent-sleep <mode>", "Prevent system sleep during the run (\"on\" or \"off\")", parseOnOffBoolean).option("--worktree", "Run in a separate git worktree (enables multiple agents on the same repo)", false).option("--mock", "", false).action(async (promptArg, options) => {
4236
4883
  if (options.mock) {
4237
4884
  const mock = new MockOrchestrator();
4238
4885
  enterAltScreen();
4239
- const renderer = new Renderer(mock, "let's minimize app startup latency without sacrificing any functionality", "claude");
4886
+ const renderer = new Renderer(mock, "let's minimize app startup latency without sacrificing any functionality", "claude", () => {
4887
+ mock.handleInterrupt();
4888
+ });
4240
4889
  renderer.start();
4241
4890
  mock.start();
4242
4891
  await renderer.waitUntilExit();
@@ -4248,16 +4897,16 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4248
4897
  let prompt = promptArg;
4249
4898
  let promptFromStdin = false;
4250
4899
  const agentName = options.agent;
4251
- if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
4252
- console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
4900
+ if (agentName !== void 0 && !isAgentName(agentName)) {
4901
+ console.error(`Unknown agent: ${options.agent}. Use ${AGENT_NAME_LIST}.`);
4253
4902
  process$1.exit(1);
4254
4903
  }
4255
4904
  const config = {
4256
4905
  ...loadConfig(agentName ? { agent: agentName } : {}),
4257
4906
  ...options.preventSleep === void 0 ? {} : { preventSleep: options.preventSleep }
4258
4907
  };
4259
- if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
4260
- console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
4908
+ if (!isAgentName(config.agent)) {
4909
+ console.error(`Unknown agent: ${config.agent}. Use ${AGENT_NAME_LIST}.`);
4261
4910
  process$1.exit(1);
4262
4911
  }
4263
4912
  if (!prompt && process$1.env.GNHF_SLEEP_INHIBITED === "1") prompt = readReexecStdinPrompt(process$1.env);
@@ -4271,7 +4920,9 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4271
4920
  let worktreeCleanup = null;
4272
4921
  const currentBranch = getCurrentBranch(cwd);
4273
4922
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
4274
- const schemaOptions = { includeStopField: options.stopWhen !== void 0 };
4923
+ const cliStopWhen = options.stopWhen === "" ? void 0 : options.stopWhen;
4924
+ let effectiveStopWhen = cliStopWhen;
4925
+ let schemaOptions = buildSchemaOptions(effectiveStopWhen);
4275
4926
  let runInfo;
4276
4927
  let startIteration = 0;
4277
4928
  if (options.worktree) {
@@ -4287,31 +4938,49 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4287
4938
  runInfo = wt.runInfo;
4288
4939
  effectiveCwd = wt.effectiveCwd;
4289
4940
  worktreePath = wt.worktreePath;
4290
- worktreeCleanup = () => {
4291
- try {
4292
- removeWorktree(cwd, wt.worktreePath);
4293
- } catch {}
4294
- };
4295
- const exitCleanup = worktreeCleanup;
4296
- process$1.on("exit", () => {
4297
- if (worktreeCleanup === exitCleanup) exitCleanup();
4298
- });
4941
+ if (wt.resumed) {
4942
+ startIteration = getLastIterationNumber(runInfo);
4943
+ console.error(`\n gnhf: resuming preserved worktree at ${worktreePath}\n gnhf: continuing run ${runInfo.runId} from iteration ${startIteration}\n`);
4944
+ } else {
4945
+ worktreeCleanup = () => {
4946
+ try {
4947
+ removeWorktree(cwd, wt.worktreePath);
4948
+ } catch {}
4949
+ };
4950
+ const exitCleanup = worktreeCleanup;
4951
+ process$1.on("exit", () => {
4952
+ if (worktreeCleanup === exitCleanup) exitCleanup();
4953
+ });
4954
+ }
4299
4955
  } else if (onGnhfBranch) {
4300
4956
  const existingRunId = currentBranch.slice(5);
4301
- const existing = resumeRun(existingRunId, cwd, schemaOptions);
4957
+ let existing = resumeRun(existingRunId, cwd, { includeStopField: false });
4302
4958
  const existingPrompt = readFileSync(existing.promptPath, "utf-8");
4303
4959
  if (!prompt || prompt === existingPrompt) {
4960
+ existing = resumeRun(existingRunId, cwd, buildResumeSchemaOptions(options.stopWhen));
4961
+ const resumeStopWhen = existing.stopWhen;
4962
+ const resumeSchemaOptions = buildSchemaOptions(resumeStopWhen);
4304
4963
  prompt = existingPrompt;
4305
4964
  runInfo = existing;
4965
+ effectiveStopWhen = resumeStopWhen;
4966
+ schemaOptions = resumeSchemaOptions;
4306
4967
  startIteration = getLastIterationNumber(existing);
4307
4968
  } else {
4308
4969
  const answer = await ask(`You are on gnhf branch "${currentBranch}".\n (o) Update prompt and continue current run\n (n) Start a new branch on top of this one\n (q) Quit\nChoose [o/n/q]: `, "The overwrite prompt closed before a choice was entered. Re-run gnhf from an interactive terminal and choose o, n, or q.", "Cannot show the overwrite prompt because stdin is not interactive. Re-run gnhf from an interactive terminal and choose o, n, or q.");
4309
4970
  if (answer === "o") {
4310
4971
  ensureCleanWorkingTree(cwd);
4311
- runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd, schemaOptions);
4972
+ existing = resumeRun(existingRunId, cwd, buildResumeSchemaOptions(options.stopWhen));
4973
+ const resumeStopWhen = existing.stopWhen;
4974
+ const resumeSchemaOptions = buildSchemaOptions(resumeStopWhen);
4975
+ runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd, resumeSchemaOptions);
4976
+ effectiveStopWhen = resumeStopWhen;
4977
+ schemaOptions = resumeSchemaOptions;
4312
4978
  startIteration = getLastIterationNumber(existing);
4313
- } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4314
- else process$1.exit(0);
4979
+ } else if (answer === "n") {
4980
+ effectiveStopWhen = cliStopWhen;
4981
+ schemaOptions = buildSchemaOptions(effectiveStopWhen);
4982
+ runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4983
+ } else process$1.exit(0);
4315
4984
  }
4316
4985
  } else {
4317
4986
  if (!prompt) {
@@ -4346,7 +5015,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4346
5015
  startIteration,
4347
5016
  maxIterations: options.maxIterations,
4348
5017
  maxTokens: options.maxTokens,
4349
- stopWhen: options.stopWhen,
5018
+ stopWhen: effectiveStopWhen,
4350
5019
  preventSleep: config.preventSleep,
4351
5020
  agentArgsOverride: config.agentArgsOverride?.[config.agent],
4352
5021
  worktree: options.worktree,
@@ -4358,21 +5027,39 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4358
5027
  const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent], config.agentArgsOverride?.[config.agent], schemaOptions), runInfo, prompt, effectiveCwd, startIteration, {
4359
5028
  maxIterations: options.maxIterations,
4360
5029
  maxTokens: options.maxTokens,
4361
- stopWhen: options.stopWhen
5030
+ stopWhen: effectiveStopWhen
4362
5031
  });
4363
5032
  let shutdownSignal = null;
4364
- enterAltScreen();
4365
- const renderer = new Renderer(orchestrator, prompt, config.agent);
4366
- renderer.start();
4367
- const requestShutdown = (signal) => {
4368
- if (shutdownSignal) return;
5033
+ let forceShutdownRequested = false;
5034
+ const requestForceShutdown = (signal) => {
5035
+ if (forceShutdownRequested) return;
5036
+ forceShutdownRequested = true;
4369
5037
  shutdownSignal = signal;
4370
5038
  appendDebugLog(`signal:${signal}`);
4371
5039
  renderer.stop();
5040
+ };
5041
+ const handleSigInt = () => {
5042
+ const disposition = orchestrator.handleInterrupt();
5043
+ if (disposition === "force-stop") {
5044
+ requestForceShutdown("SIGINT");
5045
+ return;
5046
+ }
5047
+ if (disposition === "exit") {
5048
+ shutdownSignal = "SIGINT";
5049
+ appendDebugLog("signal:SIGINT");
5050
+ renderer.stop("interrupted");
5051
+ return;
5052
+ }
5053
+ shutdownSignal = "SIGINT";
5054
+ appendDebugLog("signal:SIGINT");
5055
+ };
5056
+ const handleSigTerm = () => {
4372
5057
  orchestrator.stop();
5058
+ requestForceShutdown("SIGTERM");
4373
5059
  };
4374
- const handleSigInt = () => requestShutdown("SIGINT");
4375
- const handleSigTerm = () => requestShutdown("SIGTERM");
5060
+ enterAltScreen();
5061
+ const renderer = new Renderer(orchestrator, prompt, config.agent, handleSigInt);
5062
+ renderer.start();
4376
5063
  process$1.on("SIGINT", handleSigInt);
4377
5064
  process$1.on("SIGTERM", handleSigTerm);
4378
5065
  const orchestratorPromise = orchestrator.start().finally(() => {