@coresource/hz 0.20.2 → 0.20.4

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 (2) hide show
  1. package/dist/hz.mjs +1347 -668
  2. package/package.json +2 -2
package/dist/hz.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { readFileSync, realpathSync } from "fs";
5
5
  import { fileURLToPath } from "url";
6
- import { Command, CommanderError as CommanderError3 } from "commander";
6
+ import { Command, CommanderError as CommanderError4 } from "commander";
7
7
 
8
8
  // src/cli-support.ts
9
9
  import { password } from "@inquirer/prompts";
@@ -28,6 +28,50 @@ function writeLine(stream, message = "") {
28
28
  stream.write(`${message}
29
29
  `);
30
30
  }
31
+ function hasExitCode(error, exitCode) {
32
+ return error instanceof Error && "exitCode" in error && error.exitCode === exitCode;
33
+ }
34
+ function isAbortError(error) {
35
+ return error instanceof DOMException && error.name === "AbortError";
36
+ }
37
+ function isAbortPromptError(error) {
38
+ return error instanceof Error && error.name === "AbortPromptError";
39
+ }
40
+ function isInterruptedError(error) {
41
+ return hasExitCode(error, 130) || isAbortError(error) || isAbortPromptError(error);
42
+ }
43
+ function createSignalHandlerCoordinator(registerSignalHandler, exitProcess = (code) => process.exit(code)) {
44
+ if (!registerSignalHandler) {
45
+ return void 0;
46
+ }
47
+ let activeUnregister;
48
+ let cleanupInProgress = false;
49
+ return (signal, handler) => {
50
+ activeUnregister?.();
51
+ cleanupInProgress = false;
52
+ const wrappedHandler = () => {
53
+ if (cleanupInProgress) {
54
+ exitProcess(130);
55
+ return;
56
+ }
57
+ cleanupInProgress = true;
58
+ handler();
59
+ };
60
+ const underlyingUnregister = registerSignalHandler(signal, wrappedHandler);
61
+ const unregister = () => {
62
+ if (activeUnregister !== unregister) {
63
+ return;
64
+ }
65
+ activeUnregister = void 0;
66
+ cleanupInProgress = false;
67
+ if (typeof underlyingUnregister === "function") {
68
+ underlyingUnregister();
69
+ }
70
+ };
71
+ activeUnregister = unregister;
72
+ return unregister;
73
+ };
74
+ }
31
75
 
32
76
  // src/config.ts
33
77
  import { open } from "fs/promises";
@@ -300,6 +344,9 @@ function formatNetworkError(error) {
300
344
  }
301
345
  return new ApiError("Unexpected error while contacting the Horizon API.");
302
346
  }
347
+ function isAbortError2(error) {
348
+ return error instanceof DOMException && error.name === "AbortError";
349
+ }
303
350
  function createApiClient(options) {
304
351
  const fetchImpl = options.fetch ?? globalThis.fetch;
305
352
  const sleep = options.sleep ?? ((ms) => delay(ms).then(() => void 0));
@@ -323,7 +370,8 @@ function createApiClient(options) {
323
370
  );
324
371
  }
325
372
  for (let attempt = 0; attempt <= retryCount; attempt += 1) {
326
- const signal = AbortSignal.timeout(request.timeoutMs ?? timeoutMs);
373
+ const timeoutSignal = AbortSignal.timeout(request.timeoutMs ?? timeoutMs);
374
+ const signal = request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
327
375
  try {
328
376
  const response = await fetchImpl(url, {
329
377
  method,
@@ -346,6 +394,9 @@ function createApiClient(options) {
346
394
  }
347
395
  throw formatHttpError(response, payload);
348
396
  } catch (error) {
397
+ if (isAbortError2(error)) {
398
+ throw error;
399
+ }
349
400
  if (error instanceof ApiError) {
350
401
  throw error;
351
402
  }
@@ -552,11 +603,11 @@ function normalizeMissionSummary(value) {
552
603
  return {
553
604
  completedFeatures: asFiniteNumber(value.completedFeatures),
554
605
  createdAt: asNonEmptyString(value.createdAt),
555
- description: asNonEmptyString(value.description) ?? asNonEmptyString(value.taskDescription),
606
+ description: asNonEmptyString(value.description),
556
607
  missionId,
557
608
  passedAssertions: asFiniteNumber(value.passedAssertions),
558
609
  state: asNonEmptyString(value.state) ?? "unknown",
559
- taskDescription: asNonEmptyString(value.taskDescription) ?? asNonEmptyString(value.description),
610
+ taskDescription: asNonEmptyString(value.taskDescription),
560
611
  totalAssertions: asFiniteNumber(value.totalAssertions),
561
612
  totalFeatures: asFiniteNumber(value.totalFeatures)
562
613
  };
@@ -811,70 +862,87 @@ function registerMissionInfoCommand(mission, context, dependencies = {}) {
811
862
  }
812
863
 
813
864
  // src/commands/mission-list.ts
814
- async function resolveMissionDescription(apiClient, missionSummary) {
815
- if (missionSummary.taskDescription || missionSummary.description) {
816
- return missionSummary.taskDescription ?? missionSummary.description;
817
- }
818
- try {
819
- const missionState = normalizeMissionState(
820
- await apiClient.request({
821
- path: `/missions/${missionSummary.missionId}/mission/state`
822
- })
823
- );
824
- return missionState.taskDescription;
825
- } catch {
826
- return null;
827
- }
828
- }
865
+ import { CommanderError } from "commander";
866
+ import pc3 from "picocolors";
829
867
  function registerMissionListCommand(mission, context, dependencies = {}) {
830
868
  mission.command("list").description("List recent missions").action(async (_options, command) => {
831
- const apiClient = await createMissionApiClient(context, command, dependencies);
832
- const missionSummaries = normalizeMissionSummaries(
833
- await apiClient.request({
834
- path: "/missions"
835
- })
869
+ const registerSignalHandler = createSignalHandlerCoordinator(
870
+ dependencies.registerSignalHandler
836
871
  );
837
- if (missionSummaries.length === 0) {
838
- writeLine(context.stdout, "No missions found");
839
- return;
872
+ const abortController = new AbortController();
873
+ let interrupted = false;
874
+ const unregisterSignalHandler = registerSignalHandler?.("SIGINT", () => {
875
+ interrupted = true;
876
+ if (!abortController.signal.aborted) {
877
+ abortController.abort();
878
+ }
879
+ writeLine(
880
+ context.stdout,
881
+ pc3.yellow(
882
+ "Interrupted. Remote mission state is preserved. Re-run `hz mission list` when ready."
883
+ )
884
+ );
885
+ });
886
+ try {
887
+ const apiClient = await createMissionApiClient(context, command, dependencies);
888
+ const missionSummaries = normalizeMissionSummaries(
889
+ await apiClient.request({
890
+ path: "/missions",
891
+ signal: abortController.signal
892
+ })
893
+ );
894
+ if (interrupted) {
895
+ throw new CommanderError(130, "mission-list", "");
896
+ }
897
+ if (missionSummaries.length === 0) {
898
+ writeLine(context.stdout, "No missions found");
899
+ return;
900
+ }
901
+ renderTable(
902
+ context.stdout,
903
+ ["Mission ID", "State", "Created", "Features", "Assertions", "Description"],
904
+ missionSummaries.map((missionSummary) => [
905
+ plainCell(missionSummary.missionId),
906
+ coloredCell(
907
+ formatMissionState(missionSummary.state),
908
+ missionSummary.state
909
+ ),
910
+ plainCell(formatDateTime(missionSummary.createdAt)),
911
+ plainCell(
912
+ `${missionSummary.completedFeatures}/${missionSummary.totalFeatures}`
913
+ ),
914
+ plainCell(
915
+ `${missionSummary.passedAssertions}/${missionSummary.totalAssertions}`
916
+ ),
917
+ plainCell(
918
+ summarizeMissionDescription(
919
+ missionSummary.description ?? missionSummary.taskDescription
920
+ )
921
+ )
922
+ ])
923
+ );
924
+ } catch (error) {
925
+ if (interrupted && isAbortError(error)) {
926
+ throw new CommanderError(130, "mission-list", "");
927
+ }
928
+ throw error;
929
+ } finally {
930
+ if (typeof unregisterSignalHandler === "function") {
931
+ unregisterSignalHandler();
932
+ }
840
933
  }
841
- const missions = await Promise.all(
842
- missionSummaries.map(async (missionSummary) => ({
843
- ...missionSummary,
844
- description: await resolveMissionDescription(apiClient, missionSummary)
845
- }))
846
- );
847
- renderTable(
848
- context.stdout,
849
- ["Mission ID", "State", "Created", "Features", "Assertions", "Description"],
850
- missions.map((missionSummary) => [
851
- plainCell(missionSummary.missionId),
852
- coloredCell(
853
- formatMissionState(missionSummary.state),
854
- missionSummary.state
855
- ),
856
- plainCell(formatDateTime(missionSummary.createdAt)),
857
- plainCell(
858
- `${missionSummary.completedFeatures}/${missionSummary.totalFeatures}`
859
- ),
860
- plainCell(
861
- `${missionSummary.passedAssertions}/${missionSummary.totalAssertions}`
862
- ),
863
- plainCell(summarizeMissionDescription(missionSummary.description))
864
- ])
865
- );
866
934
  });
867
935
  }
868
936
 
869
937
  // src/commands/mission-lifecycle.ts
870
938
  import { confirm as defaultConfirmPrompt } from "@inquirer/prompts";
871
- import { CommanderError } from "commander";
872
- import pc6 from "picocolors";
939
+ import { CommanderError as CommanderError2 } from "commander";
940
+ import pc7 from "picocolors";
873
941
 
874
942
  // src/monitor.ts
875
943
  import { setTimeout as delay2 } from "timers/promises";
876
944
  import ora from "ora";
877
- import pc4 from "picocolors";
945
+ import pc5 from "picocolors";
878
946
  import WebSocket from "ws";
879
947
 
880
948
  // src/state.ts
@@ -1641,7 +1709,7 @@ import {
1641
1709
  input as defaultInputPrompt,
1642
1710
  select as defaultSelectPrompt
1643
1711
  } from "@inquirer/prompts";
1644
- import pc3 from "picocolors";
1712
+ import pc4 from "picocolors";
1645
1713
  var TRIAGE_PROGRESS_PAGE_SIZE = 200;
1646
1714
  var AUTO_DISMISS_RATIONALE = "Auto-dismissed by `hz mission run --yes`.";
1647
1715
  function isRecord3(value) {
@@ -1725,48 +1793,63 @@ function normalizeProgressEvent(value) {
1725
1793
  };
1726
1794
  }
1727
1795
  async function defaultPromptSelect(options) {
1728
- return defaultSelectPrompt({
1729
- choices: options.choices.map((choice) => ({
1730
- description: choice.description,
1731
- name: choice.name,
1732
- value: choice.value
1733
- })),
1734
- message: options.message
1735
- });
1796
+ return defaultSelectPrompt(
1797
+ {
1798
+ choices: options.choices.map((choice) => ({
1799
+ description: choice.description,
1800
+ name: choice.name,
1801
+ value: choice.value
1802
+ })),
1803
+ message: options.message
1804
+ },
1805
+ { signal: options.signal }
1806
+ );
1736
1807
  }
1737
1808
  async function defaultPromptInput(options) {
1738
- return defaultInputPrompt({
1739
- default: options.default,
1740
- message: options.message
1741
- });
1809
+ return defaultInputPrompt(
1810
+ {
1811
+ default: options.default,
1812
+ message: options.message
1813
+ },
1814
+ { signal: options.signal }
1815
+ );
1742
1816
  }
1743
1817
  function buildIssueId(featureId) {
1744
1818
  return `triage:${featureId}`;
1745
1819
  }
1746
- async function fetchTriageFeatures(apiClient, missionId) {
1820
+ function throwIfInterrupted(signal) {
1821
+ if (signal?.aborted) {
1822
+ throw new CliError("Triage interrupted.", 130);
1823
+ }
1824
+ }
1825
+ async function fetchTriageFeatures(apiClient, missionId, signal) {
1747
1826
  const response = await apiClient.request({
1748
- path: `/missions/${missionId}/features`
1827
+ path: `/missions/${missionId}/features`,
1828
+ signal
1749
1829
  });
1750
1830
  if (!Array.isArray(response)) {
1751
1831
  throw new CliError("Mission features response was not an array.");
1752
1832
  }
1753
1833
  return response.map((feature) => normalizeFeature(feature));
1754
1834
  }
1755
- async function fetchAssertions(apiClient, missionId) {
1835
+ async function fetchAssertions(apiClient, missionId, signal) {
1756
1836
  const response = await apiClient.request({
1757
- path: `/missions/${missionId}/assertions`
1837
+ path: `/missions/${missionId}/assertions`,
1838
+ signal
1758
1839
  });
1759
1840
  if (!Array.isArray(response)) {
1760
1841
  throw new CliError("Mission assertions response was not an array.");
1761
1842
  }
1762
1843
  return response.map((assertion) => normalizeAssertion(assertion));
1763
1844
  }
1764
- async function fetchLatestTriageEvents(apiClient, missionId) {
1845
+ async function fetchLatestTriageEvents(apiClient, missionId, signal) {
1765
1846
  const eventsByFeature = /* @__PURE__ */ new Map();
1766
1847
  let since = 0;
1767
1848
  for (; ; ) {
1849
+ throwIfInterrupted(signal);
1768
1850
  const response = await apiClient.request({
1769
- path: `/missions/${missionId}/progress?since=${since}&limit=${TRIAGE_PROGRESS_PAGE_SIZE}`
1851
+ path: `/missions/${missionId}/progress?since=${since}&limit=${TRIAGE_PROGRESS_PAGE_SIZE}`,
1852
+ signal
1770
1853
  });
1771
1854
  if (!Array.isArray(response) || response.length === 0) {
1772
1855
  break;
@@ -1856,7 +1939,7 @@ function buildTriageItem(feature, allAssertions, suggestedActionsByFeature) {
1856
1939
  }
1857
1940
  function writeItemBlock(stdout, item) {
1858
1941
  writeLine(stdout);
1859
- writeLine(stdout, pc3.bold(`Triage required for ${pc3.cyan(item.feature.id)}`));
1942
+ writeLine(stdout, pc4.bold(`Triage required for ${pc4.cyan(item.feature.id)}`));
1860
1943
  writeLine(stdout, `Failure: ${item.failureDescription}`);
1861
1944
  writeLine(
1862
1945
  stdout,
@@ -1930,11 +2013,12 @@ function toFixFeaturePayload(item, features) {
1930
2013
  verificationSteps: item.feature.verificationSteps.length > 0 ? [...item.feature.verificationSteps] : [`Verify that ${item.feature.id} no longer requires triage.`]
1931
2014
  };
1932
2015
  }
1933
- async function dismissItem(apiClient, missionId, item, rationale) {
2016
+ async function dismissItem(apiClient, missionId, item, rationale, signal) {
1934
2017
  const trimmedRationale = rationale.trim();
1935
2018
  if (trimmedRationale.length === 0) {
1936
2019
  throw new CliError("Dismissal rationale is required.");
1937
2020
  }
2021
+ throwIfInterrupted(signal);
1938
2022
  await apiClient.request({
1939
2023
  body: {
1940
2024
  items: [
@@ -1945,19 +2029,22 @@ async function dismissItem(apiClient, missionId, item, rationale) {
1945
2029
  ]
1946
2030
  },
1947
2031
  method: "POST",
1948
- path: `/missions/${missionId}/triage/dismiss`
2032
+ path: `/missions/${missionId}/triage/dismiss`,
2033
+ signal
1949
2034
  });
1950
2035
  }
1951
- async function createFixFeature(apiClient, missionId, item, features) {
2036
+ async function createFixFeature(apiClient, missionId, item, features, signal) {
1952
2037
  const feature = toFixFeaturePayload(item, features);
2038
+ throwIfInterrupted(signal);
1953
2039
  await apiClient.request({
1954
2040
  body: { feature },
1955
2041
  method: "POST",
1956
- path: `/missions/${missionId}/triage/create-fix`
2042
+ path: `/missions/${missionId}/triage/create-fix`,
2043
+ signal
1957
2044
  });
1958
2045
  return feature;
1959
2046
  }
1960
- async function reassignAssertions(apiClient, missionId, item, features, promptSelect) {
2047
+ async function reassignAssertions(apiClient, missionId, item, features, promptSelect, signal) {
1961
2048
  if (item.assertions.length === 0) {
1962
2049
  return 0;
1963
2050
  }
@@ -1971,9 +2058,11 @@ async function reassignAssertions(apiClient, missionId, item, features, promptSe
1971
2058
  }
1972
2059
  const moves = [];
1973
2060
  for (const assertion of item.assertions) {
2061
+ throwIfInterrupted(signal);
1974
2062
  const targetFeatureId = await promptSelect({
1975
2063
  choices: destinationChoices,
1976
- message: `Move ${assertion.id} to which feature?`
2064
+ message: `Move ${assertion.id} to which feature?`,
2065
+ signal
1977
2066
  });
1978
2067
  const targetFeature = features.find((feature) => feature.id === targetFeatureId);
1979
2068
  const targetMilestone = targetFeature?.milestone ?? assertion.milestone ?? item.feature.milestone;
@@ -1991,149 +2080,174 @@ async function reassignAssertions(apiClient, missionId, item, features, promptSe
1991
2080
  if (moves.length === 0) {
1992
2081
  return 0;
1993
2082
  }
2083
+ throwIfInterrupted(signal);
1994
2084
  await apiClient.request({
1995
2085
  body: { moves },
1996
2086
  method: "POST",
1997
- path: `/missions/${missionId}/triage/reassign-assertions`
2087
+ path: `/missions/${missionId}/triage/reassign-assertions`,
2088
+ signal
1998
2089
  });
1999
2090
  return moves.length;
2000
2091
  }
2001
2092
  async function runTriageInteraction(options) {
2002
2093
  const promptSelect = options.promptSelect ?? defaultPromptSelect;
2003
2094
  const promptInput = options.promptInput ?? defaultPromptInput;
2004
- const [features, assertions, suggestedActionsByFeature] = await Promise.all([
2005
- fetchTriageFeatures(options.apiClient, options.missionId),
2006
- fetchAssertions(options.apiClient, options.missionId),
2007
- fetchLatestTriageEvents(options.apiClient, options.missionId)
2008
- ]);
2009
- const triageItems = features.filter((feature) => feature.status === "needs_triage").map((feature) => buildTriageItem(feature, assertions, suggestedActionsByFeature));
2010
- if (triageItems.length === 0) {
2011
- writeLine(options.stdout, pc3.dim("No outstanding triage items were found."));
2012
- return {
2095
+ try {
2096
+ throwIfInterrupted(options.signal);
2097
+ const [features, assertions, suggestedActionsByFeature] = await Promise.all([
2098
+ fetchTriageFeatures(options.apiClient, options.missionId, options.signal),
2099
+ fetchAssertions(options.apiClient, options.missionId, options.signal),
2100
+ fetchLatestTriageEvents(options.apiClient, options.missionId, options.signal)
2101
+ ]);
2102
+ throwIfInterrupted(options.signal);
2103
+ const triageItems = features.filter((feature) => feature.status === "needs_triage").map((feature) => buildTriageItem(feature, assertions, suggestedActionsByFeature));
2104
+ if (triageItems.length === 0) {
2105
+ writeLine(options.stdout, pc4.dim("No outstanding triage items were found."));
2106
+ return {
2107
+ createdFixes: 0,
2108
+ dismissed: 0,
2109
+ handledFeatureIds: [],
2110
+ movedAssertions: 0,
2111
+ skipped: 0,
2112
+ totalItems: 0
2113
+ };
2114
+ }
2115
+ if (options.autoApprove) {
2116
+ writeLine(
2117
+ options.stdout,
2118
+ pc4.yellow(
2119
+ `Auto-dismissing triage items: ${triageItems.map((item) => item.feature.id).join(", ")}`
2120
+ )
2121
+ );
2122
+ throwIfInterrupted(options.signal);
2123
+ await options.apiClient.request({
2124
+ body: {
2125
+ items: triageItems.map((item) => ({
2126
+ issueId: item.issueId,
2127
+ rationale: AUTO_DISMISS_RATIONALE
2128
+ }))
2129
+ },
2130
+ method: "POST",
2131
+ path: `/missions/${options.missionId}/triage/dismiss`,
2132
+ signal: options.signal
2133
+ });
2134
+ return {
2135
+ createdFixes: 0,
2136
+ dismissed: triageItems.length,
2137
+ handledFeatureIds: triageItems.map((item) => item.feature.id),
2138
+ movedAssertions: 0,
2139
+ skipped: 0,
2140
+ totalItems: triageItems.length
2141
+ };
2142
+ }
2143
+ const result = {
2013
2144
  createdFixes: 0,
2014
2145
  dismissed: 0,
2015
2146
  handledFeatureIds: [],
2016
2147
  movedAssertions: 0,
2017
2148
  skipped: 0,
2018
- totalItems: 0
2019
- };
2020
- }
2021
- if (options.autoApprove) {
2022
- writeLine(
2023
- options.stdout,
2024
- pc3.yellow(
2025
- `Auto-dismissing triage items: ${triageItems.map((item) => item.feature.id).join(", ")}`
2026
- )
2027
- );
2028
- await options.apiClient.request({
2029
- body: {
2030
- items: triageItems.map((item) => ({
2031
- issueId: item.issueId,
2032
- rationale: AUTO_DISMISS_RATIONALE
2033
- }))
2034
- },
2035
- method: "POST",
2036
- path: `/missions/${options.missionId}/triage/dismiss`
2037
- });
2038
- return {
2039
- createdFixes: 0,
2040
- dismissed: triageItems.length,
2041
- handledFeatureIds: triageItems.map((item) => item.feature.id),
2042
- movedAssertions: 0,
2043
- skipped: 0,
2044
2149
  totalItems: triageItems.length
2045
2150
  };
2046
- }
2047
- const result = {
2048
- createdFixes: 0,
2049
- dismissed: 0,
2050
- handledFeatureIds: [],
2051
- movedAssertions: 0,
2052
- skipped: 0,
2053
- totalItems: triageItems.length
2054
- };
2055
- const mutableFeatures = [...features];
2056
- for (const item of triageItems) {
2057
- writeItemBlock(options.stdout, item);
2058
- const action = await promptSelect({
2059
- choices: buildActionChoices(item),
2060
- message: `Choose triage action for ${item.feature.id}`
2061
- });
2062
- switch (action) {
2063
- case "dismiss": {
2064
- const rationale = await promptInput({
2065
- message: `Why are you dismissing triage for ${item.feature.id}?`
2066
- });
2067
- await dismissItem(options.apiClient, options.missionId, item, rationale);
2068
- result.dismissed += 1;
2069
- writeLine(options.stdout, `Dismissed triage for ${pc3.cyan(item.feature.id)}.`);
2070
- break;
2071
- }
2072
- case "create-fix": {
2073
- const createdFeature = await createFixFeature(
2074
- options.apiClient,
2075
- options.missionId,
2076
- item,
2077
- mutableFeatures
2078
- );
2079
- mutableFeatures.push({
2080
- attemptCount: 0,
2081
- description: createdFeature.description,
2082
- expectedBehavior: [...createdFeature.expectedBehavior],
2083
- fulfills: [],
2084
- id: createdFeature.id,
2085
- isFixFeature: true,
2086
- milestone: createdFeature.milestone,
2087
- preconditions: [...createdFeature.preconditions],
2088
- skillName: createdFeature.skillName,
2089
- status: "pending",
2090
- verificationSteps: [...createdFeature.verificationSteps],
2091
- workerSessionIds: []
2092
- });
2093
- result.createdFixes += 1;
2094
- writeLine(
2095
- options.stdout,
2096
- `Created fix feature ${pc3.cyan(createdFeature.id)} for ${pc3.cyan(item.feature.id)}.`
2097
- );
2098
- break;
2099
- }
2100
- case "reassign-assertions": {
2101
- const moved = await reassignAssertions(
2102
- options.apiClient,
2103
- options.missionId,
2104
- item,
2105
- mutableFeatures,
2106
- promptSelect
2107
- );
2108
- if (moved === 0) {
2109
- result.skipped += 1;
2151
+ const mutableFeatures = [...features];
2152
+ for (const item of triageItems) {
2153
+ throwIfInterrupted(options.signal);
2154
+ writeItemBlock(options.stdout, item);
2155
+ const action = await promptSelect({
2156
+ choices: buildActionChoices(item),
2157
+ message: `Choose triage action for ${item.feature.id}`,
2158
+ signal: options.signal
2159
+ });
2160
+ throwIfInterrupted(options.signal);
2161
+ switch (action) {
2162
+ case "dismiss": {
2163
+ const rationale = await promptInput({
2164
+ message: `Why are you dismissing triage for ${item.feature.id}?`,
2165
+ signal: options.signal
2166
+ });
2167
+ await dismissItem(
2168
+ options.apiClient,
2169
+ options.missionId,
2170
+ item,
2171
+ rationale,
2172
+ options.signal
2173
+ );
2174
+ result.dismissed += 1;
2175
+ writeLine(options.stdout, `Dismissed triage for ${pc4.cyan(item.feature.id)}.`);
2176
+ break;
2177
+ }
2178
+ case "create-fix": {
2179
+ const createdFeature = await createFixFeature(
2180
+ options.apiClient,
2181
+ options.missionId,
2182
+ item,
2183
+ mutableFeatures,
2184
+ options.signal
2185
+ );
2186
+ mutableFeatures.push({
2187
+ attemptCount: 0,
2188
+ description: createdFeature.description,
2189
+ expectedBehavior: [...createdFeature.expectedBehavior],
2190
+ fulfills: [],
2191
+ id: createdFeature.id,
2192
+ isFixFeature: true,
2193
+ milestone: createdFeature.milestone,
2194
+ preconditions: [...createdFeature.preconditions],
2195
+ skillName: createdFeature.skillName,
2196
+ status: "pending",
2197
+ verificationSteps: [...createdFeature.verificationSteps],
2198
+ workerSessionIds: []
2199
+ });
2200
+ result.createdFixes += 1;
2110
2201
  writeLine(
2111
2202
  options.stdout,
2112
- pc3.yellow(
2113
- `No assertions were reassigned for ${pc3.cyan(item.feature.id)}.`
2114
- )
2203
+ `Created fix feature ${pc4.cyan(createdFeature.id)} for ${pc4.cyan(item.feature.id)}.`
2115
2204
  );
2116
2205
  break;
2117
2206
  }
2118
- result.movedAssertions += moved;
2119
- writeLine(
2120
- options.stdout,
2121
- `Reassigned ${moved} assertion${moved === 1 ? "" : "s"} for ${pc3.cyan(item.feature.id)}.`
2122
- );
2123
- break;
2207
+ case "reassign-assertions": {
2208
+ const moved = await reassignAssertions(
2209
+ options.apiClient,
2210
+ options.missionId,
2211
+ item,
2212
+ mutableFeatures,
2213
+ promptSelect,
2214
+ options.signal
2215
+ );
2216
+ if (moved === 0) {
2217
+ result.skipped += 1;
2218
+ writeLine(
2219
+ options.stdout,
2220
+ pc4.yellow(
2221
+ `No assertions were reassigned for ${pc4.cyan(item.feature.id)}.`
2222
+ )
2223
+ );
2224
+ break;
2225
+ }
2226
+ result.movedAssertions += moved;
2227
+ writeLine(
2228
+ options.stdout,
2229
+ `Reassigned ${moved} assertion${moved === 1 ? "" : "s"} for ${pc4.cyan(item.feature.id)}.`
2230
+ );
2231
+ break;
2232
+ }
2233
+ case "skip":
2234
+ default:
2235
+ result.skipped += 1;
2236
+ writeLine(
2237
+ options.stdout,
2238
+ pc4.dim(`Skipped triage for ${item.feature.id}; it will remain unresolved.`)
2239
+ );
2240
+ break;
2124
2241
  }
2125
- case "skip":
2126
- default:
2127
- result.skipped += 1;
2128
- writeLine(
2129
- options.stdout,
2130
- pc3.dim(`Skipped triage for ${item.feature.id}; it will remain unresolved.`)
2131
- );
2132
- break;
2242
+ result.handledFeatureIds.push(item.feature.id);
2243
+ }
2244
+ return result;
2245
+ } catch (error) {
2246
+ if (options.signal?.aborted || isInterruptedError(error)) {
2247
+ throw new CliError("Triage interrupted.", 130);
2133
2248
  }
2134
- result.handledFeatureIds.push(item.feature.id);
2249
+ throw error;
2135
2250
  }
2136
- return result;
2137
2251
  }
2138
2252
 
2139
2253
  // src/monitor.ts
@@ -2226,7 +2340,19 @@ function formatSummaryLine(summary) {
2226
2340
  return `${summary.completedFeatures}/${summary.totalFeatures} features \xB7 ${summary.passedAssertions}/${summary.totalAssertions} assertions`;
2227
2341
  }
2228
2342
  function formatSpinnerText(missionId, summary) {
2229
- return `${pc4.cyan(missionId)} ${pc4.bold(summary.state)} \xB7 ${formatSummaryLine(summary)}`;
2343
+ return `${pc5.cyan(missionId)} ${pc5.bold(summary.state)} \xB7 ${formatSummaryLine(summary)}`;
2344
+ }
2345
+ function shouldShowMilestoneUpdate(status, verbose) {
2346
+ if (verbose) {
2347
+ return true;
2348
+ }
2349
+ return status === "sealed" || status === "completed";
2350
+ }
2351
+ function shouldShowFeatureUpdate(status, verbose) {
2352
+ if (verbose) {
2353
+ return true;
2354
+ }
2355
+ return status === "completed" || status === "failed" || status === "needs_triage";
2230
2356
  }
2231
2357
  function normalizeSummary(missionId, value) {
2232
2358
  if (!isRecord4(value)) {
@@ -2289,13 +2415,13 @@ function buildBackfillDescription(event) {
2289
2415
  const milestone = asNonEmptyString4(data.milestone);
2290
2416
  const assertionId = asNonEmptyString4(data.assertionId);
2291
2417
  if (featureId) {
2292
- return `Backfilled ${type} for ${pc4.cyan(featureId)}.`;
2418
+ return `Backfilled ${type} for ${pc5.cyan(featureId)}.`;
2293
2419
  }
2294
2420
  if (assertionId) {
2295
- return `Backfilled ${type} for ${pc4.cyan(assertionId)}.`;
2421
+ return `Backfilled ${type} for ${pc5.cyan(assertionId)}.`;
2296
2422
  }
2297
2423
  if (milestone) {
2298
- return `Backfilled ${type} for milestone ${pc4.cyan(milestone)}.`;
2424
+ return `Backfilled ${type} for milestone ${pc5.cyan(milestone)}.`;
2299
2425
  }
2300
2426
  return `Backfilled ${type}.`;
2301
2427
  }
@@ -2334,13 +2460,36 @@ async function fetchSummary(apiClient, missionId) {
2334
2460
  const response = await apiClient.request({
2335
2461
  path: `/missions/${missionId}/progress/summary`
2336
2462
  });
2337
- return normalizeSummary(missionId, response);
2463
+ const summary = normalizeSummary(missionId, response);
2464
+ try {
2465
+ const authoritativeState = normalizeMissionState(
2466
+ await apiClient.request({
2467
+ path: `/missions/${missionId}/mission/state`
2468
+ })
2469
+ );
2470
+ return {
2471
+ ...summary,
2472
+ completedFeatures: authoritativeState.completedFeatures,
2473
+ currentFeatureId: authoritativeState.currentFeatureId,
2474
+ currentWorkerSessionId: authoritativeState.currentWorkerSessionId,
2475
+ lastEventAt: authoritativeState.updatedAt,
2476
+ state: authoritativeState.state,
2477
+ passedAssertions: authoritativeState.passedAssertions,
2478
+ sealedMilestones: authoritativeState.sealedMilestones,
2479
+ totalAssertions: authoritativeState.totalAssertions,
2480
+ totalFeatures: authoritativeState.totalFeatures,
2481
+ totalMilestones: authoritativeState.totalMilestones
2482
+ };
2483
+ } catch {
2484
+ return summary;
2485
+ }
2338
2486
  }
2339
2487
  async function monitorMission(options) {
2340
2488
  const createSpinner = options.createSpinner ?? defaultCreateSpinner;
2341
2489
  const createWebSocket = options.createWebSocket ?? defaultCreateWebSocket;
2342
2490
  const registerSignalHandler = options.registerSignalHandler ?? defaultRegisterSignalHandler;
2343
2491
  const sleep = options.sleep ?? ((ms) => delay2(ms).then(() => void 0));
2492
+ const verbose = options.verbose === true;
2344
2493
  let summary = await fetchSummary(options.apiClient, options.missionId);
2345
2494
  let lastSeen = summary.head;
2346
2495
  let pausedForBudget = false;
@@ -2348,7 +2497,9 @@ async function monitorMission(options) {
2348
2497
  let reconnectAttempt = 0;
2349
2498
  let connectedOnce = false;
2350
2499
  let interrupted = false;
2500
+ let interruptionReported = false;
2351
2501
  let triageInFlight = false;
2502
+ const interruptionController = new AbortController();
2352
2503
  let activeSocket = null;
2353
2504
  let settleActiveConnection = null;
2354
2505
  let startInFlight = false;
@@ -2356,7 +2507,7 @@ async function monitorMission(options) {
2356
2507
  spinner.start();
2357
2508
  writeLine(
2358
2509
  options.stdout,
2359
- `Monitoring mission ${pc4.cyan(options.missionId)} \xB7 ${formatSummaryLine(summary)}`
2510
+ `Monitoring mission ${pc5.cyan(options.missionId)} \xB7 ${formatSummaryLine(summary)}`
2360
2511
  );
2361
2512
  const updateSpinner = () => {
2362
2513
  spinner.text = formatSpinnerText(options.missionId, summary);
@@ -2371,7 +2522,7 @@ async function monitorMission(options) {
2371
2522
  await refreshSummary();
2372
2523
  const message = `Mission completed \xB7 ${formatSummaryLine(summary)}`;
2373
2524
  spinner.succeed(message);
2374
- writeLine(options.stdout, pc4.green(message));
2525
+ writeLine(options.stdout, pc5.green(message));
2375
2526
  return {
2376
2527
  exitCode: 0,
2377
2528
  reason: "completed",
@@ -2383,7 +2534,7 @@ async function monitorMission(options) {
2383
2534
  await refreshSummary();
2384
2535
  const message = `Budget exceeded \xB7 ${formatBudgetUsage(details)}`;
2385
2536
  spinner.fail(message);
2386
- writeLine(options.stdout, pc4.red(message));
2537
+ writeLine(options.stdout, pc5.red(message));
2387
2538
  return {
2388
2539
  exitCode: 1,
2389
2540
  reason: "budget_exceeded",
@@ -2402,21 +2553,27 @@ async function monitorMission(options) {
2402
2553
  missionId: options.missionId,
2403
2554
  promptInput: options.promptInput,
2404
2555
  promptSelect: options.promptSelect,
2556
+ signal: interruptionController.signal,
2405
2557
  stdout: options.stdout
2406
2558
  });
2407
2559
  if (triageResult.totalItems === 0) {
2408
2560
  writeLine(
2409
2561
  options.stdout,
2410
- pc4.dim("No triage items required action. Resuming the orchestration loop.")
2562
+ pc5.dim("No triage items required action. Resuming the orchestration loop.")
2411
2563
  );
2412
2564
  } else {
2413
2565
  writeLine(
2414
2566
  options.stdout,
2415
- pc4.green(
2567
+ pc5.green(
2416
2568
  `Triage complete \xB7 ${triageResult.dismissed} dismissed \xB7 ${triageResult.createdFixes} fix features \xB7 ${triageResult.movedAssertions} assertions moved \xB7 ${triageResult.skipped} skipped`
2417
2569
  )
2418
2570
  );
2419
2571
  }
2572
+ } catch (error) {
2573
+ if (interrupted && error instanceof CliError && error.exitCode === 130) {
2574
+ return interruptedResult();
2575
+ }
2576
+ throw error;
2420
2577
  } finally {
2421
2578
  triageInFlight = false;
2422
2579
  pausedForTriage = false;
@@ -2425,11 +2582,14 @@ async function monitorMission(options) {
2425
2582
  return maybeAutoStart();
2426
2583
  };
2427
2584
  const interruptedResult = () => {
2428
- spinner.fail("Monitoring stopped.");
2429
- writeLine(
2430
- options.stdout,
2431
- pc4.yellow("Interrupted. Local state is saved. Remote mission continues.")
2432
- );
2585
+ if (!interruptionReported) {
2586
+ interruptionReported = true;
2587
+ spinner.fail("Monitoring stopped.");
2588
+ writeLine(
2589
+ options.stdout,
2590
+ pc5.yellow("Interrupted. Local state is saved. Remote mission continues.")
2591
+ );
2592
+ }
2433
2593
  return {
2434
2594
  exitCode: 130,
2435
2595
  reason: "interrupted",
@@ -2445,16 +2605,20 @@ async function monitorMission(options) {
2445
2605
  const result = await startMission(options.apiClient, options.missionId);
2446
2606
  switch (result.type) {
2447
2607
  case "dispatched":
2448
- writeLine(
2449
- options.stdout,
2450
- `Dispatching ${pc4.cyan(result.featureId)} (${pc4.dim(result.workerSessionId)}).`
2451
- );
2608
+ if (verbose) {
2609
+ writeLine(
2610
+ options.stdout,
2611
+ `Dispatching ${pc5.cyan(result.featureId)} (${pc5.dim(result.workerSessionId)}).`
2612
+ );
2613
+ }
2452
2614
  return null;
2453
2615
  case "no_work":
2454
- writeLine(
2455
- options.stdout,
2456
- pc4.dim("No eligible work is available yet. Waiting for updates.")
2457
- );
2616
+ if (verbose) {
2617
+ writeLine(
2618
+ options.stdout,
2619
+ pc5.dim("No eligible work is available yet. Waiting for updates.")
2620
+ );
2621
+ }
2458
2622
  return null;
2459
2623
  case "completed":
2460
2624
  return completedResult();
@@ -2467,6 +2631,9 @@ async function monitorMission(options) {
2467
2631
  };
2468
2632
  const unregisterSignalHandler = registerSignalHandler("SIGINT", () => {
2469
2633
  interrupted = true;
2634
+ if (!interruptionController.signal.aborted) {
2635
+ interruptionController.abort();
2636
+ }
2470
2637
  safeCloseSocket(activeSocket, 1e3, "SIGINT");
2471
2638
  settleActiveConnection?.(interruptedResult());
2472
2639
  });
@@ -2492,12 +2659,14 @@ async function monitorMission(options) {
2492
2659
  }
2493
2660
  const delayMs2 = toReconnectDelay(reconnectAttempt);
2494
2661
  reconnectAttempt += 1;
2495
- writeLine(
2496
- options.stdout,
2497
- pc4.yellow(
2498
- `WebSocket connection failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting in ${delayMs2} ms...`
2499
- )
2500
- );
2662
+ if (verbose) {
2663
+ writeLine(
2664
+ options.stdout,
2665
+ pc5.yellow(
2666
+ `WebSocket connection failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting in ${delayMs2} ms...`
2667
+ )
2668
+ );
2669
+ }
2501
2670
  await sleep(delayMs2);
2502
2671
  continue;
2503
2672
  }
@@ -2529,7 +2698,9 @@ async function monitorMission(options) {
2529
2698
  switch (type) {
2530
2699
  case "state_changed": {
2531
2700
  const state = asNonEmptyString4(data.state) ?? "unknown";
2532
- writeLine(options.stdout, `Mission state \u2192 ${pc4.cyan(state)}`);
2701
+ if (verbose) {
2702
+ writeLine(options.stdout, `Mission state \u2192 ${pc5.cyan(state)}`);
2703
+ }
2533
2704
  await refreshSummary();
2534
2705
  if (state === "completed") {
2535
2706
  return completedResult();
@@ -2545,40 +2716,48 @@ async function monitorMission(options) {
2545
2716
  if (status === "needs_triage") {
2546
2717
  pausedForTriage = true;
2547
2718
  }
2548
- writeLine(
2549
- options.stdout,
2550
- `Feature ${pc4.cyan(featureId)} \u2192 ${pc4.bold(status)}`
2551
- );
2719
+ if (shouldShowFeatureUpdate(status, verbose)) {
2720
+ writeLine(
2721
+ options.stdout,
2722
+ `Feature ${pc5.cyan(featureId)} \u2192 ${pc5.bold(status)}`
2723
+ );
2724
+ }
2552
2725
  await refreshSummary();
2553
2726
  return null;
2554
2727
  }
2555
2728
  case "milestone_updated": {
2556
2729
  const milestone = asNonEmptyString4(data.milestone) ?? "milestone";
2557
2730
  const status = asNonEmptyString4(data.status) ?? "updated";
2558
- writeLine(
2559
- options.stdout,
2560
- `Milestone ${pc4.cyan(milestone)} \u2192 ${pc4.bold(status)}`
2561
- );
2731
+ if (shouldShowMilestoneUpdate(status, verbose)) {
2732
+ writeLine(
2733
+ options.stdout,
2734
+ `Milestone ${pc5.cyan(milestone)} \u2192 ${pc5.bold(status)}`
2735
+ );
2736
+ }
2562
2737
  await refreshSummary();
2563
2738
  return null;
2564
2739
  }
2565
2740
  case "assertion_updated": {
2566
2741
  const assertionId = asNonEmptyString4(data.assertionId) ?? "assertion";
2567
2742
  const status = asNonEmptyString4(data.status) ?? "updated";
2568
- writeLine(
2569
- options.stdout,
2570
- `Assertion ${pc4.cyan(assertionId)} \u2192 ${pc4.bold(status)}`
2571
- );
2743
+ if (verbose) {
2744
+ writeLine(
2745
+ options.stdout,
2746
+ `Assertion ${pc5.cyan(assertionId)} \u2192 ${pc5.bold(status)}`
2747
+ );
2748
+ }
2572
2749
  await refreshSummary();
2573
2750
  return null;
2574
2751
  }
2575
2752
  case "handoff_received": {
2576
2753
  const featureId = asNonEmptyString4(data.featureId) ?? "feature";
2577
2754
  const status = asNonEmptyString4(data.status) ?? "received";
2578
- writeLine(
2579
- options.stdout,
2580
- `Handoff received for ${pc4.cyan(featureId)} (${status}).`
2581
- );
2755
+ if (verbose) {
2756
+ writeLine(
2757
+ options.stdout,
2758
+ `Handoff received for ${pc5.cyan(featureId)} (${status}).`
2759
+ );
2760
+ }
2582
2761
  return null;
2583
2762
  }
2584
2763
  case "triage_needed": {
@@ -2586,8 +2765,8 @@ async function monitorMission(options) {
2586
2765
  const featureId = asNonEmptyString4(data.featureId) ?? "feature";
2587
2766
  writeLine(
2588
2767
  options.stdout,
2589
- pc4.yellow(
2590
- `Triage is required for ${pc4.cyan(featureId)}. Auto-start is paused.`
2768
+ pc5.yellow(
2769
+ `Triage is required for ${pc5.cyan(featureId)}. Auto-start is paused.`
2591
2770
  )
2592
2771
  );
2593
2772
  return completeTriage();
@@ -2596,7 +2775,7 @@ async function monitorMission(options) {
2596
2775
  const featureId = asNonEmptyString4(data.featureId) ?? "feature";
2597
2776
  writeLine(
2598
2777
  options.stdout,
2599
- pc4.yellow(`Worker timed out for ${pc4.cyan(featureId)}.`)
2778
+ pc5.yellow(`Worker timed out for ${pc5.cyan(featureId)}.`)
2600
2779
  );
2601
2780
  await refreshSummary();
2602
2781
  return null;
@@ -2605,7 +2784,7 @@ async function monitorMission(options) {
2605
2784
  const details = normalizeBudgetDetails(data);
2606
2785
  writeLine(
2607
2786
  options.stdout,
2608
- pc4.yellow(`Budget warning \xB7 ${formatBudgetUsage(details)}`)
2787
+ pc5.yellow(`Budget warning \xB7 ${formatBudgetUsage(details)}`)
2609
2788
  );
2610
2789
  await refreshSummary();
2611
2790
  return null;
@@ -2629,23 +2808,27 @@ async function monitorMission(options) {
2629
2808
  safeCloseSocket(socket, 1e3, result.reason);
2630
2809
  resolveOnce(result);
2631
2810
  }).catch((error) => {
2632
- writeLine(
2633
- options.stdout,
2634
- pc4.yellow(
2635
- `Monitoring update failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting...`
2636
- )
2637
- );
2811
+ if (verbose) {
2812
+ writeLine(
2813
+ options.stdout,
2814
+ pc5.yellow(
2815
+ `Monitoring update failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting...`
2816
+ )
2817
+ );
2818
+ }
2638
2819
  safeCloseSocket(socket, 1011, "processing_error");
2639
2820
  resolveOnce("reconnect");
2640
2821
  });
2641
2822
  });
2642
2823
  socket.on("error", (error) => {
2643
- writeLine(
2644
- options.stdout,
2645
- pc4.yellow(
2646
- `WebSocket error: ${error instanceof Error ? error.message : String(error)}`
2647
- )
2648
- );
2824
+ if (verbose) {
2825
+ writeLine(
2826
+ options.stdout,
2827
+ pc5.yellow(
2828
+ `WebSocket error: ${error instanceof Error ? error.message : String(error)}`
2829
+ )
2830
+ );
2831
+ }
2649
2832
  safeCloseSocket(socket, 1011, "socket_error");
2650
2833
  });
2651
2834
  socket.on("close", () => {
@@ -2670,7 +2853,9 @@ async function monitorMission(options) {
2670
2853
  continue;
2671
2854
  }
2672
2855
  lastSeen = Math.max(lastSeen, asFiniteNumber3(event.seq));
2673
- writeLine(options.stdout, pc4.dim(buildBackfillDescription(event)));
2856
+ if (verbose) {
2857
+ writeLine(options.stdout, pc5.dim(buildBackfillDescription(event)));
2858
+ }
2674
2859
  }
2675
2860
  }
2676
2861
  connectedOnce = true;
@@ -2692,13 +2877,15 @@ async function monitorMission(options) {
2692
2877
  }
2693
2878
  const delayMs = toReconnectDelay(reconnectAttempt);
2694
2879
  reconnectAttempt += 1;
2695
- writeLine(
2696
- options.stdout,
2697
- pc4.yellow(`Reconnecting to mission updates in ${delayMs} ms...`)
2698
- );
2699
- await sleep(delayMs);
2700
- }
2701
- } finally {
2880
+ if (verbose) {
2881
+ writeLine(
2882
+ options.stdout,
2883
+ pc5.yellow(`Reconnecting to mission updates in ${delayMs} ms...`)
2884
+ );
2885
+ }
2886
+ await sleep(delayMs);
2887
+ }
2888
+ } finally {
2702
2889
  const unregister = typeof unregisterSignalHandler === "function" ? unregisterSignalHandler : void 0;
2703
2890
  unregister?.();
2704
2891
  }
@@ -2707,7 +2894,7 @@ async function monitorMission(options) {
2707
2894
  // src/commands/mission-id.ts
2708
2895
  import { readdir as readdir2, readFile as readFile3, stat } from "fs/promises";
2709
2896
  import path3 from "path";
2710
- import pc5 from "picocolors";
2897
+ import pc6 from "picocolors";
2711
2898
  function asNonEmptyString5(value) {
2712
2899
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
2713
2900
  }
@@ -2767,7 +2954,7 @@ async function resolveMissionId(missionId, context, command, homeDir) {
2767
2954
  if (detectedMissionId) {
2768
2955
  writeLine(
2769
2956
  context.stdout,
2770
- `Using the most recent local mission: ${pc5.cyan(detectedMissionId)}`
2957
+ `Using the most recent local mission: ${pc6.cyan(detectedMissionId)}`
2771
2958
  );
2772
2959
  return detectedMissionId;
2773
2960
  }
@@ -2789,14 +2976,46 @@ function asFiniteNumber4(value) {
2789
2976
  return typeof value === "number" && Number.isFinite(value) ? value : null;
2790
2977
  }
2791
2978
  async function defaultPromptConfirm(options) {
2792
- return defaultConfirmPrompt({
2793
- default: options.default,
2794
- message: options.message
2979
+ return defaultConfirmPrompt(
2980
+ {
2981
+ default: options.default,
2982
+ message: options.message
2983
+ },
2984
+ { signal: options.signal }
2985
+ );
2986
+ }
2987
+ function createLifecycleInterruptContext(registerSignalHandler, stdout, message) {
2988
+ const coordinatedRegisterSignalHandler = createSignalHandlerCoordinator(
2989
+ registerSignalHandler
2990
+ );
2991
+ const interruptState = {
2992
+ abortController: new AbortController(),
2993
+ interrupted: false
2994
+ };
2995
+ const unregisterSignalHandler = coordinatedRegisterSignalHandler?.("SIGINT", () => {
2996
+ if (interruptState.interrupted) {
2997
+ return;
2998
+ }
2999
+ interruptState.interrupted = true;
3000
+ if (!interruptState.abortController.signal.aborted) {
3001
+ interruptState.abortController.abort();
3002
+ }
3003
+ writeLine(stdout, pc7.yellow(message));
2795
3004
  });
3005
+ return {
3006
+ interruptState,
3007
+ registerSignalHandler: coordinatedRegisterSignalHandler,
3008
+ unregisterSignalHandler
3009
+ };
3010
+ }
3011
+ function throwIfInterrupted2(interruptState, commandName) {
3012
+ if (interruptState.interrupted) {
3013
+ throw new CommanderError2(130, commandName, "");
3014
+ }
2796
3015
  }
2797
3016
  function writeSection(stdout, title) {
2798
3017
  writeLine(stdout);
2799
- writeLine(stdout, pc6.bold(title));
3018
+ writeLine(stdout, pc7.bold(title));
2800
3019
  }
2801
3020
  function summarizeAssertions(assertions) {
2802
3021
  return assertions.reduce(
@@ -2847,19 +3066,20 @@ function renderMissionSnapshot(stdout, snapshot) {
2847
3066
  );
2848
3067
  }
2849
3068
  }
2850
- async function fetchMissionStateRecord(apiClient, missionId) {
3069
+ async function fetchMissionStateRecord(apiClient, missionId, signal) {
2851
3070
  return normalizeMissionState(
2852
3071
  await apiClient.request({
2853
- path: `/missions/${missionId}/mission/state`
3072
+ path: `/missions/${missionId}/mission/state`,
3073
+ signal
2854
3074
  })
2855
3075
  );
2856
3076
  }
2857
- async function fetchMissionSnapshot(apiClient, missionId) {
3077
+ async function fetchMissionSnapshot(apiClient, missionId, signal) {
2858
3078
  const [state, features, milestones, assertions] = await Promise.all([
2859
- fetchMissionStateRecord(apiClient, missionId),
2860
- apiClient.request({ path: `/missions/${missionId}/features` }).then((value) => normalizeMissionFeatures(value)),
2861
- apiClient.request({ path: `/missions/${missionId}/milestones` }).then((value) => normalizeMissionMilestones(value)),
2862
- apiClient.request({ path: `/missions/${missionId}/assertions` }).then((value) => normalizeMissionAssertions(value))
3079
+ fetchMissionStateRecord(apiClient, missionId, signal),
3080
+ apiClient.request({ path: `/missions/${missionId}/features`, signal }).then((value) => normalizeMissionFeatures(value)),
3081
+ apiClient.request({ path: `/missions/${missionId}/milestones`, signal }).then((value) => normalizeMissionMilestones(value)),
3082
+ apiClient.request({ path: `/missions/${missionId}/assertions`, signal }).then((value) => normalizeMissionAssertions(value))
2863
3083
  ]);
2864
3084
  return {
2865
3085
  assertions,
@@ -2885,16 +3105,16 @@ async function createMissionClientContext(context, command, dependencies, homeDi
2885
3105
  homeDir
2886
3106
  };
2887
3107
  }
2888
- async function fetchMissionStateAfterConflict(apiClient, missionId, error) {
3108
+ async function fetchMissionStateAfterConflict(apiClient, missionId, error, signal) {
2889
3109
  try {
2890
- return await fetchMissionStateRecord(apiClient, missionId);
3110
+ return await fetchMissionStateRecord(apiClient, missionId, signal);
2891
3111
  } catch {
2892
3112
  throw error;
2893
3113
  }
2894
3114
  }
2895
- async function fetchMissionSnapshotAfterConflict(apiClient, missionId, error) {
3115
+ async function fetchMissionSnapshotAfterConflict(apiClient, missionId, error, signal) {
2896
3116
  try {
2897
- return await fetchMissionSnapshot(apiClient, missionId);
3117
+ return await fetchMissionSnapshot(apiClient, missionId, signal);
2898
3118
  } catch {
2899
3119
  throw error;
2900
3120
  }
@@ -2949,11 +3169,11 @@ function normalizeMissionDeleteResponse(value) {
2949
3169
  function formatMissionDeleteStatus(status) {
2950
3170
  switch (status) {
2951
3171
  case "success":
2952
- return pc6.green(status);
3172
+ return pc7.green(status);
2953
3173
  case "failed":
2954
- return pc6.red(status);
3174
+ return pc7.red(status);
2955
3175
  case "skipped":
2956
- return pc6.yellow(status);
3176
+ return pc7.yellow(status);
2957
3177
  default:
2958
3178
  return status;
2959
3179
  }
@@ -3022,7 +3242,7 @@ function renderMissionDeleteResponse(stdout, response) {
3022
3242
  if (failedSteps.length > 0) {
3023
3243
  writeLine(
3024
3244
  stdout,
3025
- pc6.yellow(
3245
+ pc7.yellow(
3026
3246
  `Warning: Mission cleanup completed with cleanup warnings (${failedSteps.length} failed step${failedSteps.length === 1 ? "" : "s"}).`
3027
3247
  )
3028
3248
  );
@@ -3047,39 +3267,58 @@ async function runLifecycleMutation(action, missionIdValue, command, context, de
3047
3267
  dependencies,
3048
3268
  homeDir
3049
3269
  );
3270
+ const {
3271
+ interruptState,
3272
+ unregisterSignalHandler
3273
+ } = createLifecycleInterruptContext(
3274
+ dependencies.registerSignalHandler,
3275
+ context.stdout,
3276
+ "Interrupted. Remote mission state is preserved. Re-run this command to check or retry the lifecycle change."
3277
+ );
3050
3278
  try {
3051
3279
  const response = await apiClient.request({
3052
3280
  body: {},
3053
3281
  method: "POST",
3054
- path: `/missions/${missionId}/mission/${action}`
3282
+ path: `/missions/${missionId}/mission/${action}`,
3283
+ signal: interruptState.abortController.signal
3055
3284
  });
3285
+ throwIfInterrupted2(interruptState, `mission-${action}`);
3056
3286
  const nextState = asNonEmptyString6(response.state) ?? (action === "pause" ? "paused" : "cancelled");
3057
3287
  if (action === "pause") {
3058
3288
  writeLine(
3059
3289
  context.stdout,
3060
- `Mission ${pc6.cyan(missionId)} paused. Current state: ${pc6.bold(nextState)}.`
3290
+ `Mission ${pc7.cyan(missionId)} paused. Current state: ${pc7.bold(nextState)}.`
3061
3291
  );
3062
3292
  return;
3063
3293
  }
3064
3294
  writeLine(
3065
3295
  context.stdout,
3066
- `Mission ${pc6.cyan(missionId)} cancelled. This action is irreversible. Current state: ${pc6.bold(nextState)}.`
3296
+ `Mission ${pc7.cyan(missionId)} cancelled. This action is irreversible. Current state: ${pc7.bold(nextState)}.`
3067
3297
  );
3068
3298
  } catch (error) {
3299
+ if (interruptState.interrupted && isInterruptedError(error)) {
3300
+ throw new CommanderError2(130, `mission-${action}`, "");
3301
+ }
3069
3302
  if (!(error instanceof ApiError) || error.status !== 409) {
3070
3303
  throw error;
3071
3304
  }
3072
3305
  const missionState = await fetchMissionStateAfterConflict(
3073
3306
  apiClient,
3074
3307
  missionId,
3075
- error
3308
+ error,
3309
+ interruptState.abortController.signal
3076
3310
  );
3311
+ throwIfInterrupted2(interruptState, `mission-${action}`);
3077
3312
  const verb = action === "pause" ? "paused" : "cancelled";
3078
3313
  writeLine(
3079
3314
  context.stdout,
3080
- `Mission ${pc6.cyan(missionId)} cannot be ${verb} because it is currently ${pc6.bold(missionState.state)}.`
3315
+ `Mission ${pc7.cyan(missionId)} cannot be ${verb} because it is currently ${pc7.bold(missionState.state)}.`
3081
3316
  );
3082
- throw new CommanderError(1, `mission-${action}`, "");
3317
+ throw new CommanderError2(1, `mission-${action}`, "");
3318
+ } finally {
3319
+ if (typeof unregisterSignalHandler === "function") {
3320
+ unregisterSignalHandler();
3321
+ }
3083
3322
  }
3084
3323
  }
3085
3324
  function buildDeleteConfirmationMessage(missionId, forceDelete) {
@@ -3094,33 +3333,56 @@ function buildDeleteConfirmationMessage(missionId, forceDelete) {
3094
3333
  async function runMissionDelete(missionIdValue, options, command, context, dependencies) {
3095
3334
  const homeDir = resolveMissionCommandHomeDir(context);
3096
3335
  const missionId = await resolveMissionId(missionIdValue, context, command, homeDir);
3097
- if (options.yes !== true) {
3098
- const confirmed = await (dependencies.promptConfirm ?? defaultPromptConfirm)({
3099
- default: false,
3100
- message: buildDeleteConfirmationMessage(missionId, options.force === true)
3101
- });
3102
- if (!confirmed) {
3103
- writeLine(context.stdout, pc6.yellow("Mission deletion cancelled."));
3104
- return;
3105
- }
3106
- }
3107
- const { apiClient } = await createMissionClientContext(
3108
- context,
3109
- command,
3110
- dependencies,
3111
- homeDir
3112
- );
3113
- const query = options.force === true ? "?force=true" : "";
3114
- const response = normalizeMissionDeleteResponse(
3115
- await apiClient.request({
3116
- method: "DELETE",
3117
- path: `/missions/${missionId}${query}`
3118
- })
3336
+ const {
3337
+ interruptState,
3338
+ unregisterSignalHandler
3339
+ } = createLifecycleInterruptContext(
3340
+ dependencies.registerSignalHandler,
3341
+ context.stdout,
3342
+ "Interrupted. Remote mission state is preserved. Mission deletion was not attempted."
3119
3343
  );
3120
- if (!response.deleted) {
3121
- throw new CliError(`Mission ${missionId} was not deleted.`, 1);
3344
+ try {
3345
+ if (options.yes !== true) {
3346
+ const confirmed = await (dependencies.promptConfirm ?? defaultPromptConfirm)({
3347
+ default: false,
3348
+ message: buildDeleteConfirmationMessage(missionId, options.force === true),
3349
+ signal: interruptState.abortController.signal
3350
+ });
3351
+ throwIfInterrupted2(interruptState, "mission-delete");
3352
+ if (!confirmed) {
3353
+ writeLine(context.stdout, pc7.yellow("Mission deletion cancelled."));
3354
+ return;
3355
+ }
3356
+ }
3357
+ const { apiClient } = await createMissionClientContext(
3358
+ context,
3359
+ command,
3360
+ dependencies,
3361
+ homeDir
3362
+ );
3363
+ const query = options.force === true ? "?force=true" : "";
3364
+ const response = normalizeMissionDeleteResponse(
3365
+ await apiClient.request({
3366
+ method: "DELETE",
3367
+ path: `/missions/${missionId}${query}`,
3368
+ signal: interruptState.abortController.signal
3369
+ })
3370
+ );
3371
+ throwIfInterrupted2(interruptState, "mission-delete");
3372
+ if (!response.deleted) {
3373
+ throw new CliError(`Mission ${missionId} was not deleted.`, 1);
3374
+ }
3375
+ renderMissionDeleteResponse(context.stdout, response);
3376
+ } catch (error) {
3377
+ if (interruptState.interrupted && isInterruptedError(error)) {
3378
+ throw new CommanderError2(130, "mission-delete", "");
3379
+ }
3380
+ throw error;
3381
+ } finally {
3382
+ if (typeof unregisterSignalHandler === "function") {
3383
+ unregisterSignalHandler();
3384
+ }
3122
3385
  }
3123
- renderMissionDeleteResponse(context.stdout, response);
3124
3386
  }
3125
3387
  function resolveResumeExitCode(state) {
3126
3388
  switch (state) {
@@ -3154,66 +3416,99 @@ async function runMissionResume(missionIdValue, command, context, dependencies)
3154
3416
  dependencies,
3155
3417
  homeDir
3156
3418
  );
3157
- let snapshot = await fetchMissionSnapshot(apiClient, missionId);
3158
- if (snapshot.state.state === "paused") {
3159
- try {
3160
- const response = await apiClient.request({
3161
- body: {},
3162
- method: "POST",
3163
- path: `/missions/${missionId}/mission/resume`
3164
- });
3165
- const nextState = asNonEmptyString6(response.state) ?? "orchestrator_turn";
3166
- writeLine(
3167
- context.stdout,
3168
- `Mission ${pc6.cyan(missionId)} resumed. Current state: ${pc6.bold(nextState)}.`
3169
- );
3170
- } catch (error) {
3171
- if (!(error instanceof ApiError) || error.status !== 409) {
3172
- throw error;
3419
+ const lifecycleSignals = createLifecycleInterruptContext(
3420
+ dependencies.registerSignalHandler,
3421
+ context.stdout,
3422
+ "Interrupted. Remote mission continues. Re-run this command to reconnect when ready."
3423
+ );
3424
+ try {
3425
+ let snapshot = await fetchMissionSnapshot(
3426
+ apiClient,
3427
+ missionId,
3428
+ lifecycleSignals.interruptState.abortController.signal
3429
+ );
3430
+ throwIfInterrupted2(lifecycleSignals.interruptState, "mission-resume");
3431
+ if (snapshot.state.state === "paused") {
3432
+ try {
3433
+ const response = await apiClient.request({
3434
+ body: {},
3435
+ method: "POST",
3436
+ path: `/missions/${missionId}/mission/resume`,
3437
+ signal: lifecycleSignals.interruptState.abortController.signal
3438
+ });
3439
+ throwIfInterrupted2(lifecycleSignals.interruptState, "mission-resume");
3440
+ const nextState = asNonEmptyString6(response.state) ?? "orchestrator_turn";
3441
+ writeLine(
3442
+ context.stdout,
3443
+ `Mission ${pc7.cyan(missionId)} resumed. Current state: ${pc7.bold(nextState)}.`
3444
+ );
3445
+ } catch (error) {
3446
+ if (lifecycleSignals.interruptState.interrupted && isInterruptedError(error)) {
3447
+ throw new CommanderError2(130, "mission-resume", "");
3448
+ }
3449
+ if (!(error instanceof ApiError) || error.status !== 409) {
3450
+ throw error;
3451
+ }
3452
+ snapshot = await fetchMissionSnapshotAfterConflict(
3453
+ apiClient,
3454
+ missionId,
3455
+ error,
3456
+ lifecycleSignals.interruptState.abortController.signal
3457
+ );
3458
+ throwIfInterrupted2(lifecycleSignals.interruptState, "mission-resume");
3459
+ writeLine(
3460
+ context.stdout,
3461
+ `Mission ${pc7.cyan(missionId)} is currently ${pc7.bold(snapshot.state.state)}. Reconnecting to live updates if available.`
3462
+ );
3173
3463
  }
3174
- snapshot = await fetchMissionSnapshotAfterConflict(
3464
+ snapshot = await fetchMissionSnapshot(
3175
3465
  apiClient,
3176
3466
  missionId,
3177
- error
3467
+ lifecycleSignals.interruptState.abortController.signal
3178
3468
  );
3469
+ throwIfInterrupted2(lifecycleSignals.interruptState, "mission-resume");
3470
+ } else if (snapshot.state.state !== "cancelled" && snapshot.state.state !== "completed") {
3179
3471
  writeLine(
3180
3472
  context.stdout,
3181
- `Mission ${pc6.cyan(missionId)} is currently ${pc6.bold(snapshot.state.state)}. Reconnecting to live updates if available.`
3473
+ `Reconnecting to mission ${pc7.cyan(missionId)} from state ${pc7.bold(snapshot.state.state)}.`
3182
3474
  );
3183
3475
  }
3184
- snapshot = await fetchMissionSnapshot(apiClient, missionId);
3185
- } else if (snapshot.state.state !== "cancelled" && snapshot.state.state !== "completed") {
3186
- writeLine(
3187
- context.stdout,
3188
- `Reconnecting to mission ${pc6.cyan(missionId)} from state ${pc6.bold(snapshot.state.state)}.`
3189
- );
3190
- }
3191
- renderMissionSnapshot(context.stdout, snapshot);
3192
- const resumeExitCode = resolveResumeExitCode(snapshot.state.state);
3193
- if (resumeExitCode === 0) {
3194
- return;
3195
- }
3196
- if (resumeExitCode === 1) {
3197
- throw new CliError(
3198
- `Mission ${missionId} is currently ${snapshot.state.state}.`,
3199
- 1
3200
- );
3201
- }
3202
- const monitorResult = await monitorMission({
3203
- apiClient,
3204
- apiKey: authConfig.apiKey,
3205
- createSpinner: dependencies.createSpinner,
3206
- createWebSocket: dependencies.createWebSocket,
3207
- endpoint: authConfig.endpoint,
3208
- missionId,
3209
- promptInput: dependencies.promptInput,
3210
- promptSelect: dependencies.promptSelect,
3211
- registerSignalHandler: dependencies.registerSignalHandler,
3212
- sleep: dependencies.sleep,
3213
- stdout: context.stdout
3214
- });
3215
- if (monitorResult.exitCode !== 0) {
3216
- throw new CommanderError(monitorResult.exitCode, "mission-monitor", "");
3476
+ renderMissionSnapshot(context.stdout, snapshot);
3477
+ const resumeExitCode = resolveResumeExitCode(snapshot.state.state);
3478
+ if (resumeExitCode === 0) {
3479
+ return;
3480
+ }
3481
+ if (resumeExitCode === 1) {
3482
+ throw new CliError(
3483
+ `Mission ${missionId} is currently ${snapshot.state.state}.`,
3484
+ 1
3485
+ );
3486
+ }
3487
+ const monitorResult = await monitorMission({
3488
+ apiClient,
3489
+ apiKey: authConfig.apiKey,
3490
+ createSpinner: dependencies.createSpinner,
3491
+ createWebSocket: dependencies.createWebSocket,
3492
+ endpoint: authConfig.endpoint,
3493
+ missionId,
3494
+ promptInput: dependencies.promptInput,
3495
+ promptSelect: dependencies.promptSelect,
3496
+ registerSignalHandler: lifecycleSignals.registerSignalHandler,
3497
+ sleep: dependencies.sleep,
3498
+ stdout: context.stdout
3499
+ });
3500
+ if (monitorResult.exitCode !== 0) {
3501
+ throw new CommanderError2(monitorResult.exitCode, "mission-monitor", "");
3502
+ }
3503
+ } catch (error) {
3504
+ if (lifecycleSignals.interruptState.interrupted && isInterruptedError(error)) {
3505
+ throw new CommanderError2(130, "mission-resume", "");
3506
+ }
3507
+ throw error;
3508
+ } finally {
3509
+ if (typeof lifecycleSignals.unregisterSignalHandler === "function") {
3510
+ lifecycleSignals.unregisterSignalHandler();
3511
+ }
3217
3512
  }
3218
3513
  }
3219
3514
  async function runMissionWatch(missionIdValue, command, context, dependencies) {
@@ -3225,37 +3520,58 @@ async function runMissionWatch(missionIdValue, command, context, dependencies) {
3225
3520
  dependencies,
3226
3521
  homeDir
3227
3522
  );
3228
- const snapshot = await fetchMissionSnapshot(apiClient, missionId);
3229
- writeLine(
3523
+ const lifecycleSignals = createLifecycleInterruptContext(
3524
+ dependencies.registerSignalHandler,
3230
3525
  context.stdout,
3231
- `Watching mission ${pc6.cyan(missionId)} from state ${pc6.bold(snapshot.state.state)}.`
3526
+ "Interrupted. Remote mission continues. Re-run this command to reconnect when ready."
3232
3527
  );
3233
- renderMissionSnapshot(context.stdout, snapshot);
3234
- const watchExitCode = resolveWatchExitCode(snapshot.state.state);
3235
- if (watchExitCode === 0) {
3236
- return;
3237
- }
3238
- if (watchExitCode === 1) {
3239
- throw new CliError(
3240
- `Mission ${missionId} is currently ${snapshot.state.state}.`,
3241
- 1
3528
+ try {
3529
+ const snapshot = await fetchMissionSnapshot(
3530
+ apiClient,
3531
+ missionId,
3532
+ lifecycleSignals.interruptState.abortController.signal
3242
3533
  );
3243
- }
3244
- const monitorResult = await monitorMission({
3245
- apiClient,
3246
- apiKey: authConfig.apiKey,
3247
- createSpinner: dependencies.createSpinner,
3248
- createWebSocket: dependencies.createWebSocket,
3249
- endpoint: authConfig.endpoint,
3250
- missionId,
3251
- promptInput: dependencies.promptInput,
3252
- promptSelect: dependencies.promptSelect,
3253
- registerSignalHandler: dependencies.registerSignalHandler,
3254
- sleep: dependencies.sleep,
3255
- stdout: context.stdout
3256
- });
3257
- if (monitorResult.exitCode !== 0) {
3258
- throw new CommanderError(monitorResult.exitCode, "mission-monitor", "");
3534
+ throwIfInterrupted2(lifecycleSignals.interruptState, "mission-watch");
3535
+ writeLine(
3536
+ context.stdout,
3537
+ `Watching mission ${pc7.cyan(missionId)} from state ${pc7.bold(snapshot.state.state)}.`
3538
+ );
3539
+ renderMissionSnapshot(context.stdout, snapshot);
3540
+ const watchExitCode = resolveWatchExitCode(snapshot.state.state);
3541
+ if (watchExitCode === 0) {
3542
+ return;
3543
+ }
3544
+ if (watchExitCode === 1) {
3545
+ throw new CliError(
3546
+ `Mission ${missionId} is currently ${snapshot.state.state}.`,
3547
+ 1
3548
+ );
3549
+ }
3550
+ const monitorResult = await monitorMission({
3551
+ apiClient,
3552
+ apiKey: authConfig.apiKey,
3553
+ createSpinner: dependencies.createSpinner,
3554
+ createWebSocket: dependencies.createWebSocket,
3555
+ endpoint: authConfig.endpoint,
3556
+ missionId,
3557
+ promptInput: dependencies.promptInput,
3558
+ promptSelect: dependencies.promptSelect,
3559
+ registerSignalHandler: lifecycleSignals.registerSignalHandler,
3560
+ sleep: dependencies.sleep,
3561
+ stdout: context.stdout
3562
+ });
3563
+ if (monitorResult.exitCode !== 0) {
3564
+ throw new CommanderError2(monitorResult.exitCode, "mission-monitor", "");
3565
+ }
3566
+ } catch (error) {
3567
+ if (lifecycleSignals.interruptState.interrupted && isInterruptedError(error)) {
3568
+ throw new CommanderError2(130, "mission-watch", "");
3569
+ }
3570
+ throw error;
3571
+ } finally {
3572
+ if (typeof lifecycleSignals.unregisterSignalHandler === "function") {
3573
+ lifecycleSignals.unregisterSignalHandler();
3574
+ }
3259
3575
  }
3260
3576
  }
3261
3577
  function registerMissionLifecycleCommands(mission, context, dependencies = {}) {
@@ -3295,7 +3611,7 @@ function registerMissionLifecycleCommands(mission, context, dependencies = {}) {
3295
3611
  }
3296
3612
 
3297
3613
  // src/commands/mission-resources.ts
3298
- import pc7 from "picocolors";
3614
+ import pc8 from "picocolors";
3299
3615
  function isRecord6(value) {
3300
3616
  return value !== null && !Array.isArray(value) && typeof value === "object";
3301
3617
  }
@@ -3344,7 +3660,7 @@ function formatBytes(value) {
3344
3660
  }
3345
3661
  function writeSection2(stdout, title) {
3346
3662
  writeLine(stdout);
3347
- writeLine(stdout, pc7.bold(title));
3663
+ writeLine(stdout, pc8.bold(title));
3348
3664
  }
3349
3665
  function renderMetricTable(stdout, rows) {
3350
3666
  renderTable(
@@ -3647,7 +3963,7 @@ function registerMissionResourcesCommand(mission, context, dependencies = {}) {
3647
3963
  // src/commands/mission-run.ts
3648
3964
  import { stat as stat3 } from "fs/promises";
3649
3965
  import path7 from "path";
3650
- import { CommanderError as CommanderError2 } from "commander";
3966
+ import { CommanderError as CommanderError3 } from "commander";
3651
3967
 
3652
3968
  // src/planning.ts
3653
3969
  import path4 from "path";
@@ -3657,7 +3973,7 @@ import {
3657
3973
  select as defaultSelectPrompt2
3658
3974
  } from "@inquirer/prompts";
3659
3975
  import ora2 from "ora";
3660
- import pc8 from "picocolors";
3976
+ import pc9 from "picocolors";
3661
3977
  var DEFAULT_ANALYSIS_TIMEOUT_MS = 5 * 60 * 1e3;
3662
3978
  var DEFAULT_POLL_INTERVAL_MS = 5e3;
3663
3979
  var FREE_TEXT_OPTION_ID = "free_text";
@@ -3670,11 +3986,36 @@ function asNonEmptyString8(value) {
3670
3986
  function asStringArray2(value) {
3671
3987
  return Array.isArray(value) ? value.map((item) => asNonEmptyString8(item)).filter((item) => item !== null) : [];
3672
3988
  }
3989
+ function asCodeSnippets(value) {
3990
+ if (!Array.isArray(value)) {
3991
+ return [];
3992
+ }
3993
+ const snippets = [];
3994
+ const dedupe = /* @__PURE__ */ new Set();
3995
+ for (const entry of value) {
3996
+ const snippet = asNonEmptyString8(
3997
+ typeof entry === "string" ? entry : isRecord7(entry) ? entry.snippet ?? entry.code ?? entry.code_evidence ?? entry.content : void 0
3998
+ );
3999
+ if (!snippet) {
4000
+ continue;
4001
+ }
4002
+ const pathValue = isRecord7(entry) ? asNonEmptyString8(entry.path ?? entry.file_path ?? entry.filePath) : null;
4003
+ const key = `${pathValue ?? ""}\0${snippet}`;
4004
+ if (dedupe.has(key)) {
4005
+ continue;
4006
+ }
4007
+ dedupe.add(key);
4008
+ snippets.push(pathValue ? { path: pathValue, snippet } : { snippet });
4009
+ }
4010
+ return snippets;
4011
+ }
3673
4012
  function extractQuestions(payload) {
3674
4013
  if (!payload || !Array.isArray(payload.questions)) {
3675
4014
  return [];
3676
4015
  }
3677
4016
  return payload.questions.filter((question) => isRecord7(question)).map((question) => ({
4017
+ codeSnippets: asCodeSnippets(question.codeSnippets ?? question.code_snippets ?? question.snippets),
4018
+ context: asNonEmptyString8(question.context) ?? void 0,
3678
4019
  detailPrompt: asNonEmptyString8(question.detailPrompt) ?? void 0,
3679
4020
  freeTextOptionId: asNonEmptyString8(question.freeTextOptionId) ?? void 0,
3680
4021
  id: asNonEmptyString8(question.id) ?? "question",
@@ -3743,6 +4084,54 @@ function extractErrorCode(error) {
3743
4084
  function extractRound(payload) {
3744
4085
  return typeof payload?.round === "number" && Number.isFinite(payload.round) ? payload.round : 1;
3745
4086
  }
4087
+ function isAbortError3(error) {
4088
+ return error instanceof DOMException && error.name === "AbortError";
4089
+ }
4090
+ function isAbortPromptError2(error) {
4091
+ return error instanceof Error && error.name === "AbortPromptError";
4092
+ }
4093
+ function isInterruptedError2(error) {
4094
+ return error instanceof CliError && error.exitCode === 130 || isAbortError3(error) || isAbortPromptError2(error);
4095
+ }
4096
+ function throwIfInterrupted3(interruption) {
4097
+ if (interruption.interrupted) {
4098
+ throw new CliError("Planning interrupted.", 130);
4099
+ }
4100
+ }
4101
+ function interruptPlanning(interruption) {
4102
+ if (interruption.interrupted) {
4103
+ return;
4104
+ }
4105
+ interruption.interrupted = true;
4106
+ interruption.activeSpinner?.stop();
4107
+ interruption.activeSpinner = void 0;
4108
+ interruption.releaseActiveWait?.();
4109
+ interruption.releaseActiveWait = void 0;
4110
+ if (!interruption.abortController.signal.aborted) {
4111
+ interruption.abortController.abort();
4112
+ }
4113
+ writeLine(
4114
+ interruption.stdout,
4115
+ pc9.yellow("Interrupted. Re-run this command to resume the planning session.")
4116
+ );
4117
+ }
4118
+ async function waitForInterruptibleDelay(sleep, ms, interruption) {
4119
+ throwIfInterrupted3(interruption);
4120
+ let resolveWait;
4121
+ await Promise.race([
4122
+ sleep(ms).then(() => {
4123
+ resolveWait?.();
4124
+ }),
4125
+ new Promise((resolve) => {
4126
+ resolveWait = () => resolve();
4127
+ interruption.releaseActiveWait = resolveWait;
4128
+ })
4129
+ ]);
4130
+ if (interruption.releaseActiveWait === resolveWait) {
4131
+ interruption.releaseActiveWait = void 0;
4132
+ }
4133
+ throwIfInterrupted3(interruption);
4134
+ }
3746
4135
  function defaultCreateSpinner2(text) {
3747
4136
  const spinner = ora2({ text });
3748
4137
  return {
@@ -3776,66 +4165,96 @@ function defaultCreateSpinner2(text) {
3776
4165
  };
3777
4166
  }
3778
4167
  async function defaultPromptSelect2(options) {
3779
- return defaultSelectPrompt2({
3780
- choices: options.choices.map((choice) => ({
3781
- description: choice.description,
3782
- name: choice.name,
3783
- value: choice.value
3784
- })),
3785
- message: options.message
3786
- });
4168
+ return defaultSelectPrompt2(
4169
+ {
4170
+ choices: options.choices.map((choice) => ({
4171
+ description: choice.description,
4172
+ name: choice.name,
4173
+ value: choice.value
4174
+ })),
4175
+ message: options.message
4176
+ },
4177
+ { signal: options.signal }
4178
+ );
3787
4179
  }
3788
4180
  async function defaultPromptInput2(options) {
3789
- return defaultInputPrompt2({
3790
- default: options.default,
3791
- message: options.message
3792
- });
4181
+ return defaultInputPrompt2(
4182
+ {
4183
+ default: options.default,
4184
+ message: options.message
4185
+ },
4186
+ { signal: options.signal }
4187
+ );
3793
4188
  }
3794
4189
  async function defaultPromptConfirm2(options) {
3795
- return defaultConfirmPrompt2({
3796
- default: options.default,
3797
- message: options.message
3798
- });
4190
+ return defaultConfirmPrompt2(
4191
+ {
4192
+ default: options.default,
4193
+ message: options.message
4194
+ },
4195
+ { signal: options.signal }
4196
+ );
3799
4197
  }
3800
- async function getPlanningSession(client, sessionId) {
4198
+ async function getPlanningSession(client, sessionId, signal) {
3801
4199
  return client.request({
3802
- path: `/plan/${sessionId}`
4200
+ path: `/plan/${sessionId}`,
4201
+ signal
3803
4202
  });
3804
4203
  }
3805
- async function postClarification(client, sessionId, body) {
4204
+ async function postClarification(client, sessionId, signal, body) {
3806
4205
  return client.request({
3807
4206
  body,
3808
4207
  method: "POST",
3809
- path: `/plan/${sessionId}/clarify`
4208
+ path: `/plan/${sessionId}/clarify`,
4209
+ signal
3810
4210
  });
3811
4211
  }
3812
- async function postMilestoneConfirmation(client, sessionId, body) {
4212
+ async function postMilestoneConfirmation(client, sessionId, signal, body) {
3813
4213
  return client.request({
3814
4214
  body,
3815
4215
  method: "POST",
3816
- path: `/plan/${sessionId}/confirm-milestones`
4216
+ path: `/plan/${sessionId}/confirm-milestones`,
4217
+ signal
3817
4218
  });
3818
4219
  }
3819
- async function postDraftApproval(client, sessionId) {
4220
+ async function postDraftApproval(client, sessionId, signal) {
3820
4221
  return client.request({
3821
4222
  method: "POST",
3822
- path: `/plan/${sessionId}/approve`
4223
+ path: `/plan/${sessionId}/approve`,
4224
+ signal
3823
4225
  });
3824
4226
  }
3825
4227
  function writeSection3(stdout, title) {
3826
4228
  writeLine(stdout);
3827
- writeLine(stdout, pc8.bold(title));
4229
+ writeLine(stdout, pc9.bold(title));
4230
+ }
4231
+ function formatInlineSnippet(snippet) {
4232
+ return snippet.replace(/\s+/g, " ").trim();
4233
+ }
4234
+ function isFreeTextQuestion(question) {
4235
+ return Boolean(question.freeTextOptionId) || !question.options || question.options.length === 0;
3828
4236
  }
3829
4237
  function renderQuestion(stdout, question, index) {
3830
4238
  writeLine(stdout, `${index + 1}. ${question.text}`);
3831
4239
  if (question.references && question.references.length > 0) {
3832
- writeLine(stdout, ` ${pc8.dim(`References: ${question.references.join(", ")}`)}`);
4240
+ writeLine(stdout, ` ${pc9.dim(`References: ${question.references.join(", ")}`)}`);
4241
+ }
4242
+ if (question.context) {
4243
+ writeLine(stdout, ` ${pc9.dim(`Context: ${question.context}`)}`);
4244
+ }
4245
+ if (question.codeSnippets && question.codeSnippets.length > 0) {
4246
+ writeLine(stdout, ` ${pc9.dim("Code:")}`);
4247
+ for (const codeSnippet of question.codeSnippets) {
4248
+ const renderedSnippet = formatInlineSnippet(codeSnippet.snippet);
4249
+ const label = codeSnippet.path ? `${codeSnippet.path}: ` : "";
4250
+ writeLine(stdout, ` ${label}${renderedSnippet}`);
4251
+ }
3833
4252
  }
3834
4253
  }
3835
4254
  function renderMilestones(stdout, milestones, reviewRound) {
3836
4255
  writeSection3(stdout, `Milestone review round ${reviewRound}`);
3837
4256
  milestones.forEach((milestone, index) => {
3838
- writeLine(stdout, `${index + 1}. ${pc8.cyan(milestone.name)}`);
4257
+ writeLine(stdout, `${index + 1}. ${pc9.cyan(milestone.name)}`);
3839
4258
  if (milestone.description) {
3840
4259
  writeLine(stdout, ` ${milestone.description}`);
3841
4260
  }
@@ -3923,24 +4342,28 @@ function buildAutoApprovedDetail(question, taskDescription, repoPaths) {
3923
4342
  }
3924
4343
  return `${task}. Use ${repoPhrase} as the source of truth.${references}`;
3925
4344
  }
3926
- async function promptForAnswers(options, questions, round) {
4345
+ async function promptForAnswers(options, interruption, questions, round) {
3927
4346
  const promptSelect = options.promptSelect ?? defaultPromptSelect2;
3928
4347
  const promptInput = options.promptInput ?? defaultPromptInput2;
3929
4348
  const answers = [];
3930
4349
  const transcript = [];
3931
4350
  for (const question of questions) {
3932
- if (question.options && question.options.length > 0) {
4351
+ throwIfInterrupted3(interruption);
4352
+ if (!isFreeTextQuestion(question)) {
4353
+ const selectableOptions = question.options ?? [];
3933
4354
  const autoSelectedOption = options.autoApprove ? pickAutoApprovedOption(question, options.taskDescription, options.repoPaths) : null;
3934
- const choices = question.options.map((option) => ({
4355
+ const choices = selectableOptions.map((option) => ({
3935
4356
  description: option.description,
3936
4357
  name: option.label,
3937
4358
  value: option.id
3938
4359
  }));
3939
4360
  const optionId2 = autoSelectedOption?.id ?? await promptSelect({
3940
4361
  choices,
3941
- message: question.text
4362
+ message: question.text,
4363
+ signal: interruption.abortController.signal
3942
4364
  });
3943
- const selectedOption = question.options.find((option) => option.id === optionId2);
4365
+ throwIfInterrupted3(interruption);
4366
+ const selectedOption = selectableOptions.find((option) => option.id === optionId2);
3944
4367
  answers.push({
3945
4368
  optionId: optionId2,
3946
4369
  questionId: question.id
@@ -3957,9 +4380,11 @@ async function promptForAnswers(options, questions, round) {
3957
4380
  }
3958
4381
  const detail = options.autoApprove ? buildAutoApprovedDetail(question, options.taskDescription, options.repoPaths) : await promptInput({
3959
4382
  default: question.inputDefault,
3960
- message: question.detailPrompt ?? question.text
4383
+ message: question.detailPrompt ?? question.text,
4384
+ signal: interruption.abortController.signal
3961
4385
  });
3962
- const optionId = question.freeTextOptionId ?? FREE_TEXT_OPTION_ID;
4386
+ throwIfInterrupted3(interruption);
4387
+ const optionId = FREE_TEXT_OPTION_ID;
3963
4388
  answers.push({
3964
4389
  detail,
3965
4390
  optionId,
@@ -3979,7 +4404,7 @@ async function promptForAnswers(options, questions, round) {
3979
4404
  transcript
3980
4405
  };
3981
4406
  }
3982
- async function waitForAnalysis(options, sessionId) {
4407
+ async function waitForAnalysis(options, interruption, sessionId) {
3983
4408
  const createSpinner = options.createSpinner ?? defaultCreateSpinner2;
3984
4409
  const sleep = options.sleep ?? (async (ms) => {
3985
4410
  await new Promise((resolve) => setTimeout(resolve, ms));
@@ -3989,49 +4414,81 @@ async function waitForAnalysis(options, sessionId) {
3989
4414
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3990
4415
  const deadline = now() + timeoutMs;
3991
4416
  const spinner = createSpinner("Analyzing repository for planning...");
4417
+ interruption.activeSpinner = spinner;
3992
4418
  spinner.start();
3993
4419
  try {
3994
- let session = await getPlanningSession(options.client, sessionId);
4420
+ throwIfInterrupted3(interruption);
4421
+ let session = await getPlanningSession(
4422
+ options.client,
4423
+ sessionId,
4424
+ interruption.abortController.signal
4425
+ );
3995
4426
  while (session.state === "created" || session.state === "analyzing") {
4427
+ throwIfInterrupted3(interruption);
3996
4428
  if (now() >= deadline) {
3997
4429
  spinner.fail("Planning analysis timed out.");
3998
4430
  throw new CliError("Planning analysis timed out after 5 minutes.");
3999
4431
  }
4000
- await sleep(pollIntervalMs);
4001
- session = await getPlanningSession(options.client, sessionId);
4432
+ await waitForInterruptibleDelay(sleep, pollIntervalMs, interruption);
4433
+ session = await getPlanningSession(
4434
+ options.client,
4435
+ sessionId,
4436
+ interruption.abortController.signal
4437
+ );
4002
4438
  }
4439
+ throwIfInterrupted3(interruption);
4003
4440
  spinner.succeed("Analysis complete.");
4004
4441
  return session;
4005
4442
  } catch (error) {
4443
+ if (isInterruptedError2(error)) {
4444
+ throw error;
4445
+ }
4006
4446
  if (error instanceof CliError) {
4007
4447
  throw error;
4008
4448
  }
4009
4449
  spinner.fail("Planning analysis failed.");
4010
4450
  throw error;
4451
+ } finally {
4452
+ if (interruption.activeSpinner === spinner) {
4453
+ interruption.activeSpinner = void 0;
4454
+ }
4011
4455
  }
4012
4456
  }
4013
- async function resolveClarification(options, sessionId, initialPayload) {
4457
+ async function resolveClarification(options, interruption, sessionId, initialPayload) {
4014
4458
  let payload = initialPayload;
4015
4459
  const localTranscript = [];
4016
4460
  while (payload.state === "clarifying") {
4461
+ throwIfInterrupted3(interruption);
4017
4462
  let questions = extractQuestions(payload);
4018
4463
  let round = extractRound(payload);
4019
4464
  if (questions.length === 0) {
4020
- payload = await postClarification(options.client, sessionId, {});
4465
+ payload = await postClarification(
4466
+ options.client,
4467
+ sessionId,
4468
+ interruption.abortController.signal,
4469
+ {}
4470
+ );
4021
4471
  questions = extractQuestions(payload);
4022
4472
  round = extractRound(payload);
4023
4473
  }
4474
+ throwIfInterrupted3(interruption);
4024
4475
  if (payload.state !== "clarifying") {
4025
4476
  break;
4026
4477
  }
4027
4478
  writeSection3(options.stdout, `Clarification round ${round}`);
4028
4479
  questions.forEach((question, index) => renderQuestion(options.stdout, question, index));
4029
- const prompted = await promptForAnswers(options, questions, round);
4480
+ const prompted = await promptForAnswers(options, interruption, questions, round);
4030
4481
  localTranscript.push(...prompted.transcript);
4031
4482
  try {
4032
- payload = await postClarification(options.client, sessionId, {
4033
- answers: prompted.answers
4034
- });
4483
+ throwIfInterrupted3(interruption);
4484
+ payload = await postClarification(
4485
+ options.client,
4486
+ sessionId,
4487
+ interruption.abortController.signal,
4488
+ {
4489
+ answers: prompted.answers
4490
+ }
4491
+ );
4035
4492
  } catch (error) {
4036
4493
  if (extractErrorCode(error) !== "resolvedness_gate_failed") {
4037
4494
  throw error;
@@ -4045,9 +4502,15 @@ async function resolveClarification(options, sessionId, initialPayload) {
4045
4502
  }
4046
4503
  writeLine(
4047
4504
  options.stdout,
4048
- pc8.yellow("Proceeding because --force is enabled.")
4505
+ pc9.yellow("Proceeding because --force is enabled.")
4506
+ );
4507
+ throwIfInterrupted3(interruption);
4508
+ payload = await postMilestoneConfirmation(
4509
+ options.client,
4510
+ sessionId,
4511
+ interruption.abortController.signal,
4512
+ {}
4049
4513
  );
4050
- payload = await postMilestoneConfirmation(options.client, sessionId, {});
4051
4514
  return {
4052
4515
  payload,
4053
4516
  persistedClarification: {
@@ -4077,35 +4540,55 @@ async function resolveClarification(options, sessionId, initialPayload) {
4077
4540
  }
4078
4541
  };
4079
4542
  }
4080
- async function resolveMilestones(options, sessionId, initialPayload) {
4543
+ async function resolveMilestones(options, interruption, sessionId, initialPayload) {
4081
4544
  let reviewRound = 1;
4082
4545
  let payload = initialPayload;
4083
4546
  if (extractMilestones(payload).length === 0) {
4084
- payload = await postMilestoneConfirmation(options.client, sessionId, {});
4547
+ payload = await postMilestoneConfirmation(
4548
+ options.client,
4549
+ sessionId,
4550
+ interruption.abortController.signal,
4551
+ {}
4552
+ );
4085
4553
  }
4086
4554
  while (true) {
4555
+ throwIfInterrupted3(interruption);
4087
4556
  const milestones = extractMilestones(payload);
4088
4557
  renderMilestones(options.stdout, milestones, reviewRound);
4089
4558
  const confirmed = options.autoApprove ? true : await (options.promptConfirm ?? defaultPromptConfirm2)({
4090
4559
  default: true,
4091
- message: "Do these milestones look correct?"
4560
+ message: "Do these milestones look correct?",
4561
+ signal: interruption.abortController.signal
4092
4562
  });
4563
+ throwIfInterrupted3(interruption);
4093
4564
  if (confirmed) {
4094
- return postMilestoneConfirmation(options.client, sessionId, {
4095
- confirmed: true
4096
- });
4565
+ return postMilestoneConfirmation(
4566
+ options.client,
4567
+ sessionId,
4568
+ interruption.abortController.signal,
4569
+ {
4570
+ confirmed: true
4571
+ }
4572
+ );
4097
4573
  }
4098
4574
  const feedback = await (options.promptInput ?? defaultPromptInput2)({
4099
- message: "What should change about these milestones?"
4100
- });
4101
- payload = await postMilestoneConfirmation(options.client, sessionId, {
4102
- confirmed: false,
4103
- feedback
4575
+ message: "What should change about these milestones?",
4576
+ signal: interruption.abortController.signal
4104
4577
  });
4578
+ throwIfInterrupted3(interruption);
4579
+ payload = await postMilestoneConfirmation(
4580
+ options.client,
4581
+ sessionId,
4582
+ interruption.abortController.signal,
4583
+ {
4584
+ confirmed: false,
4585
+ feedback
4586
+ }
4587
+ );
4105
4588
  reviewRound += 1;
4106
4589
  }
4107
4590
  }
4108
- async function resolveDraft(options, sessionId, payload) {
4591
+ async function resolveDraft(options, interruption, sessionId, payload) {
4109
4592
  const directDraft = extractDraft(payload);
4110
4593
  if (directDraft) {
4111
4594
  return {
@@ -4113,7 +4596,11 @@ async function resolveDraft(options, sessionId, payload) {
4113
4596
  payload
4114
4597
  };
4115
4598
  }
4116
- const approvedPayload = await postDraftApproval(options.client, sessionId);
4599
+ const approvedPayload = await postDraftApproval(
4600
+ options.client,
4601
+ sessionId,
4602
+ interruption.abortController.signal
4603
+ );
4117
4604
  const approvedDraft = extractDraft(approvedPayload);
4118
4605
  if (!approvedDraft) {
4119
4606
  throw new CliError("Planner did not return a mission draft.");
@@ -4127,7 +4614,7 @@ async function runPlanningFlow(options) {
4127
4614
  if (options.existingMissionDraft) {
4128
4615
  writeLine(
4129
4616
  options.stdout,
4130
- pc8.cyan("Skipping planning because mission-draft.json already exists.")
4617
+ pc9.cyan("Skipping planning because mission-draft.json already exists.")
4131
4618
  );
4132
4619
  return {
4133
4620
  cancelled: false,
@@ -4136,69 +4623,113 @@ async function runPlanningFlow(options) {
4136
4623
  skippedPlanning: true
4137
4624
  };
4138
4625
  }
4139
- const sessionId = asNonEmptyString8(options.existingSessionId) ?? asNonEmptyString8(
4140
- (await options.client.request({
4141
- body: {
4142
- repos: options.repoPaths,
4143
- task_description: options.taskDescription
4144
- },
4145
- method: "POST",
4146
- path: "/plan/create",
4147
- timeoutMs: 3e4
4148
- })).session_id
4149
- );
4150
- if (!sessionId) {
4151
- throw new CliError("Planner did not return a session id.");
4152
- }
4153
- if (options.existingSessionId) {
4154
- writeLine(options.stdout, `Resuming planning session ${sessionId}.`);
4155
- } else {
4156
- await options.persistSessionId(sessionId);
4157
- writeLine(options.stdout, `Created planning session ${sessionId}.`);
4158
- }
4159
- let payload = await waitForAnalysis(options, sessionId);
4160
- if (payload.state === "clarifying") {
4161
- const clarification = await resolveClarification(options, sessionId, payload);
4162
- payload = clarification.payload;
4163
- if (clarification.persistedClarification) {
4164
- await options.persistClarification(clarification.persistedClarification);
4626
+ const interruption = {
4627
+ abortController: new AbortController(),
4628
+ interrupted: false,
4629
+ stdout: options.stdout
4630
+ };
4631
+ let sessionId = asNonEmptyString8(options.existingSessionId) ?? "";
4632
+ const unregisterSignalHandler = options.registerSignalHandler?.("SIGINT", () => {
4633
+ interruptPlanning(interruption);
4634
+ });
4635
+ try {
4636
+ sessionId = sessionId || asNonEmptyString8(
4637
+ (await options.client.request({
4638
+ body: {
4639
+ repos: options.repoPaths,
4640
+ task_description: options.taskDescription
4641
+ },
4642
+ method: "POST",
4643
+ path: "/plan/create",
4644
+ signal: interruption.abortController.signal,
4645
+ timeoutMs: 3e4
4646
+ })).session_id
4647
+ ) || "";
4648
+ if (!sessionId) {
4649
+ throw new CliError("Planner did not return a session id.");
4165
4650
  }
4166
- }
4167
- if (payload.state === "confirming") {
4168
- payload = await resolveMilestones(options, sessionId, payload);
4169
- }
4170
- if (payload.state !== "complete") {
4171
- throw new CliError(
4172
- `Planner returned an unexpected state after milestone confirmation: ${payload.state ?? "unknown"}.`
4651
+ if (options.existingSessionId) {
4652
+ writeLine(options.stdout, `Resuming planning session ${sessionId}.`);
4653
+ } else {
4654
+ await options.persistSessionId(sessionId);
4655
+ throwIfInterrupted3(interruption);
4656
+ writeLine(options.stdout, `Created planning session ${sessionId}.`);
4657
+ }
4658
+ let payload = await waitForAnalysis(options, interruption, sessionId);
4659
+ throwIfInterrupted3(interruption);
4660
+ if (payload.state === "clarifying") {
4661
+ const clarification = await resolveClarification(
4662
+ options,
4663
+ interruption,
4664
+ sessionId,
4665
+ payload
4666
+ );
4667
+ payload = clarification.payload;
4668
+ throwIfInterrupted3(interruption);
4669
+ if (clarification.persistedClarification) {
4670
+ await options.persistClarification(clarification.persistedClarification);
4671
+ throwIfInterrupted3(interruption);
4672
+ }
4673
+ }
4674
+ throwIfInterrupted3(interruption);
4675
+ if (payload.state === "confirming") {
4676
+ payload = await resolveMilestones(options, interruption, sessionId, payload);
4677
+ }
4678
+ throwIfInterrupted3(interruption);
4679
+ if (payload.state !== "complete") {
4680
+ throw new CliError(
4681
+ `Planner returned an unexpected state after milestone confirmation: ${payload.state ?? "unknown"}.`
4682
+ );
4683
+ }
4684
+ const { draft, payload: summaryPayload } = await resolveDraft(
4685
+ options,
4686
+ interruption,
4687
+ sessionId,
4688
+ payload
4173
4689
  );
4174
- }
4175
- const { draft, payload: summaryPayload } = await resolveDraft(
4176
- options,
4177
- sessionId,
4178
- payload
4179
- );
4180
- renderDraftSummary(options.stdout, summaryPayload, draft);
4181
- const approved = options.autoApprove ? true : await (options.promptConfirm ?? defaultPromptConfirm2)({
4182
- default: true,
4183
- message: "Approve this draft and continue to upload?"
4184
- });
4185
- if (!approved) {
4186
- writeLine(options.stdout, pc8.yellow("Planning cancelled before upload."));
4690
+ throwIfInterrupted3(interruption);
4691
+ renderDraftSummary(options.stdout, summaryPayload, draft);
4692
+ const approved = options.autoApprove ? true : await (options.promptConfirm ?? defaultPromptConfirm2)({
4693
+ default: true,
4694
+ message: "Approve this draft and continue to upload?",
4695
+ signal: interruption.abortController.signal
4696
+ });
4697
+ throwIfInterrupted3(interruption);
4698
+ if (!approved) {
4699
+ writeLine(options.stdout, pc9.yellow("Planning cancelled before upload."));
4700
+ return {
4701
+ cancelled: true,
4702
+ draft,
4703
+ sessionId,
4704
+ skippedPlanning: false
4705
+ };
4706
+ }
4707
+ await options.persistMissionDraft(draft);
4708
+ throwIfInterrupted3(interruption);
4709
+ writeLine(options.stdout, pc9.green("Saved approved mission draft."));
4187
4710
  return {
4188
- cancelled: true,
4711
+ cancelled: false,
4189
4712
  draft,
4190
4713
  sessionId,
4191
4714
  skippedPlanning: false
4192
4715
  };
4716
+ } catch (error) {
4717
+ if (interruption.interrupted && isInterruptedError2(error)) {
4718
+ return {
4719
+ cancelled: false,
4720
+ draft: null,
4721
+ exitCode: 130,
4722
+ interrupted: true,
4723
+ sessionId,
4724
+ skippedPlanning: false
4725
+ };
4726
+ }
4727
+ throw error;
4728
+ } finally {
4729
+ if (typeof unregisterSignalHandler === "function") {
4730
+ unregisterSignalHandler();
4731
+ }
4193
4732
  }
4194
- await options.persistMissionDraft(draft);
4195
- writeLine(options.stdout, pc8.green("Saved approved mission draft."));
4196
- return {
4197
- cancelled: false,
4198
- draft,
4199
- sessionId,
4200
- skippedPlanning: false
4201
- };
4202
4733
  }
4203
4734
 
4204
4735
  // src/upload.ts
@@ -4207,7 +4738,7 @@ import { createReadStream as createReadStream2 } from "fs";
4207
4738
  import path6 from "path";
4208
4739
  import { Transform as Transform2 } from "stream";
4209
4740
  import ora3 from "ora";
4210
- import pc9 from "picocolors";
4741
+ import pc10 from "picocolors";
4211
4742
 
4212
4743
  // src/snapshot.ts
4213
4744
  import { execFile } from "child_process";
@@ -4645,10 +5176,16 @@ async function writeDeterministicArchive(analysis, outputDir) {
4645
5176
  const tempArchivePath = `${finalArchivePath}.${process.pid}.${Date.now()}.tmp`;
4646
5177
  const archiveHash = createHash2("sha256");
4647
5178
  let archiveBytes = 0;
4648
- const meter = new Transform({
5179
+ const archiveHashMeter = new Transform({
4649
5180
  transform(chunk, _encoding, callback) {
4650
5181
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4651
5182
  archiveHash.update(buffer);
5183
+ callback(null, buffer);
5184
+ }
5185
+ });
5186
+ const outputMeter = new Transform({
5187
+ transform(chunk, _encoding, callback) {
5188
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4652
5189
  archiveBytes += buffer.length;
4653
5190
  callback(null, buffer);
4654
5191
  }
@@ -4680,8 +5217,9 @@ async function writeDeterministicArchive(analysis, outputDir) {
4680
5217
  try {
4681
5218
  await pipeline(
4682
5219
  tarStream,
5220
+ archiveHashMeter,
4683
5221
  createZstdCompress(),
4684
- meter,
5222
+ outputMeter,
4685
5223
  createWriteStream(tempArchivePath, { mode: 384 })
4686
5224
  );
4687
5225
  await rename3(tempArchivePath, finalArchivePath);
@@ -4759,8 +5297,41 @@ async function defaultPromptConfirm3(options) {
4759
5297
  return confirm({
4760
5298
  default: options.default,
4761
5299
  message: options.message
5300
+ }, {
5301
+ signal: options.signal
4762
5302
  });
4763
5303
  }
5304
+ function isAbortError4(error) {
5305
+ return error instanceof DOMException && error.name === "AbortError";
5306
+ }
5307
+ function isAbortPromptError3(error) {
5308
+ return error instanceof Error && error.name === "AbortPromptError";
5309
+ }
5310
+ function isInterruptedError3(error) {
5311
+ return error instanceof CliError && error.exitCode === 130 || isAbortError4(error) || isAbortPromptError3(error);
5312
+ }
5313
+ function throwIfInterrupted4(interruption) {
5314
+ if (interruption.interrupted) {
5315
+ throw new CliError("Upload interrupted.", 130);
5316
+ }
5317
+ }
5318
+ function interruptUpload(interruption) {
5319
+ if (interruption.interrupted) {
5320
+ return;
5321
+ }
5322
+ interruption.interrupted = true;
5323
+ interruption.activeSpinner?.stop();
5324
+ interruption.activeSpinner = void 0;
5325
+ if (!interruption.abortController.signal.aborted) {
5326
+ interruption.abortController.abort();
5327
+ }
5328
+ writeLine(
5329
+ interruption.stdout,
5330
+ pc10.yellow(
5331
+ "Interrupted. Local upload state is saved and any remote launch is preserved. Re-run this command to resume the upload."
5332
+ )
5333
+ );
5334
+ }
4764
5335
  function formatBytes2(bytes) {
4765
5336
  if (!Number.isFinite(bytes) || bytes < 1024) {
4766
5337
  return `${bytes} B`;
@@ -4804,15 +5375,15 @@ function isTerminalUploadStatus2(status) {
4804
5375
  return status === "uploaded" || status === "blob_exists";
4805
5376
  }
4806
5377
  function renderSafetyGate(stdout, repos, existingBlobs) {
4807
- writeLine(stdout, pc9.bold("Upload safety check"));
5378
+ writeLine(stdout, pc10.bold("Upload safety check"));
4808
5379
  writeLine(stdout, "Repos queued for upload:");
4809
5380
  for (const repo of repos) {
4810
- const cleanliness = repo.manifest.dirty ? pc9.yellow("dirty") : pc9.green("clean");
4811
- const secretSummary = repo.secretFindings.length > 0 ? pc9.red(`${repo.secretFindings.length} finding(s)`) : pc9.green("no findings");
4812
- const dedupSummary = existingBlobs.has(repo.manifest.archiveSha256) ? pc9.cyan("blob already exists remotely") : "new upload";
5381
+ const cleanliness = repo.manifest.dirty ? pc10.yellow("dirty") : pc10.green("clean");
5382
+ const secretSummary = repo.secretFindings.length > 0 ? pc10.red(`${repo.secretFindings.length} finding(s)`) : pc10.green("no findings");
5383
+ const dedupSummary = existingBlobs.has(repo.manifest.archiveSha256) ? pc10.cyan("blob already exists remotely") : "new upload";
4813
5384
  writeLine(
4814
5385
  stdout,
4815
- `- ${pc9.cyan(repo.manifest.repoId)} \u2014 ${repo.manifest.localPath}`
5386
+ `- ${pc10.cyan(repo.manifest.repoId)} \u2014 ${repo.manifest.localPath}`
4816
5387
  );
4817
5388
  writeLine(
4818
5389
  stdout,
@@ -4842,7 +5413,7 @@ function assertArtifactsMatchPersistedState(repos, persistedManifests) {
4842
5413
  );
4843
5414
  }
4844
5415
  }
4845
- async function createRemoteLaunch(options, repos, taskDescription) {
5416
+ async function createRemoteLaunch(options, repos, taskDescription, signal) {
4846
5417
  const response = await options.apiClient.request({
4847
5418
  body: {
4848
5419
  ...taskDescription ? {
@@ -4858,7 +5429,8 @@ async function createRemoteLaunch(options, repos, taskDescription) {
4858
5429
  }))
4859
5430
  },
4860
5431
  method: "POST",
4861
- path: "/launches"
5432
+ path: "/launches",
5433
+ signal
4862
5434
  });
4863
5435
  const remoteLaunchId = normalizeLaunchId2(response.launchId);
4864
5436
  if (remoteLaunchId.length === 0) {
@@ -4869,12 +5441,13 @@ async function createRemoteLaunch(options, repos, taskDescription) {
4869
5441
  remoteLaunchId
4870
5442
  };
4871
5443
  }
4872
- async function uploadArchive(options, repo, uploadUrl) {
5444
+ async function uploadArchive(options, repo, uploadUrl, signal, interruption) {
4873
5445
  const fetchImpl = options.fetch ?? globalThis.fetch;
4874
5446
  const createSpinner = options.createSpinner ?? defaultCreateSpinner3;
4875
5447
  const spinner = createSpinner(
4876
5448
  `Uploading ${repo.manifest.repoId} 0 B / ${formatBytes2(repo.manifest.archiveBytes)} (0 B/s)`
4877
5449
  );
5450
+ interruption.activeSpinner = spinner;
4878
5451
  spinner.start(spinner.text);
4879
5452
  const startedAt = Date.now();
4880
5453
  let bytesTransferred = 0;
@@ -4886,27 +5459,49 @@ async function uploadArchive(options, repo, uploadUrl) {
4886
5459
  callback(null, chunk);
4887
5460
  }
4888
5461
  });
5462
+ const archiveStream = createReadStream2(repo.archivePath);
5463
+ const abortStream = () => {
5464
+ archiveStream.destroy();
5465
+ meter.destroy();
5466
+ };
5467
+ if (signal.aborted) {
5468
+ abortStream();
5469
+ } else {
5470
+ signal.addEventListener("abort", abortStream, { once: true });
5471
+ }
4889
5472
  let response;
4890
5473
  try {
4891
- const body = createReadStream2(repo.archivePath).pipe(meter);
5474
+ const body = archiveStream.pipe(meter);
4892
5475
  const requestInit = {
4893
5476
  body,
4894
5477
  duplex: "half",
4895
5478
  headers: {
4896
5479
  "content-length": String(repo.manifest.archiveBytes)
4897
5480
  },
4898
- method: "PUT"
5481
+ method: "PUT",
5482
+ signal
4899
5483
  };
4900
5484
  response = await fetchImpl(uploadUrl, requestInit);
4901
5485
  } catch (error) {
5486
+ if (isInterruptedError3(error)) {
5487
+ throw error;
5488
+ }
4902
5489
  spinner.fail(`Upload failed for ${repo.manifest.repoId}`);
5490
+ if (interruption.activeSpinner === spinner) {
5491
+ interruption.activeSpinner = void 0;
5492
+ }
4903
5493
  throw new CliError(
4904
5494
  `Upload failed for ${repo.manifest.repoId}: ${error instanceof Error ? error.message : String(error)}`
4905
5495
  );
5496
+ } finally {
5497
+ signal.removeEventListener("abort", abortStream);
4906
5498
  }
4907
5499
  if (!response.ok) {
4908
5500
  const detail = await response.text().catch(() => "");
4909
5501
  spinner.fail(`Upload failed for ${repo.manifest.repoId}`);
5502
+ if (interruption.activeSpinner === spinner) {
5503
+ interruption.activeSpinner = void 0;
5504
+ }
4910
5505
  throw new CliError(
4911
5506
  `Upload failed for ${repo.manifest.repoId}: HTTP ${response.status}${detail ? ` ${detail}` : ""}`
4912
5507
  );
@@ -4915,8 +5510,38 @@ async function uploadArchive(options, repo, uploadUrl) {
4915
5510
  spinner.succeed(
4916
5511
  `Uploaded ${repo.manifest.repoId} ${formatBytes2(bytesTransferred)} in ${formatDuration(elapsedMs)} (${formatRate(bytesTransferred, elapsedMs)})`
4917
5512
  );
5513
+ if (interruption.activeSpinner === spinner) {
5514
+ interruption.activeSpinner = void 0;
5515
+ }
5516
+ }
5517
+ async function persistCreatedLaunchState(options, remoteLaunchId, repos) {
5518
+ for (const repo of repos) {
5519
+ await recordRepoManifest(
5520
+ options.launchId,
5521
+ repo.manifest.repoId,
5522
+ repo.manifest,
5523
+ {
5524
+ cwd: options.cwd,
5525
+ homeDir: options.homeDir
5526
+ }
5527
+ );
5528
+ }
5529
+ for (const repo of repos) {
5530
+ await updateRepoUploadState(
5531
+ options.launchId,
5532
+ {
5533
+ remoteLaunchId,
5534
+ repoId: repo.manifest.repoId,
5535
+ status: "pending"
5536
+ },
5537
+ {
5538
+ cwd: options.cwd,
5539
+ homeDir: options.homeDir
5540
+ }
5541
+ );
5542
+ }
4918
5543
  }
4919
- async function processRepoUpload(options, repo, remoteLaunchId) {
5544
+ async function processRepoUpload(options, repo, remoteLaunchId, signal, interruption) {
4920
5545
  const snapshot = await loadLaunchSnapshot(options.launchId, {
4921
5546
  cwd: options.cwd,
4922
5547
  homeDir: options.homeDir
@@ -4939,7 +5564,8 @@ async function processRepoUpload(options, repo, remoteLaunchId) {
4939
5564
  const response = await options.apiClient.request({
4940
5565
  body: repo.manifest,
4941
5566
  method: "POST",
4942
- path: `/launches/${remoteLaunchId}/repos/${repo.manifest.repoId}`
5567
+ path: `/launches/${remoteLaunchId}/repos/${repo.manifest.repoId}`,
5568
+ signal
4943
5569
  });
4944
5570
  if (response.status === "blob_exists") {
4945
5571
  await updateRepoUploadState(
@@ -4956,7 +5582,7 @@ async function processRepoUpload(options, repo, remoteLaunchId) {
4956
5582
  );
4957
5583
  writeLine(
4958
5584
  options.stdout,
4959
- pc9.cyan(`Skipping upload for ${repo.manifest.repoId}; blob already exists remotely.`)
5585
+ pc10.cyan(`Skipping upload for ${repo.manifest.repoId}; blob already exists remotely.`)
4960
5586
  );
4961
5587
  return;
4962
5588
  }
@@ -4978,7 +5604,7 @@ async function processRepoUpload(options, repo, remoteLaunchId) {
4978
5604
  homeDir: options.homeDir
4979
5605
  }
4980
5606
  );
4981
- await uploadArchive(options, repo, uploadUrl);
5607
+ await uploadArchive(options, repo, uploadUrl, signal, interruption);
4982
5608
  await updateRepoUploadState(
4983
5609
  options.launchId,
4984
5610
  {
@@ -4994,152 +5620,193 @@ async function processRepoUpload(options, repo, remoteLaunchId) {
4994
5620
  }
4995
5621
  async function runUploadPipeline(options) {
4996
5622
  const promptConfirm = options.promptConfirm ?? defaultPromptConfirm3;
5623
+ const interruption = {
5624
+ abortController: new AbortController(),
5625
+ interrupted: false,
5626
+ stdout: options.stdout
5627
+ };
5628
+ let remoteLaunchId = null;
5629
+ const unregisterSignalHandler = options.registerSignalHandler?.("SIGINT", () => {
5630
+ interruptUpload(interruption);
5631
+ });
4997
5632
  const launchSnapshot = await loadLaunchSnapshot(options.launchId, {
4998
5633
  cwd: options.cwd,
4999
5634
  homeDir: options.homeDir
5000
5635
  });
5001
- if (!launchSnapshot.missionDraft) {
5002
- throw new CliError(
5003
- "Cannot start uploads before a mission draft has been approved."
5004
- );
5005
- }
5006
- let repos;
5007
5636
  try {
5008
- repos = await snapshotRepositories({
5009
- allowSecrets: options.allowSecrets === true,
5010
- cwd: options.cwd,
5011
- outputDir: path6.join(launchSnapshot.paths.launchDir, "archives"),
5012
- repoPaths: options.repoPaths
5013
- });
5014
- } catch (error) {
5015
- if (!(error instanceof SecretDetectionError) || options.allowSecrets === true) {
5016
- throw error;
5017
- }
5018
- repos = await snapshotRepositories({
5019
- allowSecrets: true,
5020
- cwd: options.cwd,
5021
- outputDir: path6.join(launchSnapshot.paths.launchDir, "archives"),
5022
- repoPaths: options.repoPaths
5023
- });
5024
- repos = repos.map((repo) => ({
5025
- ...repo,
5026
- warnings: repo.warnings.filter(
5027
- (warning) => !warning.includes("--allow-secrets is enabled")
5028
- )
5029
- }));
5030
- }
5031
- if (Object.keys(launchSnapshot.repoManifests).length > 0) {
5032
- assertArtifactsMatchPersistedState(
5033
- repos,
5034
- launchSnapshot.repoManifests
5035
- );
5036
- }
5037
- let existingBlobs = /* @__PURE__ */ new Set();
5038
- let remoteLaunchId = launchSnapshot.uploadState?.remoteLaunchId ?? null;
5039
- if (!remoteLaunchId) {
5040
- renderSafetyGate(options.stdout, repos, existingBlobs);
5041
- const hasSecretFindings = repos.some((repo) => repo.secretFindings.length > 0);
5042
- if (hasSecretFindings && options.allowSecrets !== true) {
5637
+ if (!launchSnapshot.missionDraft) {
5043
5638
  throw new CliError(
5044
- "Secret scan findings blocked the upload. Re-run with `--allow-secrets` to proceed."
5639
+ "Cannot start uploads before a mission draft has been approved."
5045
5640
  );
5046
5641
  }
5047
- if (options.autoApprove !== true && !await promptConfirm({
5048
- default: false,
5049
- message: "Proceed with launch upload?"
5050
- })) {
5051
- writeLine(
5052
- options.stdout,
5053
- pc9.yellow("Upload cancelled before creating a remote launch.")
5642
+ let repos;
5643
+ try {
5644
+ repos = await snapshotRepositories({
5645
+ allowSecrets: options.allowSecrets === true,
5646
+ cwd: options.cwd,
5647
+ outputDir: path6.join(launchSnapshot.paths.launchDir, "archives"),
5648
+ repoPaths: options.repoPaths
5649
+ });
5650
+ } catch (error) {
5651
+ if (!(error instanceof SecretDetectionError) || options.allowSecrets === true) {
5652
+ throw error;
5653
+ }
5654
+ repos = await snapshotRepositories({
5655
+ allowSecrets: true,
5656
+ cwd: options.cwd,
5657
+ outputDir: path6.join(launchSnapshot.paths.launchDir, "archives"),
5658
+ repoPaths: options.repoPaths
5659
+ });
5660
+ repos = repos.map((repo) => ({
5661
+ ...repo,
5662
+ warnings: repo.warnings.filter(
5663
+ (warning) => !warning.includes("--allow-secrets is enabled")
5664
+ )
5665
+ }));
5666
+ }
5667
+ if (Object.keys(launchSnapshot.repoManifests).length > 0) {
5668
+ assertArtifactsMatchPersistedState(
5669
+ repos,
5670
+ launchSnapshot.repoManifests
5054
5671
  );
5055
- return {
5056
- finalized: false,
5057
- remoteLaunchId: null
5058
- };
5059
5672
  }
5060
- const createdLaunch = await createRemoteLaunch(
5061
- options,
5062
- repos,
5063
- launchSnapshot.request?.task ?? null
5064
- );
5065
- existingBlobs = createdLaunch.existingBlobs;
5066
- remoteLaunchId = createdLaunch.remoteLaunchId;
5067
- if (existingBlobs.size > 0) {
5068
- writeLine(
5069
- options.stdout,
5070
- pc9.cyan(
5071
- `Remote dedup will reuse ${existingBlobs.size} existing blob${existingBlobs.size === 1 ? "" : "s"}.`
5072
- )
5673
+ throwIfInterrupted4(interruption);
5674
+ let existingBlobs = /* @__PURE__ */ new Set();
5675
+ remoteLaunchId = launchSnapshot.uploadState?.remoteLaunchId ?? null;
5676
+ if (!remoteLaunchId) {
5677
+ renderSafetyGate(options.stdout, repos, existingBlobs);
5678
+ const hasSecretFindings = repos.some((repo) => repo.secretFindings.length > 0);
5679
+ if (hasSecretFindings && options.allowSecrets !== true) {
5680
+ throw new CliError(
5681
+ "Secret scan findings blocked the upload. Re-run with `--allow-secrets` to proceed."
5682
+ );
5683
+ }
5684
+ throwIfInterrupted4(interruption);
5685
+ if (options.autoApprove !== true && !await promptConfirm({
5686
+ default: false,
5687
+ message: "Proceed with launch upload?",
5688
+ signal: interruption.abortController.signal
5689
+ })) {
5690
+ writeLine(
5691
+ options.stdout,
5692
+ pc10.yellow("Upload cancelled before creating a remote launch.")
5693
+ );
5694
+ return {
5695
+ finalized: false,
5696
+ remoteLaunchId: null
5697
+ };
5698
+ }
5699
+ throwIfInterrupted4(interruption);
5700
+ const createdLaunch = await createRemoteLaunch(
5701
+ options,
5702
+ repos,
5703
+ launchSnapshot.request?.task ?? null,
5704
+ interruption.abortController.signal
5073
5705
  );
5706
+ existingBlobs = createdLaunch.existingBlobs;
5707
+ remoteLaunchId = createdLaunch.remoteLaunchId;
5708
+ await persistCreatedLaunchState(options, remoteLaunchId, repos);
5709
+ throwIfInterrupted4(interruption);
5710
+ if (existingBlobs.size > 0) {
5711
+ writeLine(
5712
+ options.stdout,
5713
+ pc10.cyan(
5714
+ `Remote dedup will reuse ${existingBlobs.size} existing blob${existingBlobs.size === 1 ? "" : "s"}.`
5715
+ )
5716
+ );
5717
+ }
5074
5718
  }
5719
+ const currentSnapshot = await loadLaunchSnapshot(options.launchId, {
5720
+ cwd: options.cwd,
5721
+ homeDir: options.homeDir
5722
+ });
5723
+ const uploadStates = new Map(
5724
+ Object.entries(currentSnapshot.uploadState?.repos ?? {}).map(
5725
+ ([repoId, state]) => [repoId, state?.status]
5726
+ )
5727
+ );
5728
+ const failures = [];
5075
5729
  for (const repo of repos) {
5076
- await recordRepoManifest(
5077
- options.launchId,
5078
- repo.manifest.repoId,
5079
- repo.manifest,
5080
- {
5730
+ throwIfInterrupted4(interruption);
5731
+ if (isTerminalUploadStatus2(uploadStates.get(repo.manifest.repoId))) {
5732
+ writeLine(
5733
+ options.stdout,
5734
+ pc10.cyan(`Skipping ${repo.manifest.repoId}; already completed in upload-state.json.`)
5735
+ );
5736
+ continue;
5737
+ }
5738
+ try {
5739
+ await processRepoUpload(
5740
+ options,
5741
+ repo,
5742
+ remoteLaunchId,
5743
+ interruption.abortController.signal,
5744
+ interruption
5745
+ );
5746
+ const refreshedSnapshot = await loadLaunchSnapshot(options.launchId, {
5081
5747
  cwd: options.cwd,
5082
5748
  homeDir: options.homeDir
5749
+ });
5750
+ uploadStates.set(
5751
+ repo.manifest.repoId,
5752
+ refreshedSnapshot.uploadState?.repos[repo.manifest.repoId]?.status
5753
+ );
5754
+ } catch (error) {
5755
+ if (interruption.interrupted || isInterruptedError3(error)) {
5756
+ throw error;
5083
5757
  }
5084
- );
5758
+ failures.push(repo.manifest.repoId);
5759
+ writeLine(
5760
+ options.stdout,
5761
+ pc10.red(
5762
+ error instanceof Error ? error.message : `Upload failed for ${repo.manifest.repoId}.`
5763
+ )
5764
+ );
5765
+ }
5085
5766
  }
5086
- }
5087
- const uploadStates = new Map(
5088
- Object.entries(launchSnapshot.uploadState?.repos ?? {}).map(
5089
- ([repoId, state]) => [repoId, state?.status]
5090
- )
5091
- );
5092
- const failures = [];
5093
- for (const repo of repos) {
5094
- if (isTerminalUploadStatus2(uploadStates.get(repo.manifest.repoId))) {
5095
- writeLine(
5096
- options.stdout,
5097
- pc9.cyan(`Skipping ${repo.manifest.repoId}; already completed in upload-state.json.`)
5767
+ if (failures.length > 0) {
5768
+ throw new CliError(
5769
+ `Upload failed for ${failures.join(", ")}. Re-run the command to resume the remaining repos.`
5098
5770
  );
5099
- continue;
5100
5771
  }
5101
- try {
5102
- await processRepoUpload(options, repo, remoteLaunchId);
5103
- const refreshedSnapshot = await loadLaunchSnapshot(options.launchId, {
5104
- cwd: options.cwd,
5105
- homeDir: options.homeDir
5106
- });
5107
- uploadStates.set(
5108
- repo.manifest.repoId,
5109
- refreshedSnapshot.uploadState?.repos[repo.manifest.repoId]?.status
5110
- );
5111
- } catch (error) {
5112
- failures.push(repo.manifest.repoId);
5113
- writeLine(
5114
- options.stdout,
5115
- pc9.red(
5116
- error instanceof Error ? error.message : `Upload failed for ${repo.manifest.repoId}.`
5117
- )
5772
+ throwIfInterrupted4(interruption);
5773
+ const finalizeResponse = await options.apiClient.request({
5774
+ method: "POST",
5775
+ path: `/launches/${remoteLaunchId}/finalize`,
5776
+ signal: interruption.abortController.signal
5777
+ });
5778
+ if (finalizeResponse.status !== "complete") {
5779
+ throw new CliError(
5780
+ `Remote launch ${remoteLaunchId} did not finalize successfully.`
5118
5781
  );
5119
5782
  }
5120
- }
5121
- if (failures.length > 0) {
5122
- throw new CliError(
5123
- `Upload failed for ${failures.join(", ")}. Re-run the command to resume the remaining repos.`
5124
- );
5125
- }
5126
- const finalizeResponse = await options.apiClient.request({
5127
- method: "POST",
5128
- path: `/launches/${remoteLaunchId}/finalize`
5129
- });
5130
- if (finalizeResponse.status !== "complete") {
5131
- throw new CliError(
5132
- `Remote launch ${remoteLaunchId} did not finalize successfully.`
5783
+ writeLine(
5784
+ options.stdout,
5785
+ pc10.green(`Upload complete. Remote launch ${remoteLaunchId} is finalized.`)
5133
5786
  );
5787
+ return {
5788
+ finalized: true,
5789
+ remoteLaunchId
5790
+ };
5791
+ } catch (error) {
5792
+ if (interruption.interrupted && isInterruptedError3(error)) {
5793
+ return {
5794
+ exitCode: 130,
5795
+ finalized: false,
5796
+ interrupted: true,
5797
+ remoteLaunchId
5798
+ };
5799
+ }
5800
+ throw error;
5801
+ } finally {
5802
+ if (typeof unregisterSignalHandler === "function") {
5803
+ unregisterSignalHandler();
5804
+ }
5805
+ if (interruption.activeSpinner) {
5806
+ interruption.activeSpinner.stop();
5807
+ interruption.activeSpinner = void 0;
5808
+ }
5134
5809
  }
5135
- writeLine(
5136
- options.stdout,
5137
- pc9.green(`Upload complete. Remote launch ${remoteLaunchId} is finalized.`)
5138
- );
5139
- return {
5140
- finalized: true,
5141
- remoteLaunchId
5142
- };
5143
5810
  }
5144
5811
 
5145
5812
  // src/commands/mission-run.ts
@@ -5212,6 +5879,9 @@ function registerMissionRunCommand(mission, context, dependencies = {}) {
5212
5879
  sleep: dependencies.sleep,
5213
5880
  verbose: globalOptions.verbose === true
5214
5881
  });
5882
+ const registerSignalHandler = createSignalHandlerCoordinator(
5883
+ dependencies.registerSignalHandler
5884
+ );
5215
5885
  const result = await runPlanningFlow({
5216
5886
  autoApprove: options.yes === true,
5217
5887
  client,
@@ -5248,11 +5918,15 @@ function registerMissionRunCommand(mission, context, dependencies = {}) {
5248
5918
  promptConfirm: dependencies.promptConfirm,
5249
5919
  promptInput: dependencies.promptInput,
5250
5920
  promptSelect: dependencies.promptSelect,
5921
+ registerSignalHandler,
5251
5922
  repoPaths,
5252
5923
  sleep: dependencies.sleep,
5253
5924
  stdout: context.stdout,
5254
5925
  taskDescription: task
5255
5926
  });
5927
+ if (result.exitCode && result.exitCode !== 0) {
5928
+ throw new CommanderError3(result.exitCode, "mission-planning", "");
5929
+ }
5256
5930
  if (!result.cancelled && !result.skippedPlanning) {
5257
5931
  writeLine(
5258
5932
  context.stdout,
@@ -5271,9 +5945,13 @@ function registerMissionRunCommand(mission, context, dependencies = {}) {
5271
5945
  homeDir,
5272
5946
  launchId: launchSnapshot.launchId,
5273
5947
  promptConfirm: dependencies.promptConfirm,
5948
+ registerSignalHandler,
5274
5949
  repoPaths,
5275
5950
  stdout: context.stdout
5276
5951
  });
5952
+ if (uploadResult.exitCode && uploadResult.exitCode !== 0) {
5953
+ throw new CommanderError3(uploadResult.exitCode, "mission-upload", "");
5954
+ }
5277
5955
  if (!uploadResult.finalized) {
5278
5956
  return;
5279
5957
  }
@@ -5297,22 +5975,23 @@ function registerMissionRunCommand(mission, context, dependencies = {}) {
5297
5975
  missionId: mission2.missionId,
5298
5976
  promptInput: dependencies.promptInput,
5299
5977
  promptSelect: dependencies.promptSelect,
5300
- registerSignalHandler: dependencies.registerSignalHandler,
5978
+ registerSignalHandler,
5301
5979
  sleep: dependencies.sleep,
5302
- stdout: context.stdout
5980
+ stdout: context.stdout,
5981
+ verbose: globalOptions.verbose === true
5303
5982
  });
5304
5983
  if (monitorResult.exitCode !== 0) {
5305
- throw new CommanderError2(monitorResult.exitCode, "mission-monitor", "");
5984
+ throw new CommanderError3(monitorResult.exitCode, "mission-monitor", "");
5306
5985
  }
5307
5986
  }
5308
5987
  );
5309
5988
  }
5310
5989
 
5311
5990
  // src/commands/mission-status.ts
5312
- import pc10 from "picocolors";
5991
+ import pc11 from "picocolors";
5313
5992
  function writeSection4(stdout, title) {
5314
5993
  writeLine(stdout);
5315
- writeLine(stdout, pc10.bold(title));
5994
+ writeLine(stdout, pc11.bold(title));
5316
5995
  }
5317
5996
  function normalizeMilestoneOrder(milestones, features, assertions) {
5318
5997
  const known = new Map(milestones.map((milestone) => [milestone.name, milestone]));
@@ -5412,7 +6091,7 @@ function renderMilestones2(stdout, milestones, features) {
5412
6091
  for (const milestone of milestones) {
5413
6092
  writeLine(
5414
6093
  stdout,
5415
- `- ${pc10.cyan(milestone.name)} (${milestone.state}) \xB7 ${milestone.completedFeatureCount}/${milestone.featureCount} features \xB7 ${milestone.passedAssertionCount}/${milestone.assertionCount} assertions`
6094
+ `- ${pc11.cyan(milestone.name)} (${milestone.state}) \xB7 ${milestone.completedFeatureCount}/${milestone.featureCount} features \xB7 ${milestone.passedAssertionCount}/${milestone.assertionCount} assertions`
5416
6095
  );
5417
6096
  const milestoneFeatures = features.filter(
5418
6097
  (feature) => feature.milestone === milestone.name
@@ -5510,7 +6189,7 @@ function writeError(stream, message) {
5510
6189
  writeLine(stream, `Error: ${message}`);
5511
6190
  }
5512
6191
  function handleRunError(error, stderr) {
5513
- if (error instanceof CommanderError3) {
6192
+ if (error instanceof CommanderError4) {
5514
6193
  return error.exitCode;
5515
6194
  }
5516
6195
  if (error instanceof CliError) {