@miraland-labs/conduit-bridge 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/driver.js CHANGED
@@ -139,17 +139,68 @@ export function buildAssignmentPrompt(context) {
139
139
  "- EVERY criterion you mark \"met\" must appear in the acceptance_criteria list of at least one evidence entry. A met criterion with no evidence mapped to it fails the whole delivery — mark it unknown instead, or add the evidence that supports it.", "", "When the work is finished, end your reply with exactly one fenced ```json block:", agentReportTemplate);
140
140
  return lines.join("\n");
141
141
  }
142
+ /**
143
+ * Extract report JSON text from an agent reply. Prefers the last closed ```json fence,
144
+ * then an unclosed ```json fence (truncated replies), then a balanced top-level object.
145
+ */
146
+ export function extractAgentReportJsonText(text) {
147
+ const closed = [...text.matchAll(/```json\s*([\s\S]*?)```/gi)];
148
+ const fromFence = closed.at(-1)?.[1]?.trim();
149
+ if (fromFence)
150
+ return fromFence;
151
+ const unclosed = /```json\s*([\s\S]*)$/i.exec(text);
152
+ const fromUnclosed = unclosed?.[1]?.trim();
153
+ if (fromUnclosed)
154
+ return fromUnclosed;
155
+ const start = text.lastIndexOf("{");
156
+ if (start < 0)
157
+ throw new Error("Agent did not emit the required structured report");
158
+ return text.slice(start).trim();
159
+ }
160
+ /** Parse JSON, or the first balanced `{...}` object when the candidate is truncated prose. */
161
+ export function parseJsonObjectCandidate(candidate) {
162
+ try {
163
+ return JSON.parse(candidate);
164
+ }
165
+ catch {
166
+ const start = candidate.indexOf("{");
167
+ if (start < 0)
168
+ throw new Error("Agent emitted malformed report JSON");
169
+ let depth = 0;
170
+ let inString = false;
171
+ for (let i = start; i < candidate.length; i++) {
172
+ const ch = candidate[i];
173
+ if (inString) {
174
+ if (ch === "\\")
175
+ i++;
176
+ else if (ch === '"')
177
+ inString = false;
178
+ }
179
+ else if (ch === '"')
180
+ inString = true;
181
+ else if (ch === "{")
182
+ depth++;
183
+ else if (ch === "}" && --depth === 0) {
184
+ try {
185
+ return JSON.parse(candidate.slice(start, i + 1));
186
+ }
187
+ catch {
188
+ throw new Error("Agent emitted malformed report JSON");
189
+ }
190
+ }
191
+ }
192
+ throw new Error("Agent emitted malformed report JSON");
193
+ }
194
+ }
142
195
  /** Parse the agent's final fenced JSON block into a bounded report. */
143
196
  export function parseAgentReport(text, acceptance) {
144
- const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/g)];
145
- const last = blocks.at(-1)?.[1];
146
- if (!last)
147
- throw new Error("Agent did not emit the required structured report");
148
197
  let raw;
149
198
  try {
150
- raw = JSON.parse(last);
199
+ raw = parseJsonObjectCandidate(extractAgentReportJsonText(text));
151
200
  }
152
- catch {
201
+ catch (error) {
202
+ if (error instanceof Error && /structured report|malformed report JSON/.test(error.message))
203
+ throw error;
153
204
  throw new Error("Agent emitted malformed report JSON");
154
205
  }
155
206
  const stringList = z.array(z.string().trim().min(1).max(4_000)).max(100);
@@ -151,17 +151,20 @@ export async function pushOriginHead(workspace, options = {}) {
151
151
  throw new Error(lastMessage);
152
152
  }
153
153
  /**
154
- * Bind reported head_commit to the checkout HEAD, then open a PR when still needed.
154
+ * Bind head_commit to the checkout HEAD, then open a PR when still needed.
155
+ * Workspace HEAD is authoritative for scoped repository deliveries: agents (especially
156
+ * Experimental local-fuel lanes) often invent or paste a wrong SHA. Prefer the worktree
157
+ * over failing a delivery that already committed correctly.
155
158
  * Agent-supplied PR URLs skip creation but still require HEAD identity.
156
159
  */
157
160
  export async function ensureDeliveryPullRequest(input) {
158
161
  const readHead = input.readHeadCommit ?? workspaceHeadCommit;
159
162
  const push = input.pushOriginHead ?? pushOriginHead;
160
163
  let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
161
- if (report.head_commit && (input.spec.change_scope?.length ?? 0) > 0) {
164
+ if ((input.spec.change_scope?.length ?? 0) > 0) {
162
165
  const head = await readHead(input.workspace);
163
- if (!commitsMatch(report.head_commit, head)) {
164
- throw new Error(`Delivered head_commit ${report.head_commit} does not match workspace HEAD ${head}`);
166
+ if (report.head_commit && !commitsMatch(report.head_commit, head)) {
167
+ console.error(`Agent head_commit ${report.head_commit} does not match workspace HEAD ${head}; using workspace HEAD`);
165
168
  }
166
169
  report = { ...report, head_commit: head };
167
170
  }
package/dist/execution.js CHANGED
@@ -479,59 +479,76 @@ async function runClaimedAssignment(client, config, driver, workspace, brief, ta
479
479
  report = parseAgentReport(reportText, spec.acceptance ?? []);
480
480
  }
481
481
  catch (error) {
482
- const parseError = error instanceof Error ? error.message : "Agent delivery report was invalid";
483
- const originalReply = reportText;
484
- const replyDigest = createHash("sha256").update(originalReply).digest("hex");
485
- await client.attemptRequest(taskId, "progress", {
486
- phase: "preparing_delivery",
487
- message: `Delivery envelope invalid (${redactSecrets(parseError)}); starting the single report-only repair turn without rerunning implementation. Original reply: ${originalReply.length} characters, sha256:${replyDigest}.`,
488
- idempotency_key: `bridge:progress:${active.attemptId}:delivery-repair`,
489
- });
490
- let repaired;
491
- try {
492
- repaired = await driver.run({
493
- prompt: buildDeliveryRepairPrompt(parseError, originalReply, spec.acceptance ?? []),
494
- workspace: attemptWorkspace,
495
- // Preserve only existing read authority. No write, test, branch, or push
496
- // capability is available while the agent repairs the response envelope.
497
- grants: grants.filter((grant) => grant === "repo_read"),
498
- capabilities: [],
499
- verificationCommands: [],
500
- // Envelope repair is read-only observe authority regardless of the original class.
501
- executionClass: "observe",
502
- resumeSessionId: result.sessionId ?? undefined,
503
- timeoutMs,
504
- model: selection.model,
505
- fuel,
506
- fuelSource,
482
+ // Pump fuel keeps one repair turn. Local-fuel Experimental lanes (Pi et al.) get a second
483
+ // read-only repair — truncated JSON fences are common there and re-implementing is wasteful.
484
+ const maxRepairs = fuelSource === "local" ? 2 : 1;
485
+ let parseError = error instanceof Error ? error.message : "Agent delivery report was invalid";
486
+ let previousReply = reportText;
487
+ let resumeSessionId = result.sessionId ?? undefined;
488
+ let repaired = null;
489
+ let parsed = null;
490
+ for (let repairTurn = 1; repairTurn <= maxRepairs; repairTurn++) {
491
+ const replyDigest = createHash("sha256").update(previousReply).digest("hex");
492
+ await client.attemptRequest(taskId, "progress", {
493
+ phase: "preparing_delivery",
494
+ message: `Delivery envelope invalid (${redactSecrets(parseError)}); starting report-only repair turn ${repairTurn}/${maxRepairs} without rerunning implementation. Original reply: ${previousReply.length} characters, sha256:${replyDigest}.`,
495
+ idempotency_key: `bridge:progress:${active.attemptId}:delivery-repair:${repairTurn}`,
507
496
  });
497
+ try {
498
+ repaired = await driver.run({
499
+ prompt: buildDeliveryRepairPrompt(parseError, previousReply, spec.acceptance ?? []),
500
+ workspace: attemptWorkspace,
501
+ // Preserve only existing read authority. No write, test, branch, or push
502
+ // capability is available while the agent repairs the response envelope.
503
+ grants: grants.filter((grant) => grant === "repo_read"),
504
+ capabilities: [],
505
+ verificationCommands: [],
506
+ // Envelope repair is read-only observe authority regardless of the original class.
507
+ executionClass: "observe",
508
+ resumeSessionId,
509
+ timeoutMs,
510
+ model: selection.model,
511
+ fuel,
512
+ fuelSource,
513
+ });
514
+ }
515
+ catch (repairError) {
516
+ const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
517
+ await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
518
+ console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
519
+ return;
520
+ }
521
+ if (repaired.sessionId) {
522
+ config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
523
+ resumeSessionId = repaired.sessionId;
524
+ }
525
+ if (repaired.status === "failed") {
526
+ const message = repaired.error ?? "Delivery report repair failed";
527
+ await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
528
+ console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
529
+ return;
530
+ }
531
+ previousReply = repaired.resultText ?? "";
532
+ try {
533
+ parsed = parseAgentReport(previousReply, spec.acceptance ?? []);
534
+ break;
535
+ }
536
+ catch (repairError) {
537
+ parseError = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
538
+ if (repairTurn >= maxRepairs) {
539
+ await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair exhausted: ${parseError}`, retryable: false, idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}` } });
540
+ console.error(`Assignment ${taskId} exhausted its report-only repair: ${redactSecrets(parseError)}`);
541
+ const replyTail = previousReply.slice(-8_000);
542
+ console.error(`Assignment ${taskId} repaired reply tail (${previousReply.length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
543
+ return;
544
+ }
545
+ console.error(`Assignment ${taskId} report-only repair turn ${repairTurn} still invalid: ${redactSecrets(parseError)}; retrying`);
546
+ }
508
547
  }
509
- catch (repairError) {
510
- const message = repairError instanceof Error ? repairError.message : "Delivery report repair failed";
511
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair failed: ${message}`, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
512
- console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
548
+ if (!parsed)
513
549
  return;
514
- }
515
- if (repaired.sessionId)
516
- config.sessions = { ...config.sessions, [taskId]: repaired.sessionId };
517
- if (repaired.status === "failed") {
518
- const message = repaired.error ?? "Delivery report repair failed";
519
- await queueTerminal(client, taskId, { action: "fail", body: { error: message, retryable: retryableAgentFailure(message), idempotency_key: `bridge:delivery-repair-failed:${active.attemptId}` } });
520
- console.error(`Assignment ${taskId} report-only repair failed: ${redactSecrets(message)}`);
521
- return;
522
- }
523
- try {
524
- reportText = repaired.resultText ?? "";
525
- report = parseAgentReport(reportText, spec.acceptance ?? []);
526
- }
527
- catch (repairError) {
528
- const message = repairError instanceof Error ? repairError.message : "Repaired delivery report was invalid";
529
- await queueTerminal(client, taskId, { action: "fail", body: { error: `Delivery report repair exhausted: ${message}`, retryable: false, idempotency_key: `bridge:delivery-repair-invalid:${active.attemptId}` } });
530
- console.error(`Assignment ${taskId} exhausted its report-only repair: ${redactSecrets(message)}`);
531
- const replyTail = (repaired.resultText ?? "").slice(-8_000);
532
- console.error(`Assignment ${taskId} repaired reply tail (${(repaired.resultText ?? "").length} chars total, redacted): ${redactSecrets(replyTail) || "<empty>"}`);
533
- return;
534
- }
550
+ reportText = previousReply;
551
+ report = parsed;
535
552
  }
536
553
  try {
537
554
  // Mechanical land path: agent may forget gh; Bridge pushes and the control plane opens the PR.
@@ -604,9 +621,10 @@ function buildDeliveryRepairPrompt(parseError, previousReply, acceptance) {
604
621
  return [
605
622
  "The implementation turn is complete. Perform exactly one report-only repair.",
606
623
  "Do not inspect or modify files. Do not run commands or tools. Do not redo implementation.",
607
- "Return only one corrected fenced ```json Delivery object and no other prose.",
624
+ "Return only one corrected fenced ```json Delivery object and no other prose. Close the fence.",
608
625
  "Preserve the previous reply's substantive outcome, changes, verification, acceptance statuses, evidence, assumptions, risks, and limitations.",
609
626
  "Use only claims and evidence already present in the previous reply. Do not invent evidence, broaden scope, change reported work, or claim a criterion is met without existing supporting evidence.",
627
+ "Omit head_commit if uncertain — Bridge binds workspace HEAD. Keep pull_request_url only when the previous reply already had a real PR URL.",
610
628
  "",
611
629
  "REQUIRED DELIVERY SHAPE",
612
630
  agentReportTemplate,
package/dist/service.js CHANGED
@@ -128,11 +128,55 @@ function shellQuote(value) {
128
128
  * the next line never ran, and the machine stayed Binding incomplete with the service unloaded.
129
129
  * The swap therefore runs in a detached helper that survives the bootout; failures append to the
130
130
  * runner log.
131
+ *
132
+ * Interactive `ops install` from a terminal must NOT use only the detached path: on some macOS
133
+ * hosts `launchctl bootstrap` returns EIO (5) from the helper while an in-process bootstrap of the
134
+ * same plist succeeds. Prefer a synchronous reload when we are not inside the runner service.
135
+ * Deprecated `launchctl load -w` also returns EIO on modern macOS — use enable + kickstart instead.
131
136
  */
132
137
  export function launchdSwapCommand(domain, plistPath, log) {
133
138
  const plist = shellQuote(plistPath);
134
139
  const logQ = shellQuote(log);
135
- return `sleep 1; launchctl bootout ${domain}/${SERVICE_LABEL} 2>>${logQ}; launchctl bootstrap ${domain} ${plist} 2>>${logQ} || launchctl load -w ${plist} 2>>${logQ}`;
140
+ const label = `${domain}/${SERVICE_LABEL}`;
141
+ return [
142
+ "sleep 1",
143
+ `launchctl bootout ${label} 2>>${logQ}`,
144
+ `launchctl bootstrap ${domain} ${plist} 2>>${logQ}`,
145
+ `launchctl enable ${label} 2>>${logQ}`,
146
+ `launchctl kickstart -k ${label} 2>>${logQ}`,
147
+ ].join("; ");
148
+ }
149
+ function launchdServiceLoaded(domain) {
150
+ return spawnSync("launchctl", ["print", `${domain}/${SERVICE_LABEL}`], { encoding: "utf8" }).status === 0;
151
+ }
152
+ function sleepMs(ms) {
153
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
154
+ }
155
+ /** True when this process is the LaunchAgent we are about to boot out. */
156
+ export function runningInsideRunnerLaunchAgent() {
157
+ return process.env.XPC_SERVICE_NAME === SERVICE_LABEL;
158
+ }
159
+ /**
160
+ * Boot out (ignore miss), bootstrap with retries, then enable + kickstart.
161
+ * Returns whether `launchctl print` sees the service.
162
+ */
163
+ export function reloadLaunchdRunnerSync(domain, plistPath) {
164
+ const label = `${domain}/${SERVICE_LABEL}`;
165
+ spawnSync("launchctl", ["bootout", label], { encoding: "utf8" });
166
+ for (let attempt = 0; attempt < 6; attempt++) {
167
+ const boot = spawnSync("launchctl", ["bootstrap", domain, plistPath], { encoding: "utf8" });
168
+ if (boot.status === 0 || launchdServiceLoaded(domain))
169
+ break;
170
+ spawnSync("launchctl", ["bootout", label], { encoding: "utf8" });
171
+ // Brief backoff — EIO (5) is often transient right after bootout.
172
+ const start = Date.now();
173
+ while (Date.now() - start < 400 * (attempt + 1)) {
174
+ /* spin */
175
+ }
176
+ }
177
+ spawnSync("launchctl", ["enable", label], { encoding: "utf8" });
178
+ spawnSync("launchctl", ["kickstart", "-k", label], { encoding: "utf8" });
179
+ return launchdServiceLoaded(domain);
136
180
  }
137
181
  export async function installRunnerService(options = {}) {
138
182
  const host = platform();
@@ -145,16 +189,25 @@ export async function installRunnerService(options = {}) {
145
189
  await mkdir(dirname(plistPath), { recursive: true });
146
190
  await mkdir(dirname(logPath()), { recursive: true });
147
191
  await writeFile(plistPath, launchdPlist(programArguments, logPath()), { mode: 0o644 });
148
- // Detached helper (bootout → bootstrap → load fallback): see launchdSwapCommand for why the
149
- // swap must survive this process dying at bootout.
150
192
  const domain = `gui/${process.getuid?.() ?? 501}`;
151
- spawn("/bin/sh", ["-c", launchdSwapCommand(domain, plistPath, logPath())], { detached: true, stdio: "ignore" }).unref();
152
- // Confirm the reloaded service when this process survives to see it (an interactive install).
153
- // A self-replacing runner dies at the helper's bootout and the helper still finishes the swap.
154
193
  let loaded = false;
155
- for (let attempt = 0; attempt < 16 && !loaded; attempt++) {
156
- await new Promise((resolveSleep) => setTimeout(resolveSleep, attempt === 0 ? 2000 : 500));
157
- loaded = spawnSync("launchctl", ["print", `${domain}/${SERVICE_LABEL}`], { encoding: "utf8" }).status === 0;
194
+ if (runningInsideRunnerLaunchAgent()) {
195
+ // Detached helper must survive bootout of this process.
196
+ spawn("/bin/sh", ["-c", launchdSwapCommand(domain, plistPath, logPath())], { detached: true, stdio: "ignore" }).unref();
197
+ for (let attempt = 0; attempt < 20 && !loaded; attempt++) {
198
+ await sleepMs(attempt === 0 ? 2000 : 500);
199
+ loaded = launchdServiceLoaded(domain);
200
+ }
201
+ }
202
+ else {
203
+ // Interactive ops install: reload in-process (avoids helper EIO flakiness).
204
+ loaded = reloadLaunchdRunnerSync(domain, plistPath);
205
+ for (let attempt = 0; attempt < 10 && !loaded; attempt++) {
206
+ await sleepMs(500);
207
+ loaded = launchdServiceLoaded(domain);
208
+ if (!loaded)
209
+ loaded = reloadLaunchdRunnerSync(domain, plistPath);
210
+ }
158
211
  }
159
212
  if (!loaded)
160
213
  throw new Error(`launchd did not load ${SERVICE_LABEL}; see ${logPath()}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraland-labs/conduit-bridge",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity agents for a Conduit organization",
5
5
  "type": "module",
6
6
  "bin": {