codeam-cli 2.75.7 → 2.75.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.js +741 -149
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2865,6 +2865,11 @@ function normalizeGuardrailPolicy(raw) {
2865
2865
  return out2;
2866
2866
  }
2867
2867
 
2868
+ // ../../packages/shared/src/packs/types.ts
2869
+ var PACK_FINDINGS_FILE = "REVIEW-FINDINGS.pack.json";
2870
+ var MAX_PACK_FINDINGS = 50;
2871
+ var PACK_HANDOFF_HEADING = "## Handoff";
2872
+
2868
2873
  // ../../packages/shared/src/packs/roles.ts
2869
2874
  var SPECIFIER_PROMPT = `# Role: Specifier
2870
2875
 
@@ -2880,6 +2885,8 @@ Method:
2880
2885
  - **Verification plan** \u2014 for each criterion, the level that proves it (unit / integration / manual) and why.
2881
2886
  3. Right-size: if the task is clearly too large for one pipeline run, narrow the criteria to a coherent first slice and record the rest under "Out of scope / next".
2882
2887
 
2888
+ 4. Close with a \`## Handoff\` section: the criteria count, what you scoped out, and any assumption the Coder must know.
2889
+
2883
2890
  Handoff bar: the spec file is committed; every acceptance criterion is testable as written; a competent implementer could start without asking you anything.`;
2884
2891
  var CODER_PROMPT = `# Role: Coder
2885
2892
 
@@ -2890,6 +2897,7 @@ Method:
2890
2897
  2. Test-first where it fits: write the test that proves a criterion, watch it fail, implement until it passes. Where strict test-first doesn't fit, still land tests alongside the change.
2891
2898
  3. Match the project's existing style, structure, and conventions. Simplest design that fully solves the problem \u2014 no speculative abstractions, no "while I'm here" changes.
2892
2899
  4. Run the project's tests / linters / build and make them pass.
2900
+ 5. Close with a \`## Handoff\` section: which criteria are implemented (by number) and where their tests live, anything you consciously did NOT do, and what the Reviewer should look at first.
2893
2901
 
2894
2902
  Handoff bar: every acceptance criterion is implemented and covered by a test; the project's checks pass; the work is committed in focused commits.`;
2895
2903
  var REVIEWER_PROMPT = `# Role: Reviewer
@@ -2905,21 +2913,42 @@ Method:
2905
2913
  - **Conventions & naming** \u2014 matches the surrounding code; names say what things are.
2906
2914
  - **Safety** \u2014 no secrets, credentials, or debugging remnants in code, tests, or fixtures.
2907
2915
  3. Fix what is justified \u2014 smallest change that resolves the finding, keeping behavior. Re-run the checks after material fixes.
2908
- 4. Record your findings honestly in your closing summary: what you found, what you fixed, what you deliberately left, and what you could not verify.
2909
-
2910
- Handoff bar: checks pass on YOUR final commit; every fix is committed; your summary lists findings \u2192 resolutions (an empty findings list must say what you checked).`;
2916
+ 4. Write your findings as DATA to \`REVIEW-FINDINGS.pack.json\` at the repo root \u2014 the next stage verifies them one by one, so what is not in this file does not get verified. Exact shape:
2917
+ \`\`\`json
2918
+ {
2919
+ "findings": [
2920
+ {
2921
+ "id": "R1",
2922
+ "severity": "blocker | major | minor | nit",
2923
+ "title": "one line: what is wrong",
2924
+ "detail": "why it matters and what you did about it",
2925
+ "file": "src/path/to/file.ts",
2926
+ "line": 42,
2927
+ "resolution": "fixed | deferred | wont_fix | needs_verification",
2928
+ "commit": "abcdef1234"
2929
+ }
2930
+ ],
2931
+ "checked": ["what you audited when the list is empty: e.g. error paths of X, test coverage of Y"]
2932
+ }
2933
+ \`\`\`
2934
+ Every finding you fixed says \`fixed\` with its commit; anything you deliberately left says \`deferred\` or \`wont_fix\` with the reason in \`detail\`; anything you could not verify says \`needs_verification\`. An empty \`findings\` array is a valid, honest result \u2014 then \`checked\` must say what you looked at. Commit this file with your stage.
2935
+ 5. Close with a \`## Handoff\` section: what you found (counts by severity), what you fixed, what you deliberately left, and what the next stage must verify.
2936
+
2937
+ Handoff bar: checks pass on YOUR final commit; every fix is committed; \`REVIEW-FINDINGS.pack.json\` is committed and lists every finding with its resolution (an empty list says what you checked).`;
2911
2938
  var QA_PROMPT = `# Role: QA
2912
2939
 
2913
2940
  You are the final gate. You verify the delivered work against the acceptance criteria as a whole \u2014 end to end, the way a demanding user would \u2014 and produce the run's closing report.
2914
2941
 
2915
2942
  Method:
2916
2943
  1. Read the task and \`SPEC.pack.md\` (when present). Your contract is the acceptance criteria; without a spec, derive them from the task.
2917
- 2. For EACH criterion, verify it against the real project: run the relevant tests, execute the code paths where feasible, inspect actual behavior/output. Do not take earlier stages' word for anything.
2918
- 3. Run the project's full checks (tests, lint, types, build) one final time.
2919
- 4. Write \`QA-REPORT.pack.md\` at the repo root: per-criterion verdict (\u2705 verified / \u26A0\uFE0F partially / \u274C failed \u2014 with evidence for each), the checks' results, anything not verifiable in this environment (stated plainly), and a short "ready to ship?" conclusion.
2920
- 5. If a criterion FAILS: fix it only when the fix is small and unambiguous; otherwise mark it failed with exact evidence \u2014 the user decides. Never paper over a failure.
2944
+ 2. Read \`REVIEW-FINDINGS.pack.json\` (when present \u2014 the Reviewer's structured findings, also summarized in your handoff input). Every finding there is an item on YOUR checklist: a \`fixed\` one must be verified fixed without regression; a \`deferred\`/\`wont_fix\` one must be judged acceptable or escalated; a \`needs_verification\` one is yours to settle.
2945
+ 3. For EACH criterion, verify it against the real project: run the relevant tests, execute the code paths where feasible, inspect actual behavior/output. Do not take earlier stages' word for anything.
2946
+ 4. Run the project's full checks (tests, lint, types, build) one final time.
2947
+ 5. Write \`QA-REPORT.pack.md\` at the repo root with two sections: **Acceptance criteria** \u2014 per-criterion verdict (\u2705 verified / \u26A0\uFE0F partially / \u274C failed \u2014 with evidence for each); **Review findings** \u2014 per finding id, verdict (\u2705 verified fixed / \u26A0\uFE0F still open / \u274C regressed \u2014 with evidence). Then the checks' results, anything not verifiable in this environment (stated plainly), and a short "ready to ship?" conclusion.
2948
+ 6. If a criterion FAILS: fix it only when the fix is small and unambiguous; otherwise mark it failed with exact evidence \u2014 the user decides. Never paper over a failure.
2949
+ 7. Close with a \`## Handoff\` section: the ship/no-ship conclusion, the failed or open items by id, and what you could not verify.
2921
2950
 
2922
- Handoff bar: the report is committed; every verdict carries evidence; the conclusion is honest about anything unverified.`;
2951
+ Handoff bar: the report is committed; every verdict carries evidence; every finding id from the Reviewer appears in the report; the conclusion is honest about anything unverified.`;
2923
2952
 
2924
2953
  // ../../packages/shared/src/packs/registry.ts
2925
2954
  var PACK_REGISTRY = {
@@ -2943,7 +2972,9 @@ var PACK_REGISTRY = {
2943
2972
  skillIds: ["code-review", "code-naming"],
2944
2973
  prompt: REVIEWER_PROMPT,
2945
2974
  // Review-only: approving clean with no change is success, not a stall.
2946
- requiresCommit: false
2975
+ requiresCommit: false,
2976
+ // Leaves REVIEW-FINDINGS.pack.json — the next stage gets a list, not prose.
2977
+ producesFindings: true
2947
2978
  }
2948
2979
  ]
2949
2980
  },
@@ -2974,7 +3005,9 @@ var PACK_REGISTRY = {
2974
3005
  skillIds: ["code-review", "code-naming"],
2975
3006
  prompt: REVIEWER_PROMPT,
2976
3007
  // Review-only: approving clean with no change is success, not a stall.
2977
- requiresCommit: false
3008
+ requiresCommit: false,
3009
+ // Leaves REVIEW-FINDINGS.pack.json — the next stage gets a list, not prose.
3010
+ producesFindings: true
2978
3011
  },
2979
3012
  {
2980
3013
  role: "qa",
@@ -3004,8 +3037,140 @@ You are ONE specialist role in a multi-role pipeline running on this repository.
3004
3037
  - **Never leave the tree broken.** Run the project's checks before finishing when the project has them; your stage ends with a working tree the next role can build on.
3005
3038
  - **Do not push, force-push, or touch remotes** \u2014 the pipeline works locally; publishing is the user's call at the end.
3006
3039
  - **Never read, edit, or commit anything under \`.codeam/\`** \u2014 that is the pipeline's own ledger, not project code.
3007
- - **Finish decisively.** When your stage's job is done and committed, say so in 2-4 lines (what you did, what you verified, anything the next stage should know) and stop. Don't ask "should I continue?" \u2014 the pipeline advances automatically.
3008
- - **If you are genuinely blocked** (contradictory requirements, missing access), say exactly what is blocking you and stop \u2014 the user is supervising and will decide.`;
3040
+ - **Finish with a \`## Handoff\` section.** When your stage's job is done and committed, end your reply with a heading \`## Handoff\` followed by 2-6 lines: what you did, what you verified, anything the next stage must know. That section \u2014 not the rest of your reply \u2014 is what the next role receives. Don't ask "should I continue?" \u2014 the pipeline advances automatically.
3041
+ - **If you need the user's decision** (contradictory requirements, a real product choice), ask ONE clear question and end your reply with 2-4 numbered options on their own lines (\`1. \u2026\`), then stop. The pipeline pauses until they answer in this conversation; keep working in it once they do, and commit as usual.
3042
+ - **If you are genuinely blocked** (missing access, a broken environment you cannot fix), say exactly what is blocking you and stop \u2014 the user is supervising and will decide.`;
3043
+
3044
+ // ../../packages/shared/src/packs/findings.ts
3045
+ var SEVERITIES = /* @__PURE__ */ new Set([
3046
+ "blocker",
3047
+ "major",
3048
+ "minor",
3049
+ "nit"
3050
+ ]);
3051
+ var RESOLUTIONS = /* @__PURE__ */ new Set([
3052
+ "fixed",
3053
+ "deferred",
3054
+ "wont_fix",
3055
+ "needs_verification"
3056
+ ]);
3057
+ var TITLE_MAX = 200;
3058
+ var DETAIL_MAX = 1200;
3059
+ function parsePackFindings(raw) {
3060
+ const text = stripFence(raw).trim();
3061
+ if (text.length === 0) return { ok: false, error: "findings file is empty" };
3062
+ let data;
3063
+ try {
3064
+ data = JSON.parse(text);
3065
+ } catch {
3066
+ const salvaged = salvageJson(text);
3067
+ if (salvaged === null) return { ok: false, error: "findings file is not valid JSON" };
3068
+ data = salvaged;
3069
+ }
3070
+ const container = Array.isArray(data) ? { findings: data } : data;
3071
+ if (!isRecord(container) || !Array.isArray(container.findings)) {
3072
+ return { ok: false, error: 'findings file has no "findings" array' };
3073
+ }
3074
+ const findings = [];
3075
+ let dropped = 0;
3076
+ const seen = /* @__PURE__ */ new Set();
3077
+ for (const entry of container.findings) {
3078
+ const f = coerceFinding(entry, findings.length + 1);
3079
+ if (!f) {
3080
+ dropped += 1;
3081
+ continue;
3082
+ }
3083
+ if (seen.has(f.id)) f.id = `${f.id}-${findings.length + 1}`;
3084
+ seen.add(f.id);
3085
+ findings.push(f);
3086
+ if (findings.length >= MAX_PACK_FINDINGS) break;
3087
+ }
3088
+ const truncated = container.findings.length - dropped - findings.length;
3089
+ const checked = Array.isArray(container.checked) ? container.checked.filter((c2) => typeof c2 === "string" && c2.trim().length > 0).slice(0, 20) : void 0;
3090
+ const notes = [];
3091
+ if (dropped > 0) notes.push(`${dropped} malformed entr${dropped === 1 ? "y" : "ies"} dropped`);
3092
+ if (truncated > 0)
3093
+ notes.push(`list truncated to ${MAX_PACK_FINDINGS} (${truncated} more in the file)`);
3094
+ return {
3095
+ ok: true,
3096
+ value: {
3097
+ findings,
3098
+ ...checked && checked.length > 0 ? { checked } : {},
3099
+ ...notes.length > 0 ? { note: notes.join("; ") } : {}
3100
+ }
3101
+ };
3102
+ }
3103
+ function coerceFinding(entry, ordinal) {
3104
+ if (!isRecord(entry)) return null;
3105
+ const title = str(entry.title) ?? str(entry.summary);
3106
+ if (!title) return null;
3107
+ const id = str(entry.id) ?? `R${ordinal}`;
3108
+ const severityRaw = (str(entry.severity) ?? "").toLowerCase();
3109
+ const resolutionRaw = (str(entry.resolution) ?? str(entry.status) ?? "").toLowerCase().replace(/[\s-]+/g, "_");
3110
+ const line = typeof entry.line === "number" && Number.isFinite(entry.line) && entry.line > 0 ? Math.floor(entry.line) : void 0;
3111
+ const detail = str(entry.detail) ?? str(entry.description);
3112
+ const file = str(entry.file) ?? str(entry.path);
3113
+ const commit = str(entry.commit);
3114
+ return {
3115
+ id: id.slice(0, 32),
3116
+ severity: SEVERITIES.has(severityRaw) ? severityRaw : "major",
3117
+ title: title.slice(0, TITLE_MAX),
3118
+ ...detail ? { detail: detail.slice(0, DETAIL_MAX) } : {},
3119
+ ...file ? { file: file.slice(0, 300) } : {},
3120
+ ...line !== void 0 ? { line } : {},
3121
+ resolution: RESOLUTIONS.has(resolutionRaw) ? resolutionRaw : "needs_verification",
3122
+ ...commit && /^[0-9a-f]{7,40}$/i.test(commit) ? { commit: commit.slice(0, 10) } : {}
3123
+ };
3124
+ }
3125
+ function extractHandoffSection(reply) {
3126
+ const lines = reply.split("\n");
3127
+ const want = PACK_HANDOFF_HEADING.replace(/^#+\s*/, "").toLowerCase();
3128
+ let start2 = -1;
3129
+ let level = 0;
3130
+ for (let i = lines.length - 1; i >= 0; i--) {
3131
+ const m = lines[i].match(/^\s{0,3}(#{1,4})\s+(.+?)\s*#*\s*$/);
3132
+ if (m && m[2].trim().toLowerCase().replace(/[:.]+$/, "") === want) {
3133
+ start2 = i;
3134
+ level = m[1].length;
3135
+ break;
3136
+ }
3137
+ }
3138
+ if (start2 < 0) return null;
3139
+ const body = [];
3140
+ for (let i = start2 + 1; i < lines.length; i++) {
3141
+ const m = lines[i].match(/^\s{0,3}(#{1,6})\s+/);
3142
+ if (m && m[1].length <= level) break;
3143
+ body.push(lines[i]);
3144
+ }
3145
+ const text = body.join("\n").trim();
3146
+ return text.length > 0 ? text : null;
3147
+ }
3148
+ function isRecord(v) {
3149
+ return typeof v === "object" && v !== null && !Array.isArray(v);
3150
+ }
3151
+ function str(v) {
3152
+ return typeof v === "string" && v.trim().length > 0 ? v.trim() : void 0;
3153
+ }
3154
+ function stripFence(raw) {
3155
+ const m = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
3156
+ return m ? m[1] : raw;
3157
+ }
3158
+ function salvageJson(text) {
3159
+ for (const [open, close] of [
3160
+ ["{", "}"],
3161
+ ["[", "]"]
3162
+ ]) {
3163
+ const a = text.indexOf(open);
3164
+ const b = text.lastIndexOf(close);
3165
+ if (a >= 0 && b > a) {
3166
+ try {
3167
+ return JSON.parse(text.slice(a, b + 1));
3168
+ } catch {
3169
+ }
3170
+ }
3171
+ }
3172
+ return null;
3173
+ }
3009
3174
 
3010
3175
  // ../../packages/shared/src/api-url.ts
3011
3176
  var DEFAULT_API_BASE_URL = "https://api.codeagent-mobile.com";
@@ -8689,7 +8854,7 @@ var TOPLEVEL_SENTINELS = [
8689
8854
  field: "window_id"
8690
8855
  }
8691
8856
  ];
8692
- function isRecord(value) {
8857
+ function isRecord2(value) {
8693
8858
  return "object" == typeof value && null !== value && !Array.isArray(value);
8694
8859
  }
8695
8860
  function toRfc3339(timestamp) {
@@ -8708,7 +8873,7 @@ function relocateInto(properties, key, value) {
8708
8873
  if (void 0 !== value && !(key in properties)) properties[key] = value;
8709
8874
  }
8710
8875
  function buildV1Event(message) {
8711
- const sourceProperties = isRecord(message.properties) ? message.properties : {};
8876
+ const sourceProperties = isRecord2(message.properties) ? message.properties : {};
8712
8877
  const properties = {
8713
8878
  ...sourceProperties
8714
8879
  };
@@ -10344,7 +10509,7 @@ function readAnonId() {
10344
10509
  }
10345
10510
  function superProperties() {
10346
10511
  return {
10347
- cliVersion: true ? "2.75.7" : "0.0.0-dev",
10512
+ cliVersion: true ? "2.75.9" : "0.0.0-dev",
10348
10513
  nodeVersion: process.version,
10349
10514
  platform: process.platform,
10350
10515
  arch: process.arch,
@@ -10586,7 +10751,7 @@ var http = __toESM(require("http"));
10586
10751
  // package.json
10587
10752
  var package_default = {
10588
10753
  name: "codeam-cli",
10589
- version: "2.75.7",
10754
+ version: "2.75.9",
10590
10755
  description: "Workflow-continuity bridge for AI coding agents. Wrap Claude Code or Codex in a PTY and supervise, approve, and redirect the session from any device \u2014 async. The terminal companion for CodeAgent Mobile.",
10591
10756
  type: "commonjs",
10592
10757
  main: "dist/index.js",
@@ -11384,6 +11549,9 @@ var streamHost = new StreamHostSelector(
11384
11549
  );
11385
11550
 
11386
11551
  // src/services/command-relay.service.ts
11552
+ function isRecord3(v) {
11553
+ return typeof v === "object" && v !== null;
11554
+ }
11387
11555
  function httpStatusOf(err) {
11388
11556
  if (typeof err === "object" && err !== null && "statusCode" in err) {
11389
11557
  const status2 = err.statusCode;
@@ -11394,6 +11562,8 @@ function httpStatusOf(err) {
11394
11562
  var API_BASE2 = resolveApiBaseUrl();
11395
11563
  var PENDING_STREAM_PATH = "/api/commands/pending/stream";
11396
11564
  var SSE_LIVENESS_TIMEOUT_MS = 45e3;
11565
+ var HEARTBEAT_RETRY_MS = 3e3;
11566
+ var HEARTBEAT_WARN_INTERVAL_MS = 6e4;
11397
11567
  var SSE_WATCHDOG_INTERVAL_MS = 1e4;
11398
11568
  var CommandRelayService = class _CommandRelayService {
11399
11569
  constructor(pluginId, onCommand, agentMeta, agentsOverride, explicitPollSecret, onHeartbeat) {
@@ -11412,6 +11582,18 @@ var CommandRelayService = class _CommandRelayService {
11412
11582
  onHeartbeat;
11413
11583
  _running = false;
11414
11584
  pairingInvalid = false;
11585
+ /**
11586
+ * Heartbeat health. Before 2026-09-23 a failed heartbeat was a `log.trace`,
11587
+ * i.e. INVISIBLE in a normal `~/.codeam/debug-*.log` — a user whose beats
11588
+ * never reached the origin (every other request did) spent a day on "Server
11589
+ * Failed to Start" with nothing in his log to show for it. Failures are now
11590
+ * WARN (rate-limited per streak) + a PostHog event, and the process exits
11591
+ * when the backend answers `paired:false`.
11592
+ */
11593
+ heartbeatFailStreak = 0;
11594
+ heartbeatLastWarnAt = 0;
11595
+ sessionGone = false;
11596
+ onSessionGone;
11415
11597
  heartbeatTimer = null;
11416
11598
  agentsTimer = null;
11417
11599
  /** Injectable for tests; defaults to real fs/fetch/spawn wiring. */
@@ -11530,6 +11712,17 @@ var CommandRelayService = class _CommandRelayService {
11530
11712
  stop() {
11531
11713
  void this.stopAndFlush();
11532
11714
  }
11715
+ /**
11716
+ * Called ONCE when a heartbeat answers `paired:false` — the backend no longer
11717
+ * has a live session for this pluginId (deleted / disconnected from the
11718
+ * app). The relay has already stopped itself by then; the owner should exit
11719
+ * the process like a Ctrl-C would. Backstop for a process whose
11720
+ * `session_terminated` command was consumed by a sibling on the same
11721
+ * pluginId (two `codeam start` on one saved session, 2026-09-23).
11722
+ */
11723
+ setOnSessionGone(cb) {
11724
+ this.onSessionGone = cb;
11725
+ }
11533
11726
  /**
11534
11727
  * Same teardown as {@link stop}, but resolves only once the `online:false`
11535
11728
  * heartbeat POST has settled — so a caller can `await` it before exiting.
@@ -11877,8 +12070,9 @@ var CommandRelayService = class _CommandRelayService {
11877
12070
  });
11878
12071
  }
11879
12072
  // ─── Heartbeat + agents ──────────────────────────────────────────
11880
- async sendHeartbeat(online) {
11881
- await _postJson(`${API_BASE2}/api/plugin/heartbeat`, {
12073
+ async sendHeartbeat(online, attempt = 0) {
12074
+ if (this.sessionGone && online) return;
12075
+ const body = {
11882
12076
  pluginId: this.pluginId,
11883
12077
  online,
11884
12078
  agentId: this.agentMeta.id,
@@ -11887,8 +12081,58 @@ var CommandRelayService = class _CommandRelayService {
11887
12081
  // fresh + clear the "CLI update available" banner after a self-update
11888
12082
  // (a codespace that reinstalls @latest reconnects via heartbeat, not
11889
12083
  // pair/reconnect). Older backends ignore the extra field.
11890
- ..."2.75.7" ? { ideVersion: "2.75.7" } : {}
11891
- }).then(() => log.trace("relay", `heartbeat ok online=${online}`)).catch((err) => log.trace("relay", `heartbeat failed online=${online}`, err));
12084
+ ..."2.75.9" ? { ideVersion: "2.75.9" } : {}
12085
+ };
12086
+ try {
12087
+ const res = await _postJson(`${API_BASE2}/api/plugin/heartbeat`, body);
12088
+ this.heartbeatFailStreak = 0;
12089
+ log.trace("relay", `heartbeat ok online=${online}`);
12090
+ const data = res?.data;
12091
+ if (online && isRecord3(data) && data.paired === false) this.handleSessionGone();
12092
+ } catch (err) {
12093
+ if (!online) {
12094
+ log.trace("relay", "goodbye heartbeat failed", err);
12095
+ return;
12096
+ }
12097
+ this.heartbeatFailStreak += 1;
12098
+ this.noteHeartbeatFailure(err);
12099
+ if (attempt === 0 && this._running) {
12100
+ const t2 = setTimeout(() => {
12101
+ void this.sendHeartbeat(true, 1);
12102
+ }, HEARTBEAT_RETRY_MS);
12103
+ t2.unref?.();
12104
+ }
12105
+ }
12106
+ }
12107
+ noteHeartbeatFailure(err) {
12108
+ const status2 = httpStatusOf(err);
12109
+ const code = err?.code;
12110
+ const reason = status2 ? `status_${status2}` : typeof code === "string" ? code : (err instanceof Error ? err.message : String(err)).slice(0, 80);
12111
+ const now = Date.now();
12112
+ const firstOfStreak = this.heartbeatFailStreak === 1;
12113
+ if (!firstOfStreak && now - this.heartbeatLastWarnAt < HEARTBEAT_WARN_INTERVAL_MS) return;
12114
+ this.heartbeatLastWarnAt = now;
12115
+ log.warn(
12116
+ "relay",
12117
+ `heartbeat failed (${reason}, streak=${this.heartbeatFailStreak}) \u2014 two misses in a row and the app shows this session OFFLINE even though it is running`
12118
+ );
12119
+ capture("heartbeat_failed", {
12120
+ pluginId: this.pluginId,
12121
+ agentId: this.agentMeta.id,
12122
+ reason,
12123
+ streak: this.heartbeatFailStreak
12124
+ });
12125
+ }
12126
+ handleSessionGone() {
12127
+ if (this.sessionGone) return;
12128
+ log.warn("relay", "backend reports this session no longer exists (paired:false) \u2014 stopping");
12129
+ capture("session_gone_detected", { pluginId: this.pluginId, agentId: this.agentMeta.id });
12130
+ process.stderr.write(
12131
+ "[codeam] This session was deleted or disconnected from the app \u2014 exiting. Run `codeam pair` to start a new one.\n"
12132
+ );
12133
+ this.stop();
12134
+ this.sessionGone = true;
12135
+ this.onSessionGone?.();
11892
12136
  }
11893
12137
  /**
11894
12138
  * Refresh {@link cachedBranch} without blocking the event loop.
@@ -31218,14 +31462,14 @@ var zGuardCreateElicitationResponseCancel = z7.object({
31218
31462
 
31219
31463
  // ../../node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js
31220
31464
  var CANCEL_REQUEST_METHOD = "$/cancel_request";
31221
- function isRecord2(value) {
31465
+ function isRecord4(value) {
31222
31466
  return typeof value === "object" && value !== null;
31223
31467
  }
31224
31468
  function isJsonRpcId(value) {
31225
31469
  return value === null || typeof value === "string" || typeof value === "number" && Number.isFinite(value);
31226
31470
  }
31227
31471
  function cancelRequestId(params) {
31228
- if (!isRecord2(params) || !isJsonRpcId(params["requestId"])) {
31472
+ if (!isRecord4(params) || !isJsonRpcId(params["requestId"])) {
31229
31473
  return void 0;
31230
31474
  }
31231
31475
  return params["requestId"];
@@ -31657,7 +31901,7 @@ var Connection = class {
31657
31901
  if (this.abortController.signal.aborted) {
31658
31902
  return;
31659
31903
  }
31660
- if (!isRecord2(message)) {
31904
+ if (!isRecord4(message)) {
31661
31905
  console.error("Invalid message", { message });
31662
31906
  return;
31663
31907
  }
@@ -31750,7 +31994,7 @@ var Connection = class {
31750
31994
  pendingResponse.cleanup?.();
31751
31995
  if ("result" in response) {
31752
31996
  pendingResponse.resolve(response.result);
31753
- } else if ("error" in response && isRecord2(response.error)) {
31997
+ } else if ("error" in response && isRecord4(response.error)) {
31754
31998
  const { code, message, data } = response.error;
31755
31999
  pendingResponse.reject(new RequestError(code, message, data));
31756
32000
  } else {
@@ -32023,7 +32267,7 @@ function ndJsonStream(output, input) {
32023
32267
  if (trimmedLine) {
32024
32268
  try {
32025
32269
  const message = JSON.parse(trimmedLine);
32026
- if (isRecord2(message)) {
32270
+ if (isRecord4(message)) {
32027
32271
  controller.enqueue(message);
32028
32272
  } else {
32029
32273
  console.warn("Skipping JSON line that is not an object:", trimmedLine);
@@ -37277,11 +37521,16 @@ async function postCredentialSync(opts, fetchImpl = fetch) {
37277
37521
  var import_node_child_process22 = require("child_process");
37278
37522
  var fs60 = __toESM(require("fs"));
37279
37523
  var path70 = __toESM(require("path"));
37280
- var defaultCommandRunner = (file, args2, cwd, timeoutMs) => new Promise((resolve11) => {
37524
+ var defaultCommandRunner = (file, args2, cwd, timeoutMs, env) => new Promise((resolve11) => {
37281
37525
  (0, import_node_child_process22.execFile)(
37282
37526
  file,
37283
37527
  args2,
37284
- { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 },
37528
+ {
37529
+ cwd,
37530
+ timeout: timeoutMs,
37531
+ maxBuffer: 4 * 1024 * 1024,
37532
+ env: env ? { ...process.env, ...env } : process.env
37533
+ },
37285
37534
  (err, stdout, stderr) => {
37286
37535
  let code = 0;
37287
37536
  if (err) {
@@ -37298,7 +37547,12 @@ async function gitHead(run, cwd) {
37298
37547
  return res.code === 0 ? res.stdout.trim() : null;
37299
37548
  }
37300
37549
  async function canonicalCommit(run, cwd, sha) {
37301
- const verify = await run("git", ["rev-parse", "--verify", `${sha}^{commit}`], cwd, GIT_TIMEOUT_MS);
37550
+ const verify = await run(
37551
+ "git",
37552
+ ["rev-parse", "--verify", `${sha}^{commit}`],
37553
+ cwd,
37554
+ GIT_TIMEOUT_MS
37555
+ );
37302
37556
  if (verify.code !== 0) return null;
37303
37557
  const short = await run("git", ["rev-parse", "--short=10", sha], cwd, GIT_TIMEOUT_MS);
37304
37558
  return short.code === 0 ? short.stdout.trim() : null;
@@ -37309,6 +37563,18 @@ async function diffStat(run, cwd, from, to) {
37309
37563
  const lines = res.stdout.trim().split("\n").filter(Boolean);
37310
37564
  return lines.length > 0 ? lines[lines.length - 1].trim() : "";
37311
37565
  }
37566
+ async function changedFiles(run, cwd, from, to) {
37567
+ const res = await run("git", ["diff", "--name-only", `${from}..${to}`], cwd, GIT_TIMEOUT_MS);
37568
+ if (res.code !== 0) return [];
37569
+ return res.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
37570
+ }
37571
+ function readWorkspaceFile(cwd, relPath) {
37572
+ try {
37573
+ return fs60.readFileSync(path70.join(cwd, relPath), "utf8");
37574
+ } catch {
37575
+ return null;
37576
+ }
37577
+ }
37312
37578
  var NO_TEST_PLACEHOLDER = 'echo "Error: no test specified"';
37313
37579
  var CHECKS_TIMEOUT_MS = 5 * 6e4;
37314
37580
  function detectChecksCommand(cwd) {
@@ -37330,7 +37596,7 @@ function detectChecksCommand(cwd) {
37330
37596
  return null;
37331
37597
  }
37332
37598
  async function runChecks(run, cwd, command2) {
37333
- const res = await run("sh", ["-c", command2], cwd, CHECKS_TIMEOUT_MS);
37599
+ const res = await run("sh", ["-c", command2], cwd, CHECKS_TIMEOUT_MS, { CI: "1" });
37334
37600
  const combined = `${res.stdout}
37335
37601
  ${res.stderr}`.trim();
37336
37602
  const tail = combined.split("\n").slice(-12).join("\n").slice(-1500);
@@ -37427,33 +37693,109 @@ async function postPackState(opts, state, fetchImpl = fetch) {
37427
37693
  }
37428
37694
 
37429
37695
  // src/packs/runner.ts
37430
- var NUDGE_PROMPT = "Your stage is not committed yet. Commit your completed work now (focused commits, ending with your role byline `By <role>.` on its own line), then summarize in 2-4 lines and stop. If you are blocked, say exactly what is blocking you instead.";
37431
- var SUMMARY_MAX_CHARS = 600;
37432
- function composeStagePrompt(pack, stageIndex, task, previous) {
37696
+ var TERMINAL_PACK_STATUSES = /* @__PURE__ */ new Set([
37697
+ "completed",
37698
+ "aborted",
37699
+ "failed"
37700
+ ]);
37701
+ var NUDGE_PROMPT = "Your stage is not committed yet. Commit your completed work now (focused commits, ending with your role byline `By <role>.` on its own line), then close with your `## Handoff` section and stop. If you are blocked, say exactly what is blocking you instead.";
37702
+ var SUMMARY_TAIL_MAX_CHARS = 600;
37703
+ var SUMMARY_SECTION_MAX_CHARS = 1500;
37704
+ var EARLIER_HANDOFF_MAX_CHARS = 400;
37705
+ function composeStagePrompt(pack, stageIndex, task, ctx) {
37433
37706
  const stage = pack.stages[stageIndex];
37434
37707
  const pipeline2 = pack.stages.map((s, i) => i === stageIndex ? `[${s.name}]` : s.name).join(" \u2192 ");
37708
+ const position = [
37709
+ `${pack.name}: ${pipeline2} \u2014 you are stage ${stageIndex + 1} of ${pack.stages.length}. Your commit byline: \`By ${stage.role}.\``
37710
+ ];
37711
+ if (ctx.baseCommit) {
37712
+ position.push(
37713
+ `Pipeline base commit: \`${ctx.baseCommit}\` \u2014 everything the pipeline has done so far is \`git log ${ctx.baseCommit}..HEAD\` / \`git diff ${ctx.baseCommit}..HEAD\`. Use that range; do not guess it.`
37714
+ );
37715
+ }
37435
37716
  const parts = [
37436
37717
  stage.prompt,
37437
37718
  PACK_WORKFLOW_ARTICLE,
37438
37719
  `## Your pipeline position
37439
- ${pack.name}: ${pipeline2} \u2014 you are stage ${stageIndex + 1} of ${pack.stages.length}. Your commit byline: \`By ${stage.role}.\``,
37720
+ ${position.join("\n")}`,
37440
37721
  `## Task
37441
37722
  ${task}`
37442
37723
  ];
37443
- if (previous) {
37724
+ const done = ctx.prior.filter((p2) => p2.status === "done" && p2.handoff);
37725
+ const skipped = ctx.prior.filter((p2) => p2.status === "skipped");
37726
+ if (done.length > 0) {
37727
+ const last = done[done.length - 1];
37728
+ const earlier = done.slice(0, -1);
37729
+ if (earlier.length > 0) {
37730
+ parts.push(
37731
+ `## Earlier handoffs
37732
+ ${earlier.map((p2) => renderHandoff(p2, EARLIER_HANDOFF_MAX_CHARS)).join("\n\n")}`
37733
+ );
37734
+ }
37444
37735
  parts.push(
37445
- `## Previous stage handoff (${previous.role})
37446
- commit: ${previous.handoff.commit}
37447
- ${previous.handoff.diffStat}
37448
-
37449
- ${previous.handoff.summary}`
37736
+ `## Previous stage handoff (${last.role})
37737
+ ${renderHandoff(last, SUMMARY_SECTION_MAX_CHARS)}`
37738
+ );
37739
+ }
37740
+ if (skipped.length > 0) {
37741
+ parts.push(
37742
+ `## Skipped stages
37743
+ ${skipped.map((p2) => `- ${p2.name} (${p2.role}) was skipped by the user \u2014 its artifacts do not exist; do not assume them.`).join("\n")}`
37744
+ );
37745
+ }
37746
+ if (ctx.attempt > 1) {
37747
+ parts.push(
37748
+ `## Previous attempt of this stage
37749
+ Attempt ${ctx.attempt - 1} did not hand off${ctx.previousAttemptError ? `: ${ctx.previousAttemptError}` : "."}
37750
+ The working tree may contain that attempt's partial work. Inspect it first, keep what is right, and finish the stage properly.`
37450
37751
  );
37451
37752
  }
37452
37753
  return parts.join("\n\n");
37453
37754
  }
37755
+ function renderHandoff(p2, summaryMax) {
37756
+ const h = p2.handoff;
37757
+ const head = [`### ${p2.name} (${p2.role})`, `commit: ${h.commit}`];
37758
+ if (h.diffStat) head.push(h.diffStat);
37759
+ if (h.checks)
37760
+ head.push(`checks (\`${h.checks.command}\`): ${h.checks.passed ? "passed" : "FAILED"}`);
37761
+ const lines = [head.join("\n")];
37762
+ const summary = h.summary.trim();
37763
+ if (summary) lines.push(summary.length > summaryMax ? `\u2026${summary.slice(-summaryMax)}` : summary);
37764
+ if (h.checks && !h.checks.passed && h.checks.tail) {
37765
+ lines.push(`Checks output (tail):
37766
+ ${h.checks.tail}`);
37767
+ }
37768
+ if (h.findings) {
37769
+ lines.push(renderFindings(h.findings, h.findingsNote));
37770
+ } else if (h.findingsNote) {
37771
+ lines.push(`Structured findings: ${h.findingsNote}`);
37772
+ }
37773
+ return lines.join("\n\n");
37774
+ }
37775
+ function renderFindings(findings, note) {
37776
+ const list = findings ?? [];
37777
+ const header = `Structured findings (${PACK_FINDINGS_FILE}): ${list.length}${note ? ` \u2014 ${note}` : ""}`;
37778
+ if (list.length === 0) return header;
37779
+ const rows = list.map((f) => {
37780
+ const where = f.file ? ` @ ${f.file}${f.line ? `:${f.line}` : ""}` : "";
37781
+ return `- [${f.id}] ${f.severity.toUpperCase()} \u2014 ${f.title}${where} \u2192 ${f.resolution}${f.commit ? ` (${f.commit})` : ""}`;
37782
+ });
37783
+ return `${header}
37784
+ ${rows.join("\n")}`;
37785
+ }
37786
+ function summarizeReply(text) {
37787
+ const section = extractHandoffSection(text);
37788
+ if (section)
37789
+ return section.length > SUMMARY_SECTION_MAX_CHARS ? section.slice(0, SUMMARY_SECTION_MAX_CHARS) : section;
37790
+ return text.trim().slice(-SUMMARY_TAIL_MAX_CHARS);
37791
+ }
37454
37792
  function nowIso() {
37455
37793
  return (/* @__PURE__ */ new Date()).toISOString();
37456
37794
  }
37795
+ function sameCommit(a, b) {
37796
+ if (!a || !b) return a === b;
37797
+ return a.startsWith(b) || b.startsWith(a);
37798
+ }
37457
37799
  var PackRunner = class _PackRunner {
37458
37800
  constructor(deps, pack, initial) {
37459
37801
  this.deps = deps;
@@ -37465,6 +37807,10 @@ var PackRunner = class _PackRunner {
37465
37807
  state;
37466
37808
  control = "run";
37467
37809
  looping = false;
37810
+ inTurn = false;
37811
+ /** Next `runStage` continues the current stage in place (no new prompt). */
37812
+ resumeInPlace = false;
37813
+ mountedSkills = [];
37468
37814
  static create(deps, packId, task, runId) {
37469
37815
  const pack = getPackDefinition(packId);
37470
37816
  if (!pack) throw new Error(`unknown pack: ${packId}`);
@@ -37485,9 +37831,51 @@ var PackRunner = class _PackRunner {
37485
37831
  };
37486
37832
  return new _PackRunner(deps, pack, state);
37487
37833
  }
37834
+ /**
37835
+ * Rebuild a runner from the workspace ledger after a CLI restart. The
37836
+ * in-memory run died with the process; its conversation is gone. A stage
37837
+ * that was mid-turn is marked failed with the real reason (never left
37838
+ * "active" forever), and the run lands paused/stalled so Resume / Retry /
37839
+ * Skip / Abort all work again. Null when the pack id is unknown.
37840
+ */
37841
+ static rehydrate(deps, stored) {
37842
+ const pack = getPackDefinition(stored.packId);
37843
+ if (!pack) return null;
37844
+ const interrupted = stored.stages.some((s) => s.status === "active");
37845
+ const stages = stored.stages.map(
37846
+ (s) => s.status === "active" ? {
37847
+ ...s,
37848
+ status: "failed",
37849
+ error: "the CLI restarted while this stage was running",
37850
+ conversationId: void 0,
37851
+ awaitingUser: void 0
37852
+ } : s
37853
+ );
37854
+ let status2 = stored.status;
37855
+ let stalledReason = stored.stalledReason;
37856
+ if (interrupted) {
37857
+ status2 = "stalled";
37858
+ stalledReason = "The CLI restarted mid-stage. Resume re-checks whether the stage committed its work; Retry stage runs it again in a fresh conversation.";
37859
+ } else if (status2 === "running") {
37860
+ status2 = "paused";
37861
+ stalledReason = "The CLI restarted between stages \u2014 tap Resume to continue.";
37862
+ }
37863
+ const state = {
37864
+ ...stored,
37865
+ stages,
37866
+ status: status2,
37867
+ stalledReason,
37868
+ pendingControl: void 0
37869
+ };
37870
+ return new _PackRunner(deps, pack, state);
37871
+ }
37488
37872
  getState() {
37489
37873
  return this.state;
37490
37874
  }
37875
+ /** Persist + post the current state without changing it (rehydration announce). */
37876
+ async announce() {
37877
+ await this.publish();
37878
+ }
37491
37879
  /** Persist + post the current state (ledger first — it's the truth). */
37492
37880
  async publish() {
37493
37881
  this.state = { ...this.state, updatedAt: nowIso() };
@@ -37499,64 +37887,101 @@ var PackRunner = class _PackRunner {
37499
37887
  await this.deps.postState(this.state);
37500
37888
  }
37501
37889
  async settle(status2, stalledReason) {
37502
- this.state = { ...this.state, status: status2, stalledReason };
37890
+ this.state = { ...this.state, status: status2, stalledReason, pendingControl: void 0 };
37503
37891
  await this.publish();
37504
37892
  }
37893
+ patchStage(index, patch) {
37894
+ const stages = this.state.stages.slice();
37895
+ stages[index] = { ...stages[index], ...patch };
37896
+ this.state = { ...this.state, stages };
37897
+ }
37505
37898
  // ── Control surface (relay `pack_action`) ────────────────────────────────
37506
37899
  async applyAction(action) {
37900
+ const status2 = this.state.status;
37901
+ const idx = this.state.currentStage;
37902
+ const reject = (why) => ({ state: this.state, rejected: why });
37903
+ if (TERMINAL_PACK_STATUSES.has(status2)) return reject(`the run already finished (${status2})`);
37507
37904
  switch (action) {
37508
- case "pause":
37905
+ case "pause": {
37906
+ if (status2 === "paused" || status2 === "stalled") return { state: this.state };
37509
37907
  this.control = "pause";
37510
- if (this.state.status === "running") {
37511
- this.state = { ...this.state, status: "paused" };
37512
- await this.publish();
37513
- }
37908
+ this.state = { ...this.state, pendingControl: "pause" };
37909
+ await this.publish();
37514
37910
  break;
37515
- case "resume":
37911
+ }
37912
+ case "resume": {
37913
+ if (status2 !== "paused" && status2 !== "stalled")
37914
+ return reject(`nothing to resume \u2014 the run is ${status2}`);
37516
37915
  this.control = "run";
37517
- if (this.state.status === "paused" || this.state.status === "stalled") {
37518
- this.state = { ...this.state, status: "running", stalledReason: void 0 };
37519
- await this.publish();
37520
- void this.run();
37521
- }
37916
+ const stage = this.state.stages[idx];
37917
+ this.resumeInPlace = !!stage && (stage.awaitingUser === true || stage.status === "failed");
37918
+ this.state = {
37919
+ ...this.state,
37920
+ status: "running",
37921
+ stalledReason: void 0,
37922
+ pendingControl: void 0
37923
+ };
37924
+ await this.publish();
37925
+ void this.run();
37522
37926
  break;
37927
+ }
37523
37928
  case "retry_stage": {
37524
- const idx = this.state.currentStage;
37525
- if (idx < this.state.stages.length) {
37526
- const stages = this.state.stages.slice();
37527
- stages[idx] = { role: stages[idx].role, name: stages[idx].name, status: "pending" };
37528
- this.control = "run";
37529
- this.state = { ...this.state, stages, status: "running", stalledReason: void 0 };
37530
- await this.publish();
37531
- void this.run();
37532
- }
37929
+ if (status2 === "running" || this.inTurn)
37930
+ return reject("the stage is still running \u2014 pause or abort first");
37931
+ if (idx >= this.state.stages.length) return reject("no stage left to retry");
37932
+ const prev = this.state.stages[idx];
37933
+ this.patchStage(idx, {
37934
+ status: "pending",
37935
+ conversationId: void 0,
37936
+ handoff: void 0,
37937
+ awaitingUser: void 0,
37938
+ startCommit: void 0,
37939
+ attempts: prev.attempts,
37940
+ error: prev.error
37941
+ });
37942
+ this.resumeInPlace = false;
37943
+ this.control = "run";
37944
+ this.state = {
37945
+ ...this.state,
37946
+ status: "running",
37947
+ stalledReason: void 0,
37948
+ pendingControl: void 0
37949
+ };
37950
+ await this.publish();
37951
+ void this.run();
37533
37952
  break;
37534
37953
  }
37535
37954
  case "skip_stage": {
37536
- const idx = this.state.currentStage;
37537
- if (idx < this.state.stages.length) {
37538
- const stages = this.state.stages.slice();
37539
- stages[idx] = { ...stages[idx], status: "skipped" };
37540
- this.control = "run";
37541
- this.state = {
37542
- ...this.state,
37543
- stages,
37544
- currentStage: idx + 1,
37545
- status: "running",
37546
- stalledReason: void 0
37547
- };
37548
- await this.publish();
37549
- void this.run();
37550
- }
37955
+ if (status2 === "running" || this.inTurn)
37956
+ return reject("the stage is still running \u2014 pause or abort first");
37957
+ if (idx >= this.state.stages.length) return reject("no stage left to skip");
37958
+ this.patchStage(idx, { status: "skipped", awaitingUser: void 0, error: void 0 });
37959
+ this.resumeInPlace = false;
37960
+ this.control = "run";
37961
+ this.state = {
37962
+ ...this.state,
37963
+ currentStage: idx + 1,
37964
+ status: "running",
37965
+ stalledReason: void 0,
37966
+ pendingControl: void 0
37967
+ };
37968
+ await this.publish();
37969
+ void this.run();
37551
37970
  break;
37552
37971
  }
37553
- case "abort":
37972
+ case "abort": {
37554
37973
  this.control = "abort";
37555
- await this.deps.driver.cancel().catch(() => void 0);
37556
- if (!this.looping) await this.settle("aborted");
37974
+ if (this.looping) {
37975
+ this.state = { ...this.state, pendingControl: "abort" };
37976
+ await this.publish();
37977
+ await this.deps.driver.cancel().catch(() => void 0);
37978
+ } else {
37979
+ await this.settle("aborted");
37980
+ }
37557
37981
  break;
37982
+ }
37558
37983
  }
37559
- return this.state;
37984
+ return { state: this.state };
37560
37985
  }
37561
37986
  // ── The loop ──────────────────────────────────────────────────────────────
37562
37987
  async run() {
@@ -37581,104 +38006,225 @@ var PackRunner = class _PackRunner {
37581
38006
  await this.settle("failed", err.message).catch(() => void 0);
37582
38007
  } finally {
37583
38008
  this.looping = false;
38009
+ this.inTurn = false;
37584
38010
  }
37585
38011
  }
37586
38012
  /** Run one stage to its handoff. True = advanced; false = run settled. */
37587
38013
  async runStage(index) {
37588
38014
  const stageDef = this.pack.stages[index];
38015
+ const inPlace = this.resumeInPlace;
38016
+ this.resumeInPlace = false;
37589
38017
  const startedMs = Date.now();
37590
- const startSha = await this.deps.gates.head();
37591
- let stages = this.state.stages.slice();
37592
- stages[index] = { ...stages[index], status: "active" };
37593
- this.state = { ...this.state, stages, status: "running" };
37594
38018
  try {
37595
- const conversationId = await this.deps.driver.newConversation();
37596
- stages = this.state.stages.slice();
37597
- stages[index] = { ...stages[index], conversationId };
37598
- this.state = { ...this.state, stages };
37599
- await this.publish();
37600
- this.deps.driver.mountSkills(stageDef.skillIds);
37601
- const previous = this.previousHandoff(index);
37602
- const prompt = composeStagePrompt(this.pack, index, this.state.task, previous);
37603
- const displayLine = `\u25B6 ${this.pack.name} \u2014 stage ${index + 1}/${this.pack.stages.length}: ${stageDef.name}`;
37604
- let reply = await this.deps.driver.runTurn(prompt, displayLine);
37605
- if (this.control === "abort") {
38019
+ let startSha;
38020
+ let turn;
38021
+ if (inPlace) {
38022
+ startSha = this.state.stages[index].startCommit ?? await this.deps.gates.head();
38023
+ this.patchStage(index, { status: "active", awaitingUser: void 0, error: void 0 });
38024
+ this.state = { ...this.state, status: "running" };
38025
+ await this.publish();
38026
+ turn = { text: "", awaitingUser: false };
38027
+ } else {
38028
+ startSha = await this.deps.gates.head();
38029
+ const prevStage = this.state.stages[index];
38030
+ const attempt = (prevStage.attempts ?? 0) + 1;
38031
+ const previousAttemptError = attempt > 1 ? prevStage.error : void 0;
38032
+ const startCommit = startSha ? await this.deps.gates.canonicalCommit(startSha) : null;
38033
+ this.patchStage(index, {
38034
+ status: "active",
38035
+ attempts: attempt,
38036
+ startCommit: startCommit ?? void 0,
38037
+ conversationId: void 0,
38038
+ handoff: void 0,
38039
+ awaitingUser: void 0,
38040
+ error: void 0
38041
+ });
38042
+ if (index === 0 && !this.state.baseCommit && startCommit) {
38043
+ this.state = { ...this.state, baseCommit: startCommit };
38044
+ }
38045
+ this.state = { ...this.state, status: "running" };
38046
+ const conversationId = await this.deps.driver.newConversation();
38047
+ this.patchStage(index, { conversationId });
38048
+ await this.publish();
38049
+ this.swapSkills(stageDef.skillIds);
38050
+ const prompt = composeStagePrompt(this.pack, index, this.state.task, {
38051
+ baseCommit: this.state.baseCommit,
38052
+ prior: this.priorOutcomes(index),
38053
+ attempt,
38054
+ previousAttemptError
38055
+ });
38056
+ const displayLine = `\u25B6 ${this.pack.name} \u2014 stage ${index + 1}/${this.pack.stages.length}: ${stageDef.name}`;
38057
+ turn = await this.drive(prompt, displayLine);
38058
+ }
38059
+ if (this.aborted()) {
37606
38060
  await this.settle("aborted");
37607
38061
  return false;
37608
38062
  }
38063
+ if (turn.awaitingUser) return this.awaitUser(index);
37609
38064
  const commitOptional = stageDef.requiresCommit === false;
38065
+ let text = turn.text;
37610
38066
  let endSha = await this.deps.gates.head();
37611
- if (!endSha || endSha === startSha) {
37612
- if (commitOptional && reply.trim().length > 0) {
37613
- const reviewedCommit = startSha ? await this.deps.gates.canonicalCommit(startSha) : null;
37614
- const handoff2 = {
37615
- commit: reviewedCommit ?? "(no changes)",
37616
- summary: reply.trim().slice(-SUMMARY_MAX_CHARS),
37617
- diffStat: "reviewed \u2014 no changes needed",
37618
- durationMs: Date.now() - startedMs
37619
- };
37620
- return this.recordHandoff(index, stageDef.role, handoff2);
38067
+ if (!endSha || sameCommit(endSha, startSha)) {
38068
+ if (commitOptional && text.trim().length > 0) {
38069
+ return this.recordHandoff(
38070
+ index,
38071
+ stageDef,
38072
+ await this.noChangeHandoff(startSha, text, startedMs)
38073
+ );
37621
38074
  }
37622
- reply = await this.deps.driver.runTurn(NUDGE_PROMPT, "\u25B6 Waiting for the stage commit\u2026");
38075
+ if (!this.state.stages[index].conversationId) {
38076
+ return this.stall(
38077
+ index,
38078
+ "stage produced no commit \u2014 Retry stage to run it again in a fresh conversation"
38079
+ );
38080
+ }
38081
+ const nudged = await this.drive(NUDGE_PROMPT, "\u25B6 Waiting for the stage commit\u2026");
38082
+ if (this.aborted()) {
38083
+ await this.settle("aborted");
38084
+ return false;
38085
+ }
38086
+ if (nudged.awaitingUser) return this.awaitUser(index);
38087
+ text = nudged.text.trim().length > 0 ? nudged.text : text;
37623
38088
  endSha = await this.deps.gates.head();
37624
- if (!endSha || endSha === startSha) {
37625
- if (commitOptional && reply.trim().length > 0) {
37626
- const reviewedCommit = startSha ? await this.deps.gates.canonicalCommit(startSha) : null;
37627
- return this.recordHandoff(index, stageDef.role, {
37628
- commit: reviewedCommit ?? "(no changes)",
37629
- summary: reply.trim().slice(-SUMMARY_MAX_CHARS),
37630
- diffStat: "reviewed \u2014 no changes needed",
37631
- durationMs: Date.now() - startedMs
37632
- });
38089
+ if (!endSha || sameCommit(endSha, startSha)) {
38090
+ if (commitOptional && text.trim().length > 0) {
38091
+ return this.recordHandoff(
38092
+ index,
38093
+ stageDef,
38094
+ await this.noChangeHandoff(startSha, text, startedMs)
38095
+ );
37633
38096
  }
37634
- return this.stall(index, "stage produced no commit", reply);
38097
+ return this.stall(index, "stage produced no commit", text);
37635
38098
  }
37636
38099
  }
37637
38100
  const commit = await this.deps.gates.canonicalCommit(endSha);
37638
- if (!commit) return this.stall(index, "stage HEAD did not resolve to a commit", reply);
38101
+ if (!commit) return this.stall(index, "stage HEAD did not resolve to a commit", text);
37639
38102
  const handoff = {
37640
38103
  commit,
37641
- summary: reply.trim().slice(-SUMMARY_MAX_CHARS),
38104
+ summary: summarizeReply(text),
37642
38105
  diffStat: startSha ? await this.deps.gates.diffStat(startSha, endSha) : "",
37643
38106
  checks: await this.deps.gates.runChecks() ?? void 0,
37644
38107
  durationMs: Date.now() - startedMs
37645
38108
  };
37646
- return this.recordHandoff(index, stageDef.role, handoff);
38109
+ if (stageDef.producesFindings) {
38110
+ Object.assign(handoff, await this.captureFindings(startSha, endSha));
38111
+ }
38112
+ return this.recordHandoff(index, stageDef, handoff);
37647
38113
  } catch (err) {
37648
- if (this.control === "abort") {
38114
+ if (this.aborted()) {
37649
38115
  await this.settle("aborted");
37650
38116
  return false;
37651
38117
  }
37652
38118
  return this.stall(index, err.message);
37653
38119
  }
37654
38120
  }
38121
+ /** Read through a method: `control` flips from another call while a turn is
38122
+ * awaited, which TS's property narrowing would otherwise hide. */
38123
+ aborted() {
38124
+ return this.control === "abort";
38125
+ }
38126
+ async drive(prompt, displayLine) {
38127
+ this.inTurn = true;
38128
+ try {
38129
+ return await this.deps.driver.runTurn(prompt, displayLine);
38130
+ } finally {
38131
+ this.inTurn = false;
38132
+ }
38133
+ }
38134
+ swapSkills(next) {
38135
+ const stale = this.mountedSkills.filter((id) => !next.includes(id));
38136
+ const fresh = next.filter((id) => !this.mountedSkills.includes(id));
38137
+ if (stale.length > 0) this.deps.driver.unmountSkills(stale);
38138
+ if (fresh.length > 0) this.deps.driver.mountSkills(fresh);
38139
+ this.mountedSkills = next.slice();
38140
+ }
38141
+ async noChangeHandoff(startSha, text, startedMs) {
38142
+ const reviewedCommit = startSha ? await this.deps.gates.canonicalCommit(startSha) : null;
38143
+ return {
38144
+ commit: reviewedCommit ?? "(no changes)",
38145
+ summary: summarizeReply(text),
38146
+ diffStat: "reviewed \u2014 no changes needed",
38147
+ durationMs: Date.now() - startedMs
38148
+ };
38149
+ }
38150
+ /**
38151
+ * Parse the stage's `PACK_FINDINGS_FILE` into the handoff. Only a file the
38152
+ * stage itself touched counts — a stale one left by an earlier run must not
38153
+ * masquerade as this review's verdict. Every failure path leaves an honest
38154
+ * `findingsNote`; none of them stalls the run.
38155
+ */
38156
+ async captureFindings(startSha, endSha) {
38157
+ try {
38158
+ if (startSha) {
38159
+ const changed = await this.deps.gates.changedFiles(startSha, endSha);
38160
+ if (!changed.includes(PACK_FINDINGS_FILE)) {
38161
+ return {
38162
+ findingsNote: `${PACK_FINDINGS_FILE} was not written by this stage \u2014 no structured findings`
38163
+ };
38164
+ }
38165
+ }
38166
+ const raw = await this.deps.gates.readFile(PACK_FINDINGS_FILE);
38167
+ if (raw === null)
38168
+ return { findingsNote: `no ${PACK_FINDINGS_FILE} \u2014 the stage left no structured findings` };
38169
+ const parsed = parsePackFindings(raw);
38170
+ if (!parsed.ok) return { findingsNote: `${PACK_FINDINGS_FILE}: ${parsed.error}` };
38171
+ const notes = [];
38172
+ if (parsed.value.findings.length === 0 && parsed.value.checked) {
38173
+ notes.push(`no findings; checked: ${parsed.value.checked.join(", ")}`);
38174
+ }
38175
+ if (parsed.value.note) notes.push(parsed.value.note);
38176
+ return {
38177
+ findings: parsed.value.findings,
38178
+ ...notes.length > 0 ? { findingsNote: notes.join(" \u2014 ") } : {}
38179
+ };
38180
+ } catch (err) {
38181
+ return { findingsNote: `could not read ${PACK_FINDINGS_FILE}: ${err.message}` };
38182
+ }
38183
+ }
37655
38184
  /** Mark a stage `done` with its handoff, persist, and advance. */
37656
- async recordHandoff(index, role, handoff) {
37657
- const stages = this.state.stages.slice();
37658
- stages[index] = { ...stages[index], status: "done", handoff };
37659
- this.state = { ...this.state, stages, currentStage: index + 1 };
38185
+ async recordHandoff(index, stageDef, handoff) {
38186
+ this.patchStage(index, { status: "done", handoff, awaitingUser: void 0, error: void 0 });
38187
+ this.state = { ...this.state, currentStage: index + 1 };
37660
38188
  try {
37661
- this.deps.ledger.saveStageHandoff(this.state.runId, index, role, handoff);
38189
+ this.deps.ledger.saveStageHandoff(this.state.runId, index, stageDef.role, handoff);
37662
38190
  } catch (err) {
37663
38191
  this.deps.log(`pack handoff save failed: ${err.message}`);
37664
38192
  }
37665
38193
  await this.publish();
37666
38194
  return true;
37667
38195
  }
38196
+ /** The stage asked the user something: park the run, keep the conversation. */
38197
+ async awaitUser(index) {
38198
+ const name = this.state.stages[index].name;
38199
+ this.patchStage(index, { awaitingUser: true });
38200
+ this.control = "pause";
38201
+ await this.settle(
38202
+ "paused",
38203
+ `${name} asked you a question \u2014 answer it in the stage chat, then tap Resume.`
38204
+ );
38205
+ this.deps.log(
38206
+ `pack run ${this.state.runId} paused at stage ${index + 1}: awaiting the user's answer`
38207
+ );
38208
+ return false;
38209
+ }
37668
38210
  async stall(index, reason, lastReply) {
37669
- const stages = this.state.stages.slice();
37670
- stages[index] = { ...stages[index], status: "failed", error: reason };
37671
- this.state = { ...this.state, stages };
37672
- await this.settle("stalled", lastReply ? `${reason} \u2014 last reply: ${lastReply.slice(-300)}` : reason);
38211
+ this.patchStage(index, { status: "failed", error: reason, awaitingUser: void 0 });
38212
+ await this.settle(
38213
+ "stalled",
38214
+ lastReply ? `${reason} \u2014 last reply: ${lastReply.slice(-300)}` : reason
38215
+ );
37673
38216
  this.deps.log(`pack run ${this.state.runId} stalled at stage ${index + 1}: ${reason}`);
37674
38217
  return false;
37675
38218
  }
37676
- previousHandoff(index) {
37677
- for (let i = index - 1; i >= 0; i--) {
38219
+ priorOutcomes(index) {
38220
+ const out2 = [];
38221
+ for (let i = 0; i < index; i++) {
37678
38222
  const s = this.state.stages[i];
37679
- if (s.status === "done" && s.handoff) return { role: s.role, handoff: s.handoff };
38223
+ if (s.status === "done" && s.handoff)
38224
+ out2.push({ role: s.role, name: s.name, status: "done", handoff: s.handoff });
38225
+ else if (s.status === "skipped") out2.push({ role: s.role, name: s.name, status: "skipped" });
37680
38226
  }
37681
- return null;
38227
+ return out2;
37682
38228
  }
37683
38229
  };
37684
38230
 
@@ -37692,7 +38238,7 @@ function getActivePackRunner() {
37692
38238
  }
37693
38239
 
37694
38240
  // src/packs/handlers.ts
37695
- var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "aborted", "failed"]);
38241
+ var TERMINAL_STATUSES = TERMINAL_PACK_STATUSES;
37696
38242
  var PACK_ACTIONS = /* @__PURE__ */ new Set(["pause", "resume", "retry_stage", "skip_stage", "abort"]);
37697
38243
  function buildPackRunnerDeps(ctx) {
37698
38244
  const cwd = ctx.opts.cwd;
@@ -37712,10 +38258,10 @@ function buildPackRunnerDeps(ctx) {
37712
38258
  ctx.history.appendUserPrompt(displayLine);
37713
38259
  await ctx.client.prompt(prompt);
37714
38260
  const text = ctx.streaming.getCurrentText();
37715
- await ctx.streaming.closeTurnWithInteractiveDetection();
38261
+ const awaitingUser = await ctx.streaming.closeTurnWithInteractiveDetection();
37716
38262
  ctx.history.appendAgentReply(text);
37717
38263
  await ctx.history.flush();
37718
- return text;
38264
+ return { text, awaitingUser };
37719
38265
  },
37720
38266
  cancel: () => ctx.client.cancel(),
37721
38267
  mountSkills: (skillIds) => {
@@ -37726,16 +38272,27 @@ function buildPackRunnerDeps(ctx) {
37726
38272
  log.warn("packs", `skill mount failed for ${id}: ${err.message}`);
37727
38273
  }
37728
38274
  }
38275
+ },
38276
+ unmountSkills: (skillIds) => {
38277
+ for (const id of skillIds) {
38278
+ try {
38279
+ configureSkill("remove", id);
38280
+ } catch (err) {
38281
+ log.warn("packs", `skill unmount failed for ${id}: ${err.message}`);
38282
+ }
38283
+ }
37729
38284
  }
37730
38285
  },
37731
38286
  gates: {
37732
38287
  head: () => gitHead(run, cwd),
37733
38288
  canonicalCommit: (sha) => canonicalCommit(run, cwd, sha),
37734
38289
  diffStat: (from, to) => diffStat(run, cwd, from, to),
38290
+ changedFiles: (from, to) => changedFiles(run, cwd, from, to),
37735
38291
  runChecks: async () => {
37736
38292
  const command2 = detectChecksCommand(cwd);
37737
38293
  return command2 ? runChecks(run, cwd, command2) : null;
37738
- }
38294
+ },
38295
+ readFile: async (relPath) => readWorkspaceFile(cwd, relPath)
37739
38296
  },
37740
38297
  ledger: {
37741
38298
  saveRun: (state) => saveRun(cwd, state),
@@ -37753,19 +38310,38 @@ function buildPackRunnerDeps(ctx) {
37753
38310
  log: (message) => log.info("packs", message)
37754
38311
  };
37755
38312
  }
38313
+ async function ensureRunner(ctx) {
38314
+ const active2 = getActivePackRunner();
38315
+ if (active2) return active2;
38316
+ const stored = loadLatestRun(ctx.opts.cwd);
38317
+ if (!stored || TERMINAL_STATUSES.has(stored.status)) return null;
38318
+ const runner = PackRunner.rehydrate(buildPackRunnerDeps(ctx), stored);
38319
+ if (!runner) return null;
38320
+ setActivePackRunner(runner);
38321
+ log.info(
38322
+ "packs",
38323
+ `rehydrated run ${stored.runId} from the ledger (was ${stored.status}) \u2192 ${runner.getState().status}`
38324
+ );
38325
+ await runner.announce();
38326
+ return runner;
38327
+ }
37756
38328
  var packStartH = async (ctx) => {
37757
38329
  const payload = ctx.cmd.payload;
37758
38330
  const packId = typeof payload?.packId === "string" ? payload.packId : "";
37759
38331
  const task = typeof payload?.task === "string" ? payload.task.trim() : "";
37760
38332
  if (!isPackId(packId)) {
37761
- await ctx.relay.sendResult(ctx.cmd.id, "failed", { error: `unknown pack: ${packId || "(none)"}` });
38333
+ await ctx.relay.sendResult(ctx.cmd.id, "failed", {
38334
+ error: `unknown pack: ${packId || "(none)"}`
38335
+ });
37762
38336
  return;
37763
38337
  }
37764
38338
  if (task.length === 0) {
37765
- await ctx.relay.sendResult(ctx.cmd.id, "failed", { error: "pack_start requires a non-empty task" });
38339
+ await ctx.relay.sendResult(ctx.cmd.id, "failed", {
38340
+ error: "pack_start requires a non-empty task"
38341
+ });
37766
38342
  return;
37767
38343
  }
37768
- const existing = getActivePackRunner();
38344
+ const existing = await ensureRunner(ctx);
37769
38345
  if (existing && !TERMINAL_STATUSES.has(existing.getState().status)) {
37770
38346
  await ctx.relay.sendResult(ctx.cmd.id, "failed", {
37771
38347
  error: "a pack run is already active on this session \u2014 pause/abort it first",
@@ -37788,10 +38364,12 @@ var packActionH = async (ctx) => {
37788
38364
  const payload = ctx.cmd.payload;
37789
38365
  const action = typeof payload?.action === "string" ? payload.action : "";
37790
38366
  if (!PACK_ACTIONS.has(action)) {
37791
- await ctx.relay.sendResult(ctx.cmd.id, "failed", { error: `unknown pack action: ${action || "(none)"}` });
38367
+ await ctx.relay.sendResult(ctx.cmd.id, "failed", {
38368
+ error: `unknown pack action: ${action || "(none)"}`
38369
+ });
37792
38370
  return;
37793
38371
  }
37794
- const runner = getActivePackRunner();
38372
+ const runner = await ensureRunner(ctx);
37795
38373
  if (!runner) {
37796
38374
  await ctx.relay.sendResult(ctx.cmd.id, "failed", {
37797
38375
  error: "no active pack run in this session",
@@ -37799,11 +38377,20 @@ var packActionH = async (ctx) => {
37799
38377
  });
37800
38378
  return;
37801
38379
  }
37802
- const state = await runner.applyAction(action);
37803
- await ctx.relay.sendResult(ctx.cmd.id, "completed", { state });
38380
+ const outcome = await runner.applyAction(action);
38381
+ if (outcome.rejected) {
38382
+ log.info("packs", `pack_action ${action} rejected: ${outcome.rejected}`);
38383
+ await ctx.relay.sendResult(ctx.cmd.id, "failed", {
38384
+ error: outcome.rejected,
38385
+ state: outcome.state
38386
+ });
38387
+ return;
38388
+ }
38389
+ await ctx.relay.sendResult(ctx.cmd.id, "completed", { state: outcome.state });
37804
38390
  };
37805
38391
  var packStatusH = async (ctx) => {
37806
- const state = getActivePackRunner()?.getState() ?? loadLatestRun(ctx.opts.cwd);
38392
+ const runner = await ensureRunner(ctx);
38393
+ const state = runner?.getState() ?? loadLatestRun(ctx.opts.cwd);
37807
38394
  await ctx.relay.sendResult(ctx.cmd.id, "completed", { state });
37808
38395
  };
37809
38396
 
@@ -40600,6 +41187,7 @@ async function runAcpSession(opts) {
40600
41187
  };
40601
41188
  process.once("SIGINT", () => void shutdown("SIGINT"));
40602
41189
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
41190
+ relay.setOnSessionGone(() => void shutdown("SIGTERM"));
40603
41191
  process.once("SIGHUP", () => void shutdown("SIGHUP"));
40604
41192
  await new Promise(() => {
40605
41193
  });
@@ -42730,6 +43318,7 @@ async function runBatonSession(opts) {
42730
43318
  process.once("SIGINT", onSignal);
42731
43319
  process.once("SIGTERM", onSignal);
42732
43320
  process.once("SIGHUP", onSignal);
43321
+ relay.setOnSessionGone(onSignal);
42733
43322
  showInfo(`Starting ${opts.agent} baton (local) \u2014 native TUI + mobile take-control\u2026`);
42734
43323
  await controller.begin();
42735
43324
  relay.start();
@@ -43170,6 +43759,9 @@ async function start(requestedAgent, presetSession) {
43170
43759
  })
43171
43760
  );
43172
43761
  ctx.relay = relay;
43762
+ relay.setOnSessionGone(() => {
43763
+ void sigintHandler();
43764
+ });
43173
43765
  void beadsReady.then((started) => {
43174
43766
  ctx.beads = started;
43175
43767
  });
@@ -46422,7 +47014,7 @@ async function autoUpgradeBeforeCriticalCommand() {
46422
47014
  if (process.env.NODE_ENV === "test") return;
46423
47015
  if (process.env.CODEAM_DISABLE_UPDATE_CHECK === "1") return;
46424
47016
  if (process.env.CI) return;
46425
- const current2 = true ? "2.75.7" : null;
47017
+ const current2 = true ? "2.75.9" : null;
46426
47018
  if (!current2) return;
46427
47019
  const cache = readCache();
46428
47020
  const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
@@ -46439,7 +47031,7 @@ function checkForUpdates() {
46439
47031
  if (process.env.CODEAM_DISABLE_UPDATE_CHECK === "1") return;
46440
47032
  if (process.env.CI) return;
46441
47033
  if (!process.stdout.isTTY) return;
46442
- const current2 = true ? "2.75.7" : null;
47034
+ const current2 = true ? "2.75.9" : null;
46443
47035
  if (!current2) return;
46444
47036
  const cache = readCache();
46445
47037
  const fresh = cache && Date.now() - cache.fetchedAt < TTL_MS;
@@ -46462,7 +47054,7 @@ var SELF_UPDATE_INSTALL_TIMEOUT_MS = 18e4;
46462
47054
  var SELF_UPDATE_LS_TIMEOUT_MS = 15e3;
46463
47055
  var SUDO_PREFLIGHT_TIMEOUT_MS = 5e3;
46464
47056
  function currentCliVersion() {
46465
- return true ? "2.75.7" : null;
47057
+ return true ? "2.75.9" : null;
46466
47058
  }
46467
47059
  async function installedVersion(deps) {
46468
47060
  const ls = await deps.run(
@@ -48202,7 +48794,7 @@ function checkChokidar() {
48202
48794
  }
48203
48795
  async function doctor(args2 = []) {
48204
48796
  const json = args2.includes("--json");
48205
- const cliVersion = true ? "2.75.7" : "0.0.0-dev";
48797
+ const cliVersion = true ? "2.75.9" : "0.0.0-dev";
48206
48798
  const apiBase2 = resolveApiBaseUrl();
48207
48799
  const diagnosticId = (0, import_node_crypto12.randomUUID)();
48208
48800
  log.info("doctor", `run id=${diagnosticId} cli=${cliVersion}`);
@@ -48726,7 +49318,7 @@ async function mcpWarm(argv = [], opts = {}) {
48726
49318
  // src/commands/version.ts
48727
49319
  var import_picocolors15 = __toESM(require("picocolors"));
48728
49320
  function version2() {
48729
- const v = true ? "2.75.7" : "unknown";
49321
+ const v = true ? "2.75.9" : "unknown";
48730
49322
  console.log(`${import_picocolors15.default.bold("codeam-cli")} ${import_picocolors15.default.cyan(v)}`);
48731
49323
  }
48732
49324