@multiclawcli/core 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -191,6 +191,7 @@ Responsibilities:
191
191
 
192
192
  Deliverables (save to the working directory):
193
193
  - code-review.md \u2014 score (1\u201310), blocking issues, advisory issues, conclusion (Approved / Changes Required)
194
+ - When the task includes a Delivery acceptance review contract, the final response must be only its JSON verdict, not the prose report. Approve only with no unresolved blocking issues; include file locations and fix directions in changes_required issues. The verdict is a review judgment, not proof that unexecuted tests passed.
194
195
 
195
196
  Principles: Judge the code, not the developer. A blocking issue must include the file path, the specific problem, and a fix direction. Advisory items should be actionable, not vague. If the code is solid, say so.`;
196
197
  var TASK4 = `## Project
@@ -358,12 +359,12 @@ There are three outcomes, and the first one matters most:
358
359
 
359
360
  Do not apply fixed rules \u2014 use your judgment based on the actual requirement and the code. A minor bug fix may need only a developer and code-reviewer; a complex new feature may need the full pipeline. Read the code to understand the impact before deciding.
360
361
 
361
- The console hands you the turns that came before this one. A requirement is often only interpretable next to them: "Postgres" is an answer, not a task, and the turn list is what tells you which question it answers. Read it as your own conversation \u2014 "you asked" is you. If you asked and got an answer, decide now; do not ask the same question again.
362
+ The console hands you the turns that came before this one. A requirement is often only interpretable next to them: "Postgres" is an answer, not a task, and the turn list is what tells you which question it answers. Read it as your own conversation \u2014 "you asked" is you. If you asked and got an answer, decide now; do not ask the same question again. For every "run" decision, include a complete, standalone "requirement" that combines the original task goal with all clarification answers and constraints. Never pass only the latest short answer to the team.
362
363
 
363
364
  Output ONLY a single JSON object on its own line \u2014 no prose, no markdown fences:
364
365
  {"mode": "reply", "message": "your answer"}
365
366
  {"mode": "ask", "message": "your question"}
366
- {"mode": "run", "reason": "one sentence on why this set", "run": ["agent-id-1", "agent-id-2"]}`;
367
+ {"mode": "run", "reason": "one sentence on why this set", "requirement": "the complete task and constraints", "run": ["agent-id-1", "agent-id-2"]}`;
367
368
  var TASK7 = `## Project
368
369
  {{projectName}}
369
370
 
@@ -378,7 +379,7 @@ Read the codebase to understand the current state, then decide how to handle the
378
379
  Output a single JSON object \u2014 exactly one of:
379
380
  {"mode": "reply", "message": "your answer"}
380
381
  {"mode": "ask", "message": "your question"}
381
- {"mode": "run", "reason": "one sentence on why this set", "run": ["agent-id", ...]}
382
+ {"mode": "run", "reason": "one sentence on why this set", "requirement": "the complete task and constraints", "run": ["agent-id", ...]}
382
383
  Agent IDs must come from the Available Agents list above.`;
383
384
  function leader(overrides) {
384
385
  return {
@@ -692,6 +693,215 @@ var EventBus = class {
692
693
  }
693
694
  };
694
695
 
696
+ // src/utils/abort.ts
697
+ var ABORT_ERROR_NAME = "AbortError";
698
+ function abortError(message = "Operation aborted") {
699
+ const err = new Error(message);
700
+ err.name = ABORT_ERROR_NAME;
701
+ return err;
702
+ }
703
+ function isAbortError(err) {
704
+ return err instanceof Error && err.name === ABORT_ERROR_NAME;
705
+ }
706
+
707
+ // src/orchestrator/AgentSessionManager.ts
708
+ var AgentSessionManager = class {
709
+ constructor() {
710
+ this.entries = /* @__PURE__ */ new Map();
711
+ this.closed = false;
712
+ }
713
+ async execute(runtime, task) {
714
+ if (this.closed) throw new Error("AgentSessionManager is closed");
715
+ if (this.resetting) throw new Error("AgentSessionManager is resetting");
716
+ if (task.signal?.aborted) throw abortError();
717
+ let entry3 = this.entries.get(task.agentId);
718
+ if (entry3?.busy || entry3?.session && entry3.session.state !== "idle" && entry3.session.state !== "closed") {
719
+ throw new Error(`Agent session ${task.agentId} is busy`);
720
+ }
721
+ if (entry3?.closing) await entry3.closing;
722
+ const fingerprint = JSON.stringify([
723
+ task.command,
724
+ task.args,
725
+ task.model,
726
+ task.workDir,
727
+ task.systemPrompt,
728
+ task.tools,
729
+ task.sessionId,
730
+ task.resume,
731
+ Boolean(task.onQuestion)
732
+ ]);
733
+ if (entry3 && (entry3.runtime !== runtime || entry3.fingerprint !== fingerprint || entry3.session?.state === "closed" || !runtime.openSession)) {
734
+ entry3.busy = true;
735
+ try {
736
+ await this.closeEntry(entry3);
737
+ this.entries.delete(task.agentId);
738
+ } finally {
739
+ entry3.busy = false;
740
+ }
741
+ entry3 = void 0;
742
+ if (this.closed || this.resetting) throw abortError();
743
+ }
744
+ if (task.signal?.aborted) throw abortError();
745
+ const openSession = runtime.openSession;
746
+ if (!openSession) return runtime.execute(task);
747
+ let detach;
748
+ if (!entry3) {
749
+ const startup = new AbortController();
750
+ const forwardAbort = () => startup.abort(task.signal?.reason);
751
+ task.signal?.addEventListener("abort", forwardAbort, { once: true });
752
+ detach = () => task.signal?.removeEventListener("abort", forwardAbort);
753
+ const created = {
754
+ runtime,
755
+ fingerprint,
756
+ startup,
757
+ busy: true,
758
+ onWarning: task.onWarning,
759
+ // Defer opening until the cache entry is installed, including synchronous failures.
760
+ opening: Promise.resolve().then(
761
+ () => openSession.call(runtime, {
762
+ agentId: task.agentId,
763
+ systemPrompt: task.systemPrompt,
764
+ tools: task.tools,
765
+ workDir: task.workDir,
766
+ command: task.command,
767
+ args: task.args,
768
+ model: task.model,
769
+ sessionId: task.sessionId,
770
+ resume: task.resume,
771
+ timeout: task.timeout,
772
+ questionForms: Boolean(task.onQuestion),
773
+ signal: startup.signal,
774
+ onWarning: (message) => created.onWarning?.(message)
775
+ })
776
+ ).then((session) => {
777
+ created.session = session;
778
+ return session;
779
+ })
780
+ };
781
+ entry3 = created;
782
+ this.entries.set(task.agentId, entry3);
783
+ } else {
784
+ entry3.busy = true;
785
+ entry3.onWarning = task.onWarning;
786
+ }
787
+ try {
788
+ const session = await entry3.opening;
789
+ detach?.();
790
+ detach = void 0;
791
+ if (entry3.startup.signal.aborted || this.closed || this.resetting) {
792
+ await this.closeEntry(entry3);
793
+ throw abortError();
794
+ }
795
+ if (session.state !== "idle") throw new Error(`Agent session ${task.agentId} is busy`);
796
+ return await session.prompt(task.prompt, {
797
+ signal: task.signal,
798
+ timeout: task.timeout,
799
+ onEvent: task.onEvent,
800
+ permissionMode: task.permissionMode,
801
+ onPermission: task.onPermission,
802
+ onQuestion: task.onQuestion
803
+ });
804
+ } finally {
805
+ detach?.();
806
+ entry3.onWarning = void 0;
807
+ try {
808
+ if (entry3.session?.state === "closed") {
809
+ await this.closeEntry(entry3);
810
+ if (this.entries.get(task.agentId) === entry3) this.entries.delete(task.agentId);
811
+ } else if (!entry3.session) {
812
+ if (this.entries.get(task.agentId) === entry3) this.entries.delete(task.agentId);
813
+ }
814
+ } finally {
815
+ entry3.busy = false;
816
+ }
817
+ }
818
+ }
819
+ /**
820
+ * Deliver a message into every session that is running a turn right now.
821
+ *
822
+ * Only `running` sessions are candidates, and that is the whole safety
823
+ * argument: a session between turns has nothing to inject into, and asking a
824
+ * provider to steer an idle session makes it *start* a turn that this manager
825
+ * never scheduled — one whose output belongs to no turn and which nothing
826
+ * would ever settle. Sessions that are idle are therefore not reported as
827
+ * failures but simply absent, and the caller sees an empty list.
828
+ *
829
+ * One agent failing never stops the others: each report stands alone, because
830
+ * the caller has to be able to say "architect took it, qa did not" rather than
831
+ * "steering failed".
832
+ */
833
+ async supplement(text) {
834
+ if (this.closed) return [];
835
+ const targets = [...this.entries.entries()].filter(
836
+ ([, entry3]) => entry3.session?.state === "running"
837
+ );
838
+ return await Promise.all(
839
+ targets.map(async ([agentId, entry3]) => {
840
+ const session = entry3.session;
841
+ if (!session) return { agentId, status: "idle" };
842
+ if (!session.capabilities.supplement || !session.supplement) {
843
+ return { agentId, status: "unsupported" };
844
+ }
845
+ try {
846
+ return { agentId, status: (await session.supplement(text)).status };
847
+ } catch (error) {
848
+ return {
849
+ agentId,
850
+ status: "error",
851
+ error: error instanceof Error ? error.message : String(error)
852
+ };
853
+ }
854
+ })
855
+ );
856
+ }
857
+ /** Closes all sessions and permits a fresh conversation after cleanup completes. */
858
+ reset() {
859
+ if (this.closing) return this.closing;
860
+ if (this.resetting) return this.resetting;
861
+ this.resetting = this.drain().finally(() => {
862
+ this.resetting = void 0;
863
+ });
864
+ return this.resetting;
865
+ }
866
+ /** Terminal, idempotent cleanup barrier. No new executions may start. */
867
+ close() {
868
+ if (this.closing) return this.closing;
869
+ this.closed = true;
870
+ this.closing = this.resetting ?? this.drain();
871
+ return this.closing;
872
+ }
873
+ async drain() {
874
+ const entries = [...this.entries.entries()];
875
+ for (const [, entry3] of entries) entry3.startup.abort();
876
+ const results = await Promise.allSettled(
877
+ entries.map(async ([id, entry3]) => {
878
+ await this.closeEntry(entry3);
879
+ if (this.entries.get(id) === entry3) this.entries.delete(id);
880
+ })
881
+ );
882
+ const errors = results.flatMap(
883
+ (result) => result.status === "rejected" ? [result.reason] : []
884
+ );
885
+ if (errors.length === 1) throw errors[0];
886
+ if (errors.length > 1) throw new AggregateError(errors, "Agent session cleanup failed");
887
+ }
888
+ closeEntry(entry3) {
889
+ if (!entry3.closing) {
890
+ entry3.closing = (async () => {
891
+ let session;
892
+ try {
893
+ session = await entry3.opening;
894
+ } catch (error) {
895
+ if (isAbortError(error)) return;
896
+ throw error;
897
+ }
898
+ await session.close();
899
+ })();
900
+ }
901
+ return entry3.closing;
902
+ }
903
+ };
904
+
695
905
  // src/orchestrator/Orchestrator.ts
696
906
  import * as fs4 from "fs";
697
907
  import * as path5 from "path";
@@ -967,7 +1177,14 @@ function asDecision(value) {
967
1177
  const run = record.run;
968
1178
  if (!Array.isArray(run) || !run.every((id) => typeof id === "string")) return void 0;
969
1179
  const reason = typeof record.reason === "string" ? record.reason.trim() : "";
970
- return reason ? { mode: "run", run, reason } : { mode: "run", run };
1180
+ const requirement = typeof record.requirement === "string" ? record.requirement.trim() : "";
1181
+ if ("requirement" in record && !requirement) return void 0;
1182
+ return {
1183
+ mode: "run",
1184
+ run,
1185
+ ...reason ? { reason } : {},
1186
+ ...requirement ? { requirement } : {}
1187
+ };
971
1188
  }
972
1189
  return void 0;
973
1190
  }
@@ -1064,17 +1281,6 @@ function resolveExecutable(command) {
1064
1281
  return resolveBridgeBinary(command);
1065
1282
  }
1066
1283
 
1067
- // src/utils/abort.ts
1068
- var ABORT_ERROR_NAME = "AbortError";
1069
- function abortError(message = "Operation aborted") {
1070
- const err = new Error(message);
1071
- err.name = ABORT_ERROR_NAME;
1072
- return err;
1073
- }
1074
- function isAbortError(err) {
1075
- return err instanceof Error && err.name === ABORT_ERROR_NAME;
1076
- }
1077
-
1078
1284
  // src/utils/errors.ts
1079
1285
  var PermanentError = class extends Error {
1080
1286
  constructor(message) {
@@ -1094,8 +1300,16 @@ var RuntimeBase = class {
1094
1300
  var discoveredModels = /* @__PURE__ */ new Map();
1095
1301
  var KILL_ESCALATION_MS = 3e3;
1096
1302
  var CANCEL_GRACE_MS = 2e3;
1303
+ var CLEANUP_ACK_MS = 3e3;
1304
+ var STEER_ACK_MS = 15e3;
1305
+ var DEFAULT_TIMEOUT_MS = 6e5;
1097
1306
  var DEFAULT_COMMAND2 = "opencode";
1098
1307
  var DEFAULT_ARGS = ["acp"];
1308
+ var STEER_METHOD = "_session/steering";
1309
+ function advertisesSteering(init) {
1310
+ const steering = init._meta?.steering;
1311
+ return isRecord(steering) && steering.supported === true;
1312
+ }
1099
1313
  var BASE_CAPABILITIES = {
1100
1314
  streaming: true,
1101
1315
  sessions: true,
@@ -1142,15 +1356,23 @@ var KIND_TO_TOOLS = {
1142
1356
  search: ["Grep", "Glob"],
1143
1357
  fetch: ["WebFetch"]
1144
1358
  };
1359
+ function toolPermitted(params, tools) {
1360
+ const allowed = new Set(tools.map((tool) => tool.toLowerCase()));
1361
+ const name = params.toolCall.name ?? void 0;
1362
+ return name !== void 0 ? allowed.has(name.toLowerCase()) : (KIND_TO_TOOLS[params.toolCall.kind ?? ""] ?? []).some(
1363
+ (tool) => allowed.has(tool.toLowerCase())
1364
+ );
1365
+ }
1145
1366
  function decidePermission(params, tools, sessionId) {
1146
1367
  if (sessionId !== void 0 && params.sessionId !== sessionId) {
1147
1368
  return { response: { outcome: { outcome: "cancelled" } } };
1148
1369
  }
1149
- const allowed = new Set(tools.map((tool) => tool.toLowerCase()));
1150
1370
  const name = params.toolCall.name ?? void 0;
1151
1371
  const kind = params.toolCall.kind ?? void 0;
1152
- const approved = name !== void 0 ? allowed.has(name.toLowerCase()) : (KIND_TO_TOOLS[kind ?? ""] ?? []).some((candidate) => allowed.has(candidate.toLowerCase()));
1153
- const option = approved ? params.options.find((o) => o.kind === "allow_always") ?? params.options.find((o) => o.kind === "allow_once") : params.options.find((o) => o.kind === "reject_always") ?? params.options.find((o) => o.kind === "reject_once");
1372
+ const approved = toolPermitted(params, tools);
1373
+ const option = params.options.find(
1374
+ (o) => approved ? o.kind === "allow_once" : o.kind === "reject_once"
1375
+ );
1154
1376
  const response = option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
1155
1377
  if (approved) return { response };
1156
1378
  return {
@@ -1163,6 +1385,68 @@ function decidePermission(params, tools, sessionId) {
1163
1385
  }
1164
1386
  };
1165
1387
  }
1388
+ var FORM_ANNOTATIONS = ["title", "description", "default", "_meta"];
1389
+ function knownFormKeys(value, keys) {
1390
+ return Object.keys(value).every((key) => keys.includes(key) || FORM_ANNOTATIONS.includes(key));
1391
+ }
1392
+ function validFormBound(value, integer = false) {
1393
+ return value == null || typeof value === "number" && Number.isFinite(value) && (!integer || Number.isInteger(value) && value >= 0);
1394
+ }
1395
+ function titledChoices(value) {
1396
+ return Array.isArray(value) && value.length > 0 && value.every(
1397
+ (option) => isRecord(option) && knownFormKeys(option, ["const"]) && typeof option.const === "string"
1398
+ );
1399
+ }
1400
+ function supportedQuestionSchema(schema) {
1401
+ if (!isRecord(schema) || schema.type !== void 0 && schema.type !== "object" || !knownFormKeys(schema, ["type", "properties", "required"]))
1402
+ return false;
1403
+ const properties = schema.properties ?? {};
1404
+ if (!isRecord(properties)) return false;
1405
+ if (schema.required != null && (!Array.isArray(schema.required) || !schema.required.every(
1406
+ (key) => typeof key === "string" && Object.getOwnPropertyDescriptor(properties, key) !== void 0
1407
+ )))
1408
+ return false;
1409
+ return Object.values(properties).every((field) => {
1410
+ if (!isRecord(field)) return false;
1411
+ if (field.type === "string") {
1412
+ return knownFormKeys(field, ["type", "enum", "oneOf", "minLength", "maxLength"]) && validFormBound(field.minLength, true) && validFormBound(field.maxLength, true) && (field.enum == null || Array.isArray(field.enum) && field.enum.length > 0 && field.enum.every((value) => typeof value === "string")) && (field.oneOf == null || titledChoices(field.oneOf));
1413
+ }
1414
+ if (field.type === "boolean") return knownFormKeys(field, ["type"]);
1415
+ if (field.type === "number" || field.type === "integer") {
1416
+ return knownFormKeys(field, ["type", "minimum", "maximum"]) && validFormBound(field.minimum) && validFormBound(field.maximum);
1417
+ }
1418
+ if (field.type !== "array" || !isRecord(field.items) || !knownFormKeys(field, ["type", "items", "minItems", "maxItems"]) || !validFormBound(field.minItems, true) || !validFormBound(field.maxItems, true))
1419
+ return false;
1420
+ return knownFormKeys(field.items, ["type", "enum"]) && field.items.type === "string" && Array.isArray(field.items.enum) && field.items.enum.length > 0 && field.items.enum.every((value) => typeof value === "string") || knownFormKeys(field.items, ["anyOf"]) && field.items.type === void 0 && titledChoices(field.items.anyOf);
1421
+ });
1422
+ }
1423
+ function validateRuntimeQuestionContent(schema, content) {
1424
+ if (!supportedQuestionSchema(schema) || !isRecord(content)) return false;
1425
+ if (Object.getOwnPropertySymbols(content).length > 0 || Object.getOwnPropertyNames(content).some((key) => {
1426
+ const descriptor = Object.getOwnPropertyDescriptor(content, key);
1427
+ return !descriptor?.enumerable || !("value" in descriptor);
1428
+ }))
1429
+ return false;
1430
+ const properties = schema.properties ?? {};
1431
+ if ((schema.required ?? []).some(
1432
+ (key) => Object.getOwnPropertyDescriptor(content, key) === void 0
1433
+ ))
1434
+ return false;
1435
+ return Object.entries(content).every(([key, value]) => {
1436
+ if (Object.getOwnPropertyDescriptor(properties, key) === void 0) return false;
1437
+ const field = properties[key];
1438
+ if (field.type === "boolean") return typeof value === "boolean";
1439
+ if (field.type === "number" || field.type === "integer") {
1440
+ return typeof value === "number" && Number.isFinite(value) && (field.type !== "integer" || Number.isInteger(value)) && (typeof field.minimum !== "number" || value >= field.minimum) && (typeof field.maximum !== "number" || value <= field.maximum);
1441
+ }
1442
+ if (field.type === "string") {
1443
+ return typeof value === "string" && (typeof field.minLength !== "number" || [...value].length >= field.minLength) && (typeof field.maxLength !== "number" || [...value].length <= field.maxLength) && (!Array.isArray(field.enum) || field.enum.includes(value)) && (!Array.isArray(field.oneOf) || field.oneOf.some((option) => isRecord(option) && option.const === value));
1444
+ }
1445
+ const items = field.items;
1446
+ const choices = Array.isArray(items.enum) ? items.enum : items.anyOf.map((option) => option.const);
1447
+ return Array.isArray(value) && value.every((item) => typeof item === "string" && choices.includes(item)) && new Set(value).size === value.length && (typeof field.minItems !== "number" || value.length >= field.minItems) && (typeof field.maxItems !== "number" || value.length <= field.maxItems);
1448
+ });
1449
+ }
1166
1450
  function withAuthHint(acp, err) {
1167
1451
  const error = err instanceof Error ? err : new Error(String(err));
1168
1452
  if (error instanceof acp.RequestError && error.code === acp.RequestError.authRequired().code) {
@@ -1173,6 +1457,13 @@ function withAuthHint(acp, err) {
1173
1457
  function errorMessage(err) {
1174
1458
  return err instanceof Error ? err.message : String(err);
1175
1459
  }
1460
+ function timeoutMs(value) {
1461
+ const timeout = value ?? DEFAULT_TIMEOUT_MS;
1462
+ if (!Number.isFinite(timeout) || timeout < 0 || timeout > 2147483647) {
1463
+ throw new PermanentError("ACP timeout must be a finite non-negative timer duration");
1464
+ }
1465
+ return timeout;
1466
+ }
1176
1467
  var AcpRuntime = class extends RuntimeBase {
1177
1468
  constructor() {
1178
1469
  super(...arguments);
@@ -1183,10 +1474,6 @@ var AcpRuntime = class extends RuntimeBase {
1183
1474
  return {
1184
1475
  name: this.name,
1185
1476
  available: resolveExecutable(command) !== null,
1186
- // `tools` is the one capability that depends on the bridge: only some of
1187
- // them read an allowlist out of `_meta`, and none negotiates it. Claiming
1188
- // it for a bridge that ignores `task.tools` would let a caller believe a
1189
- // restriction landed when the agent is running unrestricted.
1190
1477
  capabilities: {
1191
1478
  ...BASE_CAPABILITIES,
1192
1479
  tools: specForCommand(command)?.tools ?? false,
@@ -1195,126 +1482,216 @@ var AcpRuntime = class extends RuntimeBase {
1195
1482
  };
1196
1483
  }
1197
1484
  async execute(task) {
1198
- const acp = await import("@agentclientprotocol/sdk");
1199
- const command = task.command ?? DEFAULT_COMMAND2;
1200
- const args = task.args ?? DEFAULT_ARGS;
1201
- const exe = resolveExecutable(command) ?? command;
1202
- const spec = specForCommand(command);
1203
- const meta = spec?.meta?.(task);
1204
- const promptText = meta === void 0 && task.systemPrompt ? `${task.systemPrompt}
1205
-
1206
- ---
1207
-
1208
- ${task.prompt}` : task.prompt;
1209
- return new Promise((resolve4, reject) => {
1210
- const child = spawn(exe, args, {
1211
- cwd: task.workDir,
1485
+ const { prompt, onEvent, signal, permissionMode, onPermission, onQuestion, ...options } = task;
1486
+ const startup = new AbortController();
1487
+ const forward = () => startup.abort();
1488
+ if (signal?.aborted) startup.abort();
1489
+ signal?.addEventListener("abort", forward, { once: true });
1490
+ let session;
1491
+ try {
1492
+ session = await this.openSession({
1493
+ ...options,
1494
+ questionForms: Boolean(onQuestion),
1495
+ signal: startup.signal
1496
+ });
1497
+ } finally {
1498
+ signal?.removeEventListener("abort", forward);
1499
+ }
1500
+ try {
1501
+ return await session.prompt(prompt, {
1502
+ signal,
1503
+ timeout: task.timeout,
1504
+ onEvent,
1505
+ permissionMode,
1506
+ onPermission,
1507
+ onQuestion
1508
+ });
1509
+ } finally {
1510
+ await session.close();
1511
+ }
1512
+ }
1513
+ async openSession(options) {
1514
+ if (options.signal?.aborted) throw abortError();
1515
+ if (options.resume === "fork") {
1516
+ throw new PermanentError("ACP does not support forked sessions");
1517
+ }
1518
+ const defaultTimeout = timeoutMs(options.timeout);
1519
+ const questionForms = options.questionForms === true;
1520
+ let startupReject;
1521
+ const startupFailure = new Promise((_, reject) => {
1522
+ startupReject = reject;
1523
+ });
1524
+ let failSession;
1525
+ const failStartup = (error) => {
1526
+ startupReject(error);
1527
+ failSession?.(error);
1528
+ };
1529
+ const onSessionAbort = () => failStartup(abortError());
1530
+ options.signal?.addEventListener("abort", onSessionAbort, { once: true });
1531
+ const startupTimer = setTimeout(
1532
+ () => failStartup(new Error(`acp startup timed out (${defaultTimeout}ms)`)),
1533
+ defaultTimeout
1534
+ );
1535
+ let cleanup;
1536
+ try {
1537
+ const acp = await Promise.race([import("@agentclientprotocol/sdk"), startupFailure]);
1538
+ if (options.signal?.aborted) throw abortError();
1539
+ const command = options.command ?? DEFAULT_COMMAND2;
1540
+ const spec = specForCommand(command);
1541
+ const meta = spec?.meta?.({ ...options, prompt: "", timeout: defaultTimeout });
1542
+ const child = spawn(resolveExecutable(command) ?? command, options.args ?? DEFAULT_ARGS, {
1543
+ cwd: options.workDir,
1212
1544
  env: process.env,
1213
- // Own process group, so a kill reaches whatever the agent spawned too.
1214
1545
  detached: true
1215
1546
  });
1216
- const signalChild = (signal) => {
1217
- try {
1218
- child.kill(signal);
1219
- } catch {
1220
- }
1221
- };
1547
+ let state = "idle";
1548
+ let sessionId = "";
1549
+ let steeringSupported = false;
1550
+ let active;
1551
+ let childClosed = false;
1552
+ let terminalError;
1553
+ let errorOutput = "";
1554
+ let escalation;
1555
+ let acknowledgement;
1556
+ let closePromise;
1557
+ let closeResolve;
1558
+ let closeReject;
1559
+ let connection;
1222
1560
  const killTree = (signal) => {
1223
- if (child.pid === void 0) {
1224
- signalChild(signal);
1225
- return;
1561
+ if (child.pid !== void 0) {
1562
+ try {
1563
+ process.kill(-child.pid, signal);
1564
+ return;
1565
+ } catch {
1566
+ }
1226
1567
  }
1227
1568
  try {
1228
- process.kill(-child.pid, signal);
1569
+ child.kill(signal);
1229
1570
  } catch {
1230
- signalChild(signal);
1231
1571
  }
1232
1572
  };
1233
- let stopped = false;
1234
- let escalation;
1235
- const stop = (signal) => {
1236
- if (stopped) return;
1237
- stopped = true;
1238
- killTree(signal);
1239
- escalation = setTimeout(() => killTree("SIGKILL"), KILL_ESCALATION_MS);
1240
- escalation.unref();
1241
- };
1242
- let timedOut = false;
1243
- const timer = task.timeout === void 0 ? void 0 : setTimeout(() => {
1244
- timedOut = true;
1245
- stop("SIGTERM");
1246
- }, task.timeout);
1247
- let cancelGrace;
1248
- const clearTimers = () => {
1249
- if (timer) clearTimeout(timer);
1250
- if (escalation) clearTimeout(escalation);
1251
- if (cancelGrace) clearTimeout(cancelGrace);
1573
+ const emitForTurn = (turn, event) => {
1574
+ turn.options.onEvent?.({
1575
+ ...event,
1576
+ runId: turn.runId,
1577
+ agentId: options.agentId,
1578
+ seq: turn.seq++
1579
+ });
1252
1580
  };
1253
- const runId = randomUUID();
1254
- let seq = 0;
1255
1581
  const emit = (event) => {
1256
- task.onEvent?.({ ...event, runId, agentId: task.agentId, seq: seq++ });
1582
+ if (active) emitForTurn(active, event);
1257
1583
  };
1258
- const warn = (message) => {
1259
- if (task.onWarning) {
1260
- task.onWarning(message);
1261
- return;
1262
- }
1263
- process.stderr.write(`[multiclaw] Warning: ${message}
1264
- `);
1584
+ const settleTurn = (turn, output, error) => {
1585
+ if (active !== turn) return;
1586
+ turn.decisions.abort();
1587
+ if (turn.timer) clearTimeout(turn.timer);
1588
+ if (turn.cancelTimer) clearTimeout(turn.cancelTimer);
1589
+ if (turn.onAbort) turn.options.signal?.removeEventListener("abort", turn.onAbort);
1590
+ active = void 0;
1591
+ if (state === "running" || state === "cancelling") state = "idle";
1592
+ if (error) {
1593
+ if (closePromise) {
1594
+ void closePromise.then(
1595
+ () => turn.reject(error),
1596
+ (cleanupError) => turn.reject(cleanupError)
1597
+ );
1598
+ } else turn.reject(error);
1599
+ } else if (output) turn.resolve(output);
1265
1600
  };
1266
- let sessionId;
1267
- let agentText = "";
1268
- let errorOutput = "";
1269
- let connectionError;
1270
- let settled = false;
1271
- let lastUsage;
1272
- let lastCost;
1273
- const settleResolve = (output) => {
1274
- if (settled) return;
1275
- settled = true;
1276
- clearTimers();
1277
- resolve4(output);
1601
+ const clearCleanupTimers = () => {
1602
+ if (escalation) clearTimeout(escalation);
1603
+ if (acknowledgement) clearTimeout(acknowledgement);
1604
+ };
1605
+ const close = () => {
1606
+ if (closePromise) return closePromise;
1607
+ closePromise = new Promise((resolve4, reject) => {
1608
+ closeResolve = resolve4;
1609
+ closeReject = reject;
1610
+ });
1611
+ options.signal?.removeEventListener("abort", onSessionAbort);
1612
+ clearTimeout(startupTimer);
1613
+ state = "closing";
1614
+ if (active) settleTurn(active, void 0, active.error ?? terminalError ?? abortError());
1615
+ if (childClosed) {
1616
+ state = "closed";
1617
+ closeResolve?.();
1618
+ return closePromise;
1619
+ }
1620
+ escalation = setTimeout(() => {
1621
+ killTree("SIGKILL");
1622
+ if (childClosed) return;
1623
+ acknowledgement = setTimeout(() => {
1624
+ state = "closed";
1625
+ clearCleanupTimers();
1626
+ closeReject?.(new Error(`${command} cleanup failed: no child close after SIGKILL`));
1627
+ }, CLEANUP_ACK_MS);
1628
+ }, KILL_ESCALATION_MS);
1629
+ try {
1630
+ connection?.close();
1631
+ } catch {
1632
+ }
1633
+ killTree("SIGTERM");
1634
+ return closePromise;
1278
1635
  };
1279
- const settleReject = (error) => {
1280
- if (settled) return;
1281
- settled = true;
1282
- clearTimers();
1283
- reject(error);
1636
+ const fail = (error) => {
1637
+ terminalError ??= error;
1638
+ startupReject(terminalError);
1639
+ void close().catch(() => {
1640
+ });
1284
1641
  };
1642
+ failSession = fail;
1643
+ cleanup = close;
1644
+ const stderrDecoder = new StringDecoder("utf8");
1645
+ child.stderr.on("data", (data) => {
1646
+ errorOutput += stderrDecoder.write(data);
1647
+ });
1648
+ child.on("close", (code) => {
1649
+ childClosed = true;
1650
+ errorOutput += stderrDecoder.end();
1651
+ clearCleanupTimers();
1652
+ clearTimeout(startupTimer);
1653
+ options.signal?.removeEventListener("abort", onSessionAbort);
1654
+ if (!closePromise) {
1655
+ fail(new Error(`${command} exited with code ${code}: ${errorOutput}`));
1656
+ }
1657
+ state = "closed";
1658
+ try {
1659
+ connection?.close();
1660
+ } catch {
1661
+ }
1662
+ closeResolve?.();
1663
+ });
1664
+ child.on("error", (error) => {
1665
+ fail(new PermanentError(`Failed to start ${command}: ${error.message}`));
1666
+ });
1285
1667
  const handleUpdate = (notification) => {
1286
- if (sessionId && notification.sessionId !== sessionId) return;
1668
+ const turn = active;
1669
+ if (!turn || notification.sessionId !== sessionId) return;
1287
1670
  const update = notification.update;
1288
1671
  switch (update.sessionUpdate) {
1289
1672
  case "agent_message_chunk": {
1290
1673
  const text = textOf(update.content);
1291
- if (text === void 0) {
1292
- emit({ type: "raw", vendor: "acp", payload: update });
1293
- break;
1674
+ if (text === void 0) emit({ type: "raw", vendor: "acp", payload: update });
1675
+ else {
1676
+ turn.output += text;
1677
+ emit({ type: "message_delta", role: "agent", text });
1294
1678
  }
1295
- agentText += text;
1296
- emit({ type: "message_delta", role: "agent", text });
1297
- break;
1298
- }
1299
- case "user_message_chunk": {
1300
- const text = textOf(update.content);
1301
- if (text === void 0) {
1302
- emit({ type: "raw", vendor: "acp", payload: update });
1303
- break;
1304
- }
1305
- emit({ type: "message_delta", role: "user", text });
1306
1679
  break;
1307
1680
  }
1681
+ case "user_message_chunk":
1308
1682
  case "agent_thought_chunk": {
1309
1683
  const text = textOf(update.content);
1310
- if (text === void 0) {
1311
- emit({ type: "raw", vendor: "acp", payload: update });
1312
- break;
1684
+ if (text === void 0) emit({ type: "raw", vendor: "acp", payload: update });
1685
+ else {
1686
+ emit({
1687
+ type: "message_delta",
1688
+ role: update.sessionUpdate === "user_message_chunk" ? "user" : "thought",
1689
+ text
1690
+ });
1313
1691
  }
1314
- emit({ type: "message_delta", role: "thought", text });
1315
1692
  break;
1316
1693
  }
1317
- case "tool_call": {
1694
+ case "tool_call":
1318
1695
  emit({
1319
1696
  type: "tool_call",
1320
1697
  id: update.toolCallId,
@@ -1322,8 +1699,7 @@ ${task.prompt}` : task.prompt;
1322
1699
  input: update.rawInput
1323
1700
  });
1324
1701
  break;
1325
- }
1326
- case "tool_call_update": {
1702
+ case "tool_call_update":
1327
1703
  emit({
1328
1704
  type: "tool_result",
1329
1705
  id: update.toolCallId,
@@ -1331,29 +1707,21 @@ ${task.prompt}` : task.prompt;
1331
1707
  isError: update.status === "failed"
1332
1708
  });
1333
1709
  break;
1334
- }
1335
- case "plan": {
1710
+ case "plan":
1336
1711
  emit({ type: "plan", entries: update.entries.map((entry3) => entry3.content) });
1337
1712
  break;
1338
- }
1339
1713
  case "usage_update": {
1340
- const contextTokens = typeof update.used === "number" ? update.used : void 0;
1341
- const contextSize = typeof update.size === "number" ? update.size : void 0;
1342
- if (contextTokens !== void 0 && contextSize !== void 0) {
1343
- lastUsage = { contextTokens, contextSize };
1344
- emit({ type: "usage", contextTokens, contextSize });
1714
+ if (typeof update.used === "number" && typeof update.size === "number") {
1715
+ turn.usage = { contextTokens: update.used, contextSize: update.size };
1716
+ emit({ type: "usage", ...turn.usage });
1345
1717
  }
1346
1718
  const cost = isRecord(update.cost) ? update.cost : void 0;
1347
- const amount = typeof cost?.amount === "number" ? cost.amount : void 0;
1348
- const currency = typeof cost?.currency === "string" ? cost.currency : void 0;
1349
- if (amount !== void 0 && currency !== void 0) {
1350
- lastCost = { amount, currency };
1351
- const costUsd = currency.toLowerCase() === "usd" ? amount : void 0;
1719
+ if (typeof cost?.amount === "number" && typeof cost.currency === "string") {
1720
+ turn.cost = { amount: cost.amount, currency: cost.currency };
1352
1721
  emit({
1353
1722
  type: "cost",
1354
- amount,
1355
- currency,
1356
- ...costUsd !== void 0 ? { costUsd } : {}
1723
+ ...turn.cost,
1724
+ ...cost.currency.toLowerCase() === "usd" ? { costUsd: cost.amount } : {}
1357
1725
  });
1358
1726
  }
1359
1727
  break;
@@ -1363,154 +1731,372 @@ ${task.prompt}` : task.prompt;
1363
1731
  }
1364
1732
  };
1365
1733
  const app = acp.client({ name: "multiclaw" });
1366
- app.onNotification(acp.methods.client.session.update, (nctx) => {
1367
- handleUpdate(nctx.params);
1734
+ app.onNotification(acp.methods.client.session.update, (context) => {
1735
+ handleUpdate(context.params);
1368
1736
  });
1369
- app.onRequest(acp.methods.client.session.requestPermission, (rctx) => {
1370
- const { response, rejected } = decidePermission(rctx.params, task.tools, sessionId);
1737
+ app.onRequest(acp.methods.client.session.requestPermission, async (context) => {
1738
+ const cancelled = { outcome: { outcome: "cancelled" } };
1739
+ const turn = active;
1740
+ const params = context.params;
1741
+ if (state !== "running" || !turn || params.sessionId !== sessionId || context.signal.aborted)
1742
+ return cancelled;
1743
+ const mode = turn.options.permissionMode ?? "auto";
1744
+ const { response, rejected } = decidePermission(params, options.tools, sessionId);
1371
1745
  if (rejected) {
1746
+ warn("Tool permission denied by the configured tools policy.");
1372
1747
  emit({
1373
1748
  type: "raw",
1374
1749
  vendor: "acp",
1375
1750
  payload: { type: "permission_rejected", ...rejected }
1376
1751
  });
1752
+ return response;
1753
+ }
1754
+ if (mode === "auto") return response;
1755
+ if (mode !== "ask" || !turn.options.onPermission) {
1756
+ warn(
1757
+ mode === "ask" ? "Tool permission cancelled: no approval handler is available." : "Tool permission denied by the configured approval mode."
1758
+ );
1759
+ return cancelled;
1760
+ }
1761
+ const signal = AbortSignal.any([turn.decisions.signal, context.signal]);
1762
+ const offered = new Set(params.options.map((option) => option.optionId));
1763
+ const request = {
1764
+ requestId: randomUUID(),
1765
+ runId: turn.runId,
1766
+ agentId: options.agentId,
1767
+ seq: turn.seq++,
1768
+ sessionId,
1769
+ toolCallId: params.toolCall.toolCallId,
1770
+ title: params.toolCall.title ?? params.toolCall.name ?? "Tool permission",
1771
+ toolName: params.toolCall.name ?? void 0,
1772
+ kind: params.toolCall.kind ?? void 0,
1773
+ input: params.toolCall.rawInput,
1774
+ options: params.options.map(({ optionId, name, kind }) => ({ optionId, name, kind }))
1775
+ };
1776
+ let onAbort;
1777
+ const aborted = new Promise((resolve4) => {
1778
+ onAbort = () => resolve4(void 0);
1779
+ signal.addEventListener("abort", onAbort, { once: true });
1780
+ if (signal.aborted) onAbort();
1781
+ });
1782
+ try {
1783
+ const optionId = await Promise.race([
1784
+ Promise.resolve().then(
1785
+ () => signal.aborted ? void 0 : turn.options.onPermission?.(request, signal)
1786
+ ),
1787
+ aborted
1788
+ ]);
1789
+ if (signal.aborted || active !== turn || state !== "running") return cancelled;
1790
+ return optionId !== void 0 && offered.has(optionId) ? { outcome: { outcome: "selected", optionId } } : cancelled;
1791
+ } catch {
1792
+ return cancelled;
1793
+ } finally {
1794
+ signal.removeEventListener("abort", onAbort);
1377
1795
  }
1378
- return response;
1379
- });
1380
- const stderrDecoder = new StringDecoder("utf8");
1381
- child.stderr.on("data", (d) => {
1382
- errorOutput += stderrDecoder.write(d);
1383
1796
  });
1384
- const stream = acp.ndJsonStream(
1385
- Writable.toWeb(child.stdin),
1386
- Readable.toWeb(child.stdout)
1387
- );
1388
- const connection = app.connect(stream);
1389
- const ctx = connection.agent;
1390
- const teardown = () => {
1797
+ app.onRequest(acp.methods.client.elicitation.create, async (context) => {
1798
+ const cancelled = {
1799
+ action: "cancel"
1800
+ };
1801
+ const turn = active;
1802
+ const params = context.params;
1803
+ if (state !== "running" || !turn || context.signal.aborted || !("sessionId" in params) || params.sessionId !== sessionId)
1804
+ return cancelled;
1805
+ if (params.mode !== "form" || !supportedQuestionSchema(params.requestedSchema)) {
1806
+ warn("Structured question cancelled: unsupported form mode or schema.");
1807
+ return cancelled;
1808
+ }
1809
+ if (!questionForms || !turn.options.onQuestion) {
1810
+ warn("Structured question cancelled: no negotiated question handler is available.");
1811
+ return cancelled;
1812
+ }
1813
+ const schema = structuredClone(params.requestedSchema);
1814
+ const signal = AbortSignal.any([turn.decisions.signal, context.signal]);
1815
+ const request = {
1816
+ requestId: randomUUID(),
1817
+ runId: turn.runId,
1818
+ agentId: options.agentId,
1819
+ seq: turn.seq++,
1820
+ sessionId,
1821
+ toolCallId: "toolCallId" in params && typeof params.toolCallId === "string" ? params.toolCallId : void 0,
1822
+ message: params.message,
1823
+ requestedSchema: structuredClone(schema)
1824
+ };
1825
+ let onAbort;
1826
+ const aborted = new Promise((resolve4) => {
1827
+ onAbort = () => resolve4(void 0);
1828
+ signal.addEventListener("abort", onAbort, { once: true });
1829
+ if (signal.aborted) onAbort();
1830
+ });
1391
1831
  try {
1392
- connection.close();
1832
+ const response = await Promise.race([
1833
+ Promise.resolve().then(
1834
+ () => signal.aborted ? void 0 : turn.options.onQuestion?.(request, signal)
1835
+ ),
1836
+ aborted
1837
+ ]);
1838
+ if (signal.aborted || active !== turn || state !== "running" || !response)
1839
+ return cancelled;
1840
+ if (response.action === "accept") {
1841
+ if (!validateRuntimeQuestionContent(schema, response.content)) {
1842
+ warn("Structured question cancelled: the answer does not match the requested schema.");
1843
+ return cancelled;
1844
+ }
1845
+ return { action: "accept", content: structuredClone(response.content) };
1846
+ }
1847
+ return response.action === "decline" ? { action: "decline" } : cancelled;
1393
1848
  } catch {
1849
+ return cancelled;
1850
+ } finally {
1851
+ signal.removeEventListener("abort", onAbort);
1394
1852
  }
1395
- stop("SIGTERM");
1853
+ });
1854
+ connection = app.connect(
1855
+ acp.ndJsonStream(
1856
+ Writable.toWeb(child.stdin),
1857
+ Readable.toWeb(child.stdout)
1858
+ )
1859
+ );
1860
+ const ctx = connection.agent;
1861
+ const onConnectionClosed = () => {
1862
+ if (!closePromise) fail(new Error(`${command} ACP connection closed: ${errorOutput}`));
1396
1863
  };
1397
- task.signal?.addEventListener(
1398
- "abort",
1399
- () => {
1400
- if (sessionId) {
1401
- void ctx.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => {
1864
+ connection.closed?.then(onConnectionClosed, onConnectionClosed);
1865
+ const cancelTurn = () => {
1866
+ const turn = active;
1867
+ if (!turn) return closePromise ?? Promise.resolve();
1868
+ if (turn.cancellation) return turn.cancellation;
1869
+ state = "cancelling";
1870
+ turn.decisions.abort();
1871
+ const waitForCleanup = async () => {
1872
+ if (closePromise) await closePromise;
1873
+ };
1874
+ turn.cancellation = turn.promise.then(waitForCleanup, waitForCleanup);
1875
+ turn.cancelTimer = setTimeout(() => {
1876
+ fail(turn.error ?? abortError());
1877
+ }, CANCEL_GRACE_MS);
1878
+ void Promise.resolve().then(() => {
1879
+ if (active === turn) return ctx.notify(acp.methods.agent.session.cancel, { sessionId });
1880
+ }).catch((error) => {
1881
+ if (active === turn) fail(withAuthHint(acp, error));
1882
+ });
1883
+ return turn.cancellation;
1884
+ };
1885
+ const supplement = async (text, supplementOptions = {}) => {
1886
+ if (!steeringSupported) {
1887
+ throw new Error(`${command} does not accept mid-turn input`);
1888
+ }
1889
+ const turn = active;
1890
+ if (!turn || state !== "running") return { status: "idle" };
1891
+ if (supplementOptions.signal?.aborted) throw abortError();
1892
+ let timer;
1893
+ try {
1894
+ const response = await Promise.race([
1895
+ ctx.request(STEER_METHOD, {
1896
+ sessionId,
1897
+ prompt: [{ type: "text", text }],
1898
+ _meta: { steering: { idleBehavior: "promptRequired" } }
1899
+ }),
1900
+ new Promise((_, reject) => {
1901
+ timer = setTimeout(
1902
+ () => reject(new Error(`steering was not acknowledged within ${STEER_ACK_MS}ms`)),
1903
+ STEER_ACK_MS
1904
+ );
1905
+ })
1906
+ ]);
1907
+ if (active !== turn) return { status: "idle" };
1908
+ const outcome = response?.outcome;
1909
+ if (outcome === "injected") return { status: "injected" };
1910
+ if (outcome === "promptRequired") return { status: "idle" };
1911
+ if (outcome === "startedNewTurn") {
1912
+ await Promise.resolve().then(() => ctx.notify(acp.methods.agent.session.cancel, { sessionId })).catch(() => {
1402
1913
  });
1914
+ return { status: "idle" };
1403
1915
  }
1404
- cancelGrace = setTimeout(() => stop("SIGTERM"), CANCEL_GRACE_MS);
1405
- cancelGrace.unref();
1406
- },
1407
- { once: true }
1408
- );
1409
- child.on("close", (code) => {
1410
- errorOutput += stderrDecoder.end();
1411
- if (settled) return;
1412
- if (task.signal?.aborted) {
1413
- settleReject(abortError());
1414
- return;
1916
+ if (outcome === "failed") {
1917
+ throw new Error("the agent could not apply the mid-turn message");
1918
+ }
1919
+ throw new Error(`unexpected steering outcome: ${JSON.stringify(outcome ?? null)}`);
1920
+ } finally {
1921
+ if (timer) clearTimeout(timer);
1415
1922
  }
1416
- if (timedOut) {
1417
- settleReject(new Error(`acp timed out (${task.timeout}ms)`));
1418
- return;
1923
+ };
1924
+ const prompt = (text, promptOptions = {}) => {
1925
+ if (state !== "idle") {
1926
+ return Promise.reject(new Error(`Cannot prompt ACP session while ${state}`));
1419
1927
  }
1420
- if (connectionError) {
1421
- settleReject(connectionError);
1422
- return;
1928
+ if (options.signal?.aborted || promptOptions.signal?.aborted) {
1929
+ return Promise.reject(abortError());
1423
1930
  }
1424
- settleReject(new Error(`${command} exited with code ${code}: ${errorOutput}`));
1425
- });
1426
- child.on("error", (err) => {
1427
- settleReject(new PermanentError(`Failed to start ${command}: ${err.message}`));
1428
- });
1429
- const run = async () => {
1931
+ let duration;
1932
+ try {
1933
+ duration = timeoutMs(promptOptions.timeout ?? defaultTimeout);
1934
+ } catch (error) {
1935
+ return Promise.reject(error);
1936
+ }
1937
+ let resolve4;
1938
+ let reject;
1939
+ const promise = new Promise((res, rej) => {
1940
+ resolve4 = res;
1941
+ reject = rej;
1942
+ });
1943
+ const turn = {
1944
+ runId: randomUUID(),
1945
+ seq: 0,
1946
+ options: promptOptions,
1947
+ output: "",
1948
+ decisions: new AbortController(),
1949
+ promise,
1950
+ resolve: resolve4,
1951
+ reject
1952
+ };
1953
+ active = turn;
1954
+ state = "running";
1955
+ turn.onAbort = () => {
1956
+ turn.error = abortError();
1957
+ void cancelTurn().catch(() => {
1958
+ });
1959
+ };
1960
+ promptOptions.signal?.addEventListener("abort", turn.onAbort, { once: true });
1961
+ turn.timer = setTimeout(() => {
1962
+ turn.error = new Error(`acp timed out (${duration}ms)`);
1963
+ fail(turn.error);
1964
+ }, duration);
1965
+ const run = async () => {
1966
+ emit({ type: "run_started", model: options.model });
1967
+ if (active !== turn) return;
1968
+ const response = await ctx.request(acp.methods.agent.session.prompt, {
1969
+ sessionId,
1970
+ prompt: [
1971
+ {
1972
+ type: "text",
1973
+ text: meta === void 0 && options.systemPrompt ? `${options.systemPrompt}
1974
+
1975
+ ---
1976
+
1977
+ ${text}` : text
1978
+ }
1979
+ ]
1980
+ });
1981
+ if (active !== turn) return;
1982
+ if (turn.error) {
1983
+ settleTurn(turn, void 0, turn.error);
1984
+ return;
1985
+ }
1986
+ const stopReason = mapStopReason(response.stopReason);
1987
+ settleTurn(turn, {
1988
+ output: turn.output,
1989
+ exitCode: stopReason === "success" ? 0 : 1,
1990
+ sessionId,
1991
+ stopReason,
1992
+ ...turn.usage ? { usage: turn.usage } : {},
1993
+ ...turn.cost?.currency.toLowerCase() === "usd" ? { costUsd: turn.cost.amount } : {}
1994
+ });
1995
+ emitForTurn(turn, { type: "run_finished", stopReason });
1996
+ };
1997
+ void run().catch((error) => {
1998
+ if (active === turn) fail(withAuthHint(acp, error));
1999
+ });
2000
+ return promise;
2001
+ };
2002
+ const session = {
2003
+ get sessionId() {
2004
+ return sessionId;
2005
+ },
2006
+ get state() {
2007
+ return state;
2008
+ },
2009
+ get capabilities() {
2010
+ return { continuation: true, supplement: steeringSupported };
2011
+ },
2012
+ prompt,
2013
+ supplement,
2014
+ cancelTurn,
2015
+ close
2016
+ };
2017
+ const warn = (message) => {
2018
+ if (options.onWarning) options.onWarning(message);
2019
+ else process.stderr.write(`[multiclaw] Warning: ${message}
2020
+ `);
2021
+ };
2022
+ const initialize = async () => {
1430
2023
  const init = await ctx.request(acp.methods.agent.initialize, {
1431
- protocolVersion: acp.PROTOCOL_VERSION
2024
+ protocolVersion: acp.PROTOCOL_VERSION,
2025
+ ...questionForms ? { clientCapabilities: { elicitation: { form: {} } } } : {}
1432
2026
  });
1433
- emit({ type: "run_started", model: task.model });
1434
- if (task.sessionId) {
2027
+ if (terminalError) throw terminalError;
2028
+ steeringSupported = advertisesSteering(init);
2029
+ if (options.sessionId) {
1435
2030
  if (init.agentCapabilities?.loadSession !== true) {
1436
2031
  throw new PermanentError(
1437
- `Agent does not support session/load (loadSession=false); cannot resume session ${task.sessionId}`
2032
+ `Agent does not support session/load (loadSession=false); cannot resume session ${options.sessionId}`
1438
2033
  );
1439
2034
  }
1440
2035
  await ctx.request(acp.methods.agent.session.load, {
1441
- sessionId: task.sessionId,
1442
- cwd: task.workDir,
2036
+ sessionId: options.sessionId,
2037
+ cwd: options.workDir,
1443
2038
  mcpServers: [],
1444
- // Both session-defining requests carry `_meta`: the bridge's own
1445
- // session fingerprint includes it, so a changed prompt rebuilds the
1446
- // session on resume.
1447
2039
  ...meta ? { _meta: meta } : {}
1448
2040
  });
1449
- sessionId = task.sessionId;
2041
+ sessionId = options.sessionId;
1450
2042
  } else {
1451
2043
  const created = await ctx.request(acp.methods.agent.session.new, {
1452
- cwd: task.workDir,
2044
+ cwd: options.workDir,
1453
2045
  mcpServers: [],
1454
2046
  ...meta ? { _meta: meta } : {}
1455
2047
  });
1456
2048
  sessionId = created.sessionId;
1457
2049
  const modelOpt = created.configOptions?.find(
1458
- (o) => o.category === "model" || o.id === "model"
2050
+ (option) => option.category === "model" || option.id === "model"
1459
2051
  );
1460
2052
  if (modelOpt && "options" in modelOpt && Array.isArray(modelOpt.options)) {
1461
- const ids = (modelOpt.options ?? []).map(
1462
- (o) => o.value ?? String(o)
2053
+ discoveredModels.set(
2054
+ command,
2055
+ (modelOpt.options ?? []).map(
2056
+ (option) => option.value ?? String(option)
2057
+ )
1463
2058
  );
1464
- discoveredModels.set(command, ids);
1465
2059
  }
1466
2060
  }
1467
- if (task.model && spec?.modelSelection === "config-option" && sessionId) {
2061
+ if (terminalError) throw terminalError;
2062
+ if (options.model && spec?.modelSelection === "config-option") {
1468
2063
  const known = discoveredModels.get(command);
1469
- if (known && !known.includes(task.model)) {
2064
+ if (known && !known.includes(options.model)) {
1470
2065
  warn(
1471
- `model "${task.model}" is not offered by ${command}; running with the provider's default.`
2066
+ `model "${options.model}" is not offered by ${command}; running with the provider's default.`
1472
2067
  );
1473
2068
  } else {
1474
2069
  try {
1475
2070
  await ctx.request(acp.methods.agent.session.setConfigOption, {
1476
2071
  sessionId,
1477
2072
  configId: "model",
1478
- value: task.model
2073
+ value: options.model
1479
2074
  });
1480
- } catch (err) {
2075
+ } catch (error) {
2076
+ if (terminalError) throw terminalError;
1481
2077
  warn(
1482
- `Could not set model "${task.model}" on ${command}; running with the provider's default (${errorMessage(err)}).`
2078
+ `Could not set model "${options.model}" on ${command}; running with the provider's default (${errorMessage(error)}).`
1483
2079
  );
1484
2080
  }
1485
2081
  }
1486
2082
  }
1487
- const response = await ctx.request(acp.methods.agent.session.prompt, {
1488
- sessionId,
1489
- prompt: [{ type: "text", text: promptText }]
1490
- });
1491
- if (task.signal?.aborted) {
1492
- settleReject(abortError());
1493
- teardown();
1494
- return;
1495
- }
1496
- const stopReason = mapStopReason(response.stopReason);
1497
- emit({ type: "run_finished", stopReason });
1498
- settleResolve({
1499
- output: agentText,
1500
- exitCode: 0,
1501
- sessionId,
1502
- stopReason,
1503
- ...lastUsage ? { usage: lastUsage } : {},
1504
- ...lastCost?.currency.toLowerCase() === "usd" ? { costUsd: lastCost.amount } : {}
1505
- });
1506
- teardown();
2083
+ if (terminalError) throw terminalError;
2084
+ if (options.signal?.aborted) throw abortError();
1507
2085
  };
1508
- void run().catch((err) => {
1509
- connectionError = withAuthHint(acp, err);
1510
- settleReject(connectionError);
1511
- teardown();
1512
- });
1513
- });
2086
+ await Promise.race([
2087
+ initialize().catch((error) => {
2088
+ throw withAuthHint(acp, error);
2089
+ }),
2090
+ startupFailure
2091
+ ]);
2092
+ clearTimeout(startupTimer);
2093
+ return session;
2094
+ } catch (error) {
2095
+ clearTimeout(startupTimer);
2096
+ options.signal?.removeEventListener("abort", onSessionAbort);
2097
+ if (cleanup) await cleanup();
2098
+ throw error;
2099
+ }
1514
2100
  }
1515
2101
  };
1516
2102
 
@@ -1610,16 +2196,18 @@ function retryBudgetMs(perAttemptMs, retries, delayMs = DEFAULT_RETRY_DELAY_MS)
1610
2196
  }
1611
2197
 
1612
2198
  // src/utils/timeout.ts
1613
- function withTimeout(factory, ms, label, signal) {
2199
+ function withTimeout(factory, ms, label, signal, options) {
1614
2200
  if (signal?.aborted) return Promise.reject(abortError());
1615
2201
  const controller = new AbortController();
1616
2202
  const forward = () => controller.abort();
1617
2203
  signal?.addEventListener("abort", forward, { once: true });
1618
2204
  let timer;
2205
+ let timeoutError;
1619
2206
  const timeout = new Promise((_, reject) => {
1620
2207
  timer = setTimeout(() => {
2208
+ timeoutError = new Error(`[${label}] timed out (${ms / 1e3}s)`);
1621
2209
  controller.abort();
1622
- reject(new Error(`[${label}] timed out (${ms / 1e3}s)`));
2210
+ if (!options?.waitForCleanup) reject(timeoutError);
1623
2211
  }, ms);
1624
2212
  });
1625
2213
  let onCancel;
@@ -1628,7 +2216,18 @@ function withTimeout(factory, ms, label, signal) {
1628
2216
  onCancel = () => reject(abortError());
1629
2217
  signal.addEventListener("abort", onCancel, { once: true });
1630
2218
  });
1631
- return Promise.race([factory(controller.signal), timeout, cancelled]).finally(() => {
2219
+ const work = factory(controller.signal);
2220
+ const completion = options?.waitForCleanup ? work.then(
2221
+ (result) => {
2222
+ if (timeoutError) throw timeoutError;
2223
+ return result;
2224
+ },
2225
+ (error) => {
2226
+ if (timeoutError && isAbortError(error)) throw timeoutError;
2227
+ throw error;
2228
+ }
2229
+ ) : work;
2230
+ return Promise.race([completion, timeout, cancelled]).finally(() => {
1632
2231
  clearTimeout(timer);
1633
2232
  signal?.removeEventListener("abort", forward);
1634
2233
  if (onCancel) signal?.removeEventListener("abort", onCancel);
@@ -1662,13 +2261,14 @@ function summarizeToolOutput(output) {
1662
2261
 
1663
2262
  // src/orchestrator/AgentRunner.ts
1664
2263
  var AgentRunner = class {
1665
- constructor(eventBus, contextManager, globalWorkDir, config) {
2264
+ constructor(eventBus, contextManager, globalWorkDir, config, sessionManager) {
1666
2265
  this.eventBus = eventBus;
1667
2266
  this.contextManager = contextManager;
1668
2267
  this.globalWorkDir = globalWorkDir;
1669
2268
  this.config = config;
2269
+ this.sessionManager = sessionManager;
1670
2270
  }
1671
- async run(agent, signal) {
2271
+ async run(agent, signal, instruction) {
1672
2272
  const startTime = /* @__PURE__ */ new Date();
1673
2273
  const model = resolveModel(agent, this.config);
1674
2274
  this.eventBus.emit({
@@ -1697,6 +2297,7 @@ var AgentRunner = class {
1697
2297
  (innerSignal) => withRetry(
1698
2298
  async (attempt) => {
1699
2299
  attempts = attempt;
2300
+ if (signal?.aborted) throw abortError();
1700
2301
  const agentContext = {
1701
2302
  ...this.contextManager.snapshot(),
1702
2303
  // Every built-in task template references both. The leader only
@@ -1707,15 +2308,28 @@ var AgentRunner = class {
1707
2308
  agentScope: agent.agentScope ?? "Not narrowed down \u2014 cover the whole task.",
1708
2309
  agentPlan: agent.agentPlan ?? "No step-by-step plan was provided \u2014 use your judgement."
1709
2310
  };
1710
- const prompt = await renderPrompt(agent.taskPrompt, agentContext, this.globalWorkDir);
1711
- return await runtime.execute({
2311
+ let prompt = await renderPrompt(agent.taskPrompt, agentContext, this.globalWorkDir);
2312
+ const requirement = this.contextManager.get("requirement") ?? this.config.context?.requirement;
2313
+ if (agent.id !== this.config.leader?.id && requirement !== void 0) {
2314
+ prompt = `## Current task
2315
+ ${requirement}
2316
+
2317
+ ${prompt}`;
2318
+ }
2319
+ if (instruction) prompt += `
2320
+
2321
+ ${instruction}`;
2322
+ const task = {
1712
2323
  agentId: agent.id,
1713
2324
  systemPrompt: agent.systemPrompt,
1714
2325
  prompt,
1715
2326
  tools,
2327
+ permissionMode: this.config.permissions?.mode,
2328
+ onPermission: this.config.permissions?.onRequest,
2329
+ onQuestion: this.config.questions?.onRequest,
1716
2330
  workDir,
1717
2331
  timeout,
1718
- signal: innerSignal,
2332
+ signal: runtime.openSession && signal ? AbortSignal.any([innerSignal, signal]) : innerSignal,
1719
2333
  model,
1720
2334
  // Only the caller knows which session to continue; absent means a
1721
2335
  // fresh one. It must stay `undefined` rather than `""` — the ACP
@@ -1766,7 +2380,8 @@ var AgentRunner = class {
1766
2380
  );
1767
2381
  }
1768
2382
  }
1769
- });
2383
+ };
2384
+ return await (this.sessionManager ? this.sessionManager.execute(runtime, task) : runtime.execute(task));
1770
2385
  },
1771
2386
  retries,
1772
2387
  (attempt, err) => {
@@ -1783,7 +2398,7 @@ var AgentRunner = class {
1783
2398
  });
1784
2399
  },
1785
2400
  DEFAULT_RETRY_DELAY_MS,
1786
- innerSignal,
2401
+ runtime.openSession && signal ? AbortSignal.any([innerSignal, signal]) : innerSignal,
1787
2402
  // A permanent failure (a missing binary, an unsupported capability)
1788
2403
  // will fail the same way on every attempt, so let the loop fail fast
1789
2404
  // instead of burning the whole backoff budget on a doomed run.
@@ -1791,17 +2406,25 @@ var AgentRunner = class {
1791
2406
  ),
1792
2407
  retryBudgetMs(timeout + KILL_ESCALATION_MS, retries),
1793
2408
  agent.name,
1794
- signal
2409
+ // Session runtimes acknowledge cancellation only after process cleanup.
2410
+ // Neither caller cancellation nor the outer timeout may race that barrier.
2411
+ runtime.openSession ? void 0 : signal,
2412
+ { waitForCleanup: !!runtime.openSession }
1795
2413
  );
1796
2414
  output = completed.output;
1797
2415
  usage = completed.usage;
1798
2416
  costUsd = completed.costUsd;
1799
2417
  const sessionId = completed.sessionId;
2418
+ const incomplete = completed.stopReason !== void 0 && completed.stopReason !== "success";
2419
+ const cancelled = completed.stopReason === "cancelled";
2420
+ const failed = completed.exitCode !== 0 || incomplete;
2421
+ const error = failed ? `Runtime turn did not complete (${completed.stopReason ?? `exit code ${completed.exitCode}`})` : void 0;
1800
2422
  const endTime = /* @__PURE__ */ new Date();
1801
2423
  const result = {
1802
2424
  agentId: agent.id,
1803
2425
  agentName: agent.name,
1804
- status: "success",
2426
+ status: cancelled ? "cancelled" : failed ? "failed" : "success",
2427
+ ...error ? { error } : {},
1805
2428
  output,
1806
2429
  duration: endTime.getTime() - startTime.getTime(),
1807
2430
  attempts,
@@ -1813,7 +2436,7 @@ var AgentRunner = class {
1813
2436
  ...sessionId ? { sessionId } : {}
1814
2437
  };
1815
2438
  this.eventBus.emit({
1816
- type: "agent:complete",
2439
+ type: failed ? "agent:failed" : "agent:complete",
1817
2440
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1818
2441
  payload: result
1819
2442
  });
@@ -1902,7 +2525,7 @@ function buildStages(agents2) {
1902
2525
 
1903
2526
  // src/orchestrator/Orchestrator.ts
1904
2527
  var Orchestrator = class {
1905
- constructor(config) {
2528
+ constructor(config, sessions) {
1906
2529
  this.config = config;
1907
2530
  this.eventBus = new EventBus();
1908
2531
  this.workDir = path5.resolve(config.workDir);
@@ -1910,11 +2533,22 @@ var Orchestrator = class {
1910
2533
  this.contextManager = new ContextManager(config.context);
1911
2534
  this.contextManager.set("conversation", renderConversation(config.history));
1912
2535
  this.contextManager.set("upstreamOutputs", "");
1913
- this.runner = new AgentRunner(this.eventBus, this.contextManager, this.workDir, config);
2536
+ this.runner = new AgentRunner(
2537
+ this.eventBus,
2538
+ this.contextManager,
2539
+ this.workDir,
2540
+ config,
2541
+ sessions
2542
+ );
1914
2543
  }
1915
2544
  async preview(options = {}) {
2545
+ const policyError = this.reviewPolicyError();
2546
+ if (policyError) throw new Error(policyError);
1916
2547
  this.clearStaleTaskPlan();
1917
2548
  const resolution = await this.resolveTurn(options.signal);
2549
+ if (resolution.kind === "cancelled" || options.signal?.aborted) {
2550
+ return { name: this.config.name, totalAgents: 0, stages: [], ...this.requirementField() };
2551
+ }
1918
2552
  if (resolution.kind === "message") {
1919
2553
  return {
1920
2554
  name: this.config.name,
@@ -1927,10 +2561,12 @@ var Orchestrator = class {
1927
2561
  const agents2 = resolution.agents;
1928
2562
  this.contextManager.set("pipelineAgents", agents2.map((a) => `${a.id}: ${a.name}`).join("\n"));
1929
2563
  const stages = this.buildStagesOrFallback(agents2, "Could not order the configured agents");
2564
+ const reviewer2 = this.config.agents.find((agent) => agent.id === this.config.review?.reviewer);
2565
+ if (reviewer2 && agents2.length) stages.push([reviewer2]);
1930
2566
  const stageInfos = this.stageInfos(stages);
1931
2567
  return {
1932
2568
  name: this.config.name,
1933
- totalAgents: agents2.length,
2569
+ totalAgents: agents2.length + (reviewer2 && agents2.length ? 1 : 0),
1934
2570
  stages: stageInfos,
1935
2571
  ...this.reasonField(resolution.reason),
1936
2572
  ...this.requirementField()
@@ -1938,13 +2574,39 @@ var Orchestrator = class {
1938
2574
  }
1939
2575
  async run(options = {}) {
1940
2576
  const startTime = /* @__PURE__ */ new Date();
2577
+ if (options.signal?.aborted) return this.finishCancelled(startTime);
2578
+ const policyError = this.reviewPolicyError();
2579
+ if (policyError) {
2580
+ this.warn(policyError);
2581
+ const result2 = this.finishEmpty(startTime, {
2582
+ status: "failed",
2583
+ reviewer: this.config.review?.reviewer ?? "",
2584
+ repairRounds: 0,
2585
+ reviews: [],
2586
+ reason: policyError
2587
+ });
2588
+ return result2;
2589
+ }
1941
2590
  this.clearStaleTaskPlan();
1942
2591
  const resolution = await this.resolveTurn(options.signal);
2592
+ if (resolution.kind === "cancelled" || options.signal?.aborted) {
2593
+ return this.finishCancelled(startTime);
2594
+ }
1943
2595
  if (resolution.kind === "message") {
1944
2596
  return this.finishWithMessage(resolution.mode, resolution.message, startTime);
1945
2597
  }
1946
2598
  const agents2 = resolution.agents;
1947
- if (agents2.length === 0) return this.finishEmpty(startTime);
2599
+ if (agents2.length === 0)
2600
+ return this.finishEmpty(
2601
+ startTime,
2602
+ this.config.review ? {
2603
+ status: "failed",
2604
+ reviewer: this.config.review.reviewer,
2605
+ repairRounds: 0,
2606
+ reviews: [],
2607
+ reason: "No worker was selected for the review-gated task."
2608
+ } : void 0
2609
+ );
1948
2610
  this.contextManager.set("pipelineAgents", agents2.map((a) => `${a.id}: ${a.name}`).join("\n"));
1949
2611
  let stages = this.buildStagesOrFallback(agents2, "Could not order the configured agents");
1950
2612
  const toStageInfos = (list) => this.stageInfos(list);
@@ -1953,7 +2615,7 @@ var Orchestrator = class {
1953
2615
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1954
2616
  payload: {
1955
2617
  name: this.config.name,
1956
- requirement: this.config.context?.requirement,
2618
+ requirement: this.contextManager.get("requirement"),
1957
2619
  totalAgents: agents2.length,
1958
2620
  stages: toStageInfos(stages),
1959
2621
  ...this.reasonField(resolution.reason)
@@ -1963,7 +2625,13 @@ var Orchestrator = class {
1963
2625
  let allSuccess = true;
1964
2626
  let cancelled = false;
1965
2627
  let planApplied = false;
2628
+ const executedIds = /* @__PURE__ */ new Set();
1966
2629
  for (let i = 0; i < stages.length; i++) {
2630
+ if (options.signal?.aborted) {
2631
+ cancelled = true;
2632
+ allSuccess = false;
2633
+ break;
2634
+ }
1967
2635
  const stage = stages[i];
1968
2636
  this.eventBus.emit({
1969
2637
  type: "stage:start",
@@ -1972,24 +2640,32 @@ var Orchestrator = class {
1972
2640
  });
1973
2641
  const stageResults = await this.runStage(stage, options.signal);
1974
2642
  results.push(...stageResults);
2643
+ for (const result2 of stageResults) executedIds.add(result2.agentId);
1975
2644
  this.contextManager.set(
1976
2645
  "upstreamOutputs",
1977
2646
  renderUpstreamOutputs(
1978
2647
  results.filter((r) => r.status === "success").map((r) => ({ agentId: r.agentId, name: r.agentName, output: r.output }))
1979
2648
  )
1980
2649
  );
1981
- if (options.signal?.aborted) {
2650
+ if (options.signal?.aborted || stageResults.some((r) => r.status === "cancelled")) {
1982
2651
  cancelled = true;
1983
2652
  allSuccess = false;
1984
2653
  break;
1985
2654
  }
1986
- if (!planApplied && stage.some((a) => this.isPlanner(a)) && stageResults.some((r) => r.status === "success")) {
2655
+ if (!planApplied && stage.some((a) => this.isPlanner(a)) && stageResults.some(
2656
+ (r) => r.status === "success" && stage.some((a) => a.id === r.agentId && this.isPlanner(a))
2657
+ )) {
1987
2658
  planApplied = true;
1988
- await this.applyTaskPlan(agents2);
1989
- stages = this.buildStagesOrFallback(
1990
- agents2,
2659
+ await this.applyTaskPlan(agents2, executedIds);
2660
+ const remaining = agents2.filter((a) => !executedIds.has(a.id)).map((a) => ({
2661
+ ...a,
2662
+ dependsOn: a.dependsOn?.filter((dep) => !executedIds.has(dep))
2663
+ }));
2664
+ const rebuilt = this.buildStagesOrFallback(
2665
+ remaining,
1991
2666
  "The task plan's dependencies could not be satisfied"
1992
2667
  );
2668
+ stages = [...stages.slice(0, i + 1), ...rebuilt];
1993
2669
  this.eventBus.emit({
1994
2670
  type: "orchestration:stages",
1995
2671
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2020,11 +2696,25 @@ var Orchestrator = class {
2020
2696
  break;
2021
2697
  }
2022
2698
  }
2699
+ let acceptance;
2700
+ if (this.config.review) {
2701
+ acceptance = await this.reviewDelivery(
2702
+ agents2,
2703
+ results,
2704
+ stages,
2705
+ allSuccess,
2706
+ cancelled,
2707
+ options.signal
2708
+ );
2709
+ cancelled ||= acceptance.status === "cancelled";
2710
+ allSuccess = allSuccess && acceptance.status === "approved";
2711
+ }
2023
2712
  const endTime = /* @__PURE__ */ new Date();
2024
2713
  const totalCostUsd = sumCosts(results);
2025
2714
  const result = {
2026
2715
  name: this.config.name,
2027
2716
  success: allSuccess,
2717
+ ...acceptance ? { acceptance } : {},
2028
2718
  totalDuration: endTime.getTime() - startTime.getTime(),
2029
2719
  agentResults: results,
2030
2720
  startTime: startTime.toISOString(),
@@ -2041,6 +2731,137 @@ var Orchestrator = class {
2041
2731
  });
2042
2732
  return result;
2043
2733
  }
2734
+ reviewPolicyError() {
2735
+ const policy = this.config.review;
2736
+ if (!policy) return void 0;
2737
+ const ids = this.config.agents.map((agent) => agent.id);
2738
+ if (new Set(ids).size !== ids.length) return "Review policy requires unique agent ids.";
2739
+ if (!ids.includes(policy.reviewer) || policy.reviewer === this.config.leader?.id) {
2740
+ return "Review policy must name a configured non-leader reviewer.";
2741
+ }
2742
+ if (!Array.isArray(policy.repairAgents) || !policy.repairAgents.length || new Set(policy.repairAgents).size !== policy.repairAgents.length || policy.repairAgents.some(
2743
+ (id) => !ids.includes(id) || id === policy.reviewer || id === this.config.leader?.id
2744
+ )) {
2745
+ return "Review policy must name unique configured repair workers, excluding the reviewer and leader.";
2746
+ }
2747
+ const limit = policy.maxRepairRounds ?? 1;
2748
+ if (!Number.isInteger(limit) || limit < 0 || limit > 5) {
2749
+ return "Review policy maxRepairRounds must be an integer from 0 to 5.";
2750
+ }
2751
+ return void 0;
2752
+ }
2753
+ async reviewDelivery(agents2, results, stages, executionSuccess, cancelled, signal) {
2754
+ const policy = this.config.review;
2755
+ if (!policy) throw new Error("Delivery review requires a configured policy.");
2756
+ const acceptance = {
2757
+ status: "failed",
2758
+ reviewer: policy.reviewer,
2759
+ repairRounds: 0,
2760
+ reviews: []
2761
+ };
2762
+ if (cancelled || signal?.aborted) return { ...acceptance, status: "cancelled" };
2763
+ if (!executionSuccess)
2764
+ return { ...acceptance, reason: "Pipeline execution failed; delivery was not reviewed." };
2765
+ const reviewer2 = this.config.agents.find((agent) => agent.id === policy.reviewer);
2766
+ if (!reviewer2) return { ...acceptance, reason: "Configured reviewer is unavailable." };
2767
+ const repairs = agents2.filter((agent) => policy.repairAgents.includes(agent.id));
2768
+ const repairIds = new Set(repairs.map((agent) => agent.id));
2769
+ const repairStages = this.buildStagesOrFallback(
2770
+ repairs.map((agent) => ({
2771
+ ...agent,
2772
+ dependsOn: agent.dependsOn?.filter((id) => repairIds.has(id))
2773
+ })),
2774
+ "Could not order repair workers"
2775
+ ).flatMap((stage) => stage.map((agent) => [agent]));
2776
+ let stageIndex = stages.length;
2777
+ const execute = async (stage, instruction) => {
2778
+ if (signal?.aborted) {
2779
+ acceptance.status = "cancelled";
2780
+ return false;
2781
+ }
2782
+ stages.push(stage);
2783
+ this.eventBus.emit({
2784
+ type: "orchestration:stages",
2785
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2786
+ payload: {
2787
+ totalAgents: new Set(stages.flat().map((agent) => agent.id)).size,
2788
+ stages: this.stageInfos(stages)
2789
+ }
2790
+ });
2791
+ const index = stageIndex++;
2792
+ this.eventBus.emit({
2793
+ type: "stage:start",
2794
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2795
+ payload: { stageIndex: index, agentIds: stage.map((agent) => agent.id) }
2796
+ });
2797
+ for (const agent of stage) {
2798
+ if (signal?.aborted) {
2799
+ acceptance.status = "cancelled";
2800
+ return false;
2801
+ }
2802
+ const result = await this.runner.run(
2803
+ agent,
2804
+ signal,
2805
+ `${instruction}
2806
+
2807
+ ## Latest worker outputs
2808
+ ${this.contextManager.get("upstreamOutputs") ?? ""}`
2809
+ );
2810
+ results.push(result);
2811
+ const latest = /* @__PURE__ */ new Map();
2812
+ for (const item of results) latest.set(item.agentId, item);
2813
+ this.contextManager.set(
2814
+ "upstreamOutputs",
2815
+ renderUpstreamOutputs(
2816
+ [...latest.values()].filter((item) => item.status === "success" && item.agentId !== policy.reviewer).map((item) => ({ agentId: item.agentId, name: item.agentName, output: item.output }))
2817
+ )
2818
+ );
2819
+ if (result.status !== "success" || signal?.aborted) {
2820
+ acceptance.status = result.status === "cancelled" || signal?.aborted ? "cancelled" : "failed";
2821
+ acceptance.reason = result.error ?? `${agent.name} did not complete.`;
2822
+ return false;
2823
+ }
2824
+ }
2825
+ this.eventBus.emit({
2826
+ type: "stage:complete",
2827
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2828
+ payload: { stageIndex: index }
2829
+ });
2830
+ return true;
2831
+ };
2832
+ while (true) {
2833
+ if (!await execute(
2834
+ [reviewer2],
2835
+ `## Delivery acceptance review
2836
+ Inspect the current workspace against the complete task and verify the changes. Do not repair files. Return ONLY a JSON object with this exact contract:
2837
+ {"verdict":"approved"|"changes_required","summary":"non-empty explanation","issues":["actionable unresolved issue"]}
2838
+ Approval requires an empty issues array; changes_required requires at least one issue. A successful process exit is not approval.`
2839
+ ))
2840
+ return acceptance;
2841
+ const verdict = parseReviewVerdict(results[results.length - 1].output);
2842
+ if (!verdict) {
2843
+ acceptance.reason = "Reviewer returned an invalid delivery verdict; acceptance is blocked.";
2844
+ this.warn(acceptance.reason);
2845
+ return acceptance;
2846
+ }
2847
+ acceptance.reviews.push(verdict);
2848
+ if (verdict.verdict === "approved") return { ...acceptance, status: "approved" };
2849
+ acceptance.status = "changes_required";
2850
+ if (acceptance.repairRounds >= (policy.maxRepairRounds ?? 1) || !repairs.length) {
2851
+ acceptance.reason = !repairs.length ? "No configured repair worker was selected for this task." : "Repair budget exhausted.";
2852
+ return acceptance;
2853
+ }
2854
+ acceptance.repairRounds++;
2855
+ this.warn(
2856
+ `Delivery requires changes \u2014 repair pass ${acceptance.repairRounds}/${policy.maxRepairRounds ?? 1}.`
2857
+ );
2858
+ const instruction = `## Repair requested by delivery review
2859
+ ${JSON.stringify(verdict)}
2860
+ Address these findings within your assigned scope, preserve the complete task requirements, and report what you changed and verified.`;
2861
+ for (const stage of repairStages) if (!await execute(stage, instruction)) return acceptance;
2862
+ acceptance.status = "failed";
2863
+ }
2864
+ }
2044
2865
  stageInfos(stages) {
2045
2866
  const getModel = (agent) => resolveModel(agent, this.config);
2046
2867
  return stages.map((stage, i) => ({
@@ -2069,7 +2890,7 @@ var Orchestrator = class {
2069
2890
  return buildStages(agents2);
2070
2891
  } catch (err) {
2071
2892
  this.warn(`${context} (${errorMessage2(err)}) \u2014 running the agents in declaration order.`);
2072
- return [agents2];
2893
+ return agents2.map((agent) => [agent]);
2073
2894
  }
2074
2895
  }
2075
2896
  /**
@@ -2092,6 +2913,7 @@ var Orchestrator = class {
2092
2913
  * job, and that is what `message` records.
2093
2914
  */
2094
2915
  async resolveTurn(signal) {
2916
+ if (signal?.aborted) return { kind: "cancelled" };
2095
2917
  if (this.config.resume) {
2096
2918
  return {
2097
2919
  kind: "run",
@@ -2103,9 +2925,10 @@ var Orchestrator = class {
2103
2925
  }
2104
2926
  this.contextManager.set(
2105
2927
  "availableAgents",
2106
- this.config.agents.map((a) => `${a.id}: ${a.name}`).join("\n")
2928
+ this.configuredAgents().map((a) => `${a.id}: ${a.name}`).join("\n")
2107
2929
  );
2108
2930
  const result = await this.runner.run(this.config.leader, signal);
2931
+ if (signal?.aborted || result.status === "cancelled") return { kind: "cancelled" };
2109
2932
  if (result.status !== "success") {
2110
2933
  this.warn("The leader could not be reached \u2014 running the full configured team.");
2111
2934
  return { kind: "run", agents: this.configuredAgents() };
@@ -2115,6 +2938,7 @@ var Orchestrator = class {
2115
2938
  if (decision.mode !== "run") {
2116
2939
  return { kind: "message", mode: decision.mode, message: decision.message };
2117
2940
  }
2941
+ if (decision.requirement) this.contextManager.set("requirement", decision.requirement);
2118
2942
  return { kind: "run", agents: this.selectAgents(decision.run), reason: decision.reason };
2119
2943
  }
2120
2944
  const message = result.output.trim();
@@ -2134,8 +2958,11 @@ var Orchestrator = class {
2134
2958
  * prevent, but for the in-memory fields rather than the file.
2135
2959
  */
2136
2960
  configuredAgents() {
2137
- return this.config.agents.map(
2138
- (a) => a.dependsOn ? { ...a, dependsOn: [...a.dependsOn] } : { ...a }
2961
+ return this.config.agents.filter((a) => a.id !== this.config.review?.reviewer).map(
2962
+ (a) => a.dependsOn ? {
2963
+ ...a,
2964
+ dependsOn: a.dependsOn.filter((id) => id !== this.config.review?.reviewer)
2965
+ } : { ...a }
2139
2966
  );
2140
2967
  }
2141
2968
  /**
@@ -2150,7 +2977,7 @@ var Orchestrator = class {
2150
2977
  * reader looking in the wrong place.
2151
2978
  */
2152
2979
  selectAgents(run, namedBy = "The leader") {
2153
- const runSet = new Set(run);
2980
+ const runSet = new Set(run.filter((id) => id !== this.config.review?.reviewer));
2154
2981
  const known = new Set(this.config.agents.map((a) => a.id));
2155
2982
  for (const id of run) {
2156
2983
  if (!known.has(id)) {
@@ -2158,34 +2985,41 @@ var Orchestrator = class {
2158
2985
  }
2159
2986
  }
2160
2987
  const skippedIds = new Set(this.config.agents.filter((a) => !runSet.has(a.id)).map((a) => a.id));
2161
- for (const agent of this.config.agents.filter((a) => skippedIds.has(a.id))) {
2988
+ for (const agent of this.config.agents.filter(
2989
+ (a) => skippedIds.has(a.id) && a.id !== this.config.review?.reviewer
2990
+ )) {
2162
2991
  this.eventBus.emit({
2163
2992
  type: "agent:skipped",
2164
2993
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2165
2994
  payload: { agentId: agent.id, agentName: agent.name, icon: agent.icon }
2166
2995
  });
2167
2996
  }
2168
- return this.config.agents.filter((a) => runSet.has(a.id)).map((a) => ({
2997
+ return this.config.agents.filter((a) => runSet.has(a.id) && a.id !== this.config.review?.reviewer).map((a) => ({
2169
2998
  ...a,
2170
- dependsOn: a.dependsOn?.filter((dep) => !skippedIds.has(dep))
2999
+ dependsOn: a.dependsOn?.filter(
3000
+ (dep) => !skippedIds.has(dep) && dep !== this.config.review?.reviewer
3001
+ )
2171
3002
  }));
2172
3003
  }
2173
3004
  async runStage(agents2, signal) {
2174
3005
  const { maxConcurrency } = this.config;
2175
- if (!maxConcurrency || agents2.length <= maxConcurrency) {
2176
- return Promise.all(agents2.map((a) => this.runner.run(a, signal)));
2177
- }
2178
- const results = new Array(agents2.length);
3006
+ const results = /* @__PURE__ */ new Map();
2179
3007
  let next = 0;
2180
- const workers = Array.from({ length: Math.min(maxConcurrency, agents2.length) }, async () => {
2181
- while (next < agents2.length) {
2182
- const index = next;
2183
- next += 1;
2184
- results[index] = await this.runner.run(agents2[index], signal);
3008
+ let cancelled = false;
3009
+ const workers = Array.from(
3010
+ { length: Math.min(maxConcurrency || agents2.length, agents2.length) },
3011
+ async () => {
3012
+ while (next < agents2.length && !signal?.aborted && !cancelled) {
3013
+ const index = next;
3014
+ next += 1;
3015
+ const result = await this.runner.run(agents2[index], signal);
3016
+ results.set(index, result);
3017
+ if (result.status === "cancelled") cancelled = true;
3018
+ }
2185
3019
  }
2186
- });
3020
+ );
2187
3021
  await Promise.all(workers);
2188
- return results;
3022
+ return [...results.entries()].sort(([a], [b]) => a - b).map(([, result]) => result);
2189
3023
  }
2190
3024
  /**
2191
3025
  * Whether `agent` owns task decomposition.
@@ -2197,7 +3031,7 @@ var Orchestrator = class {
2197
3031
  isPlanner(agent) {
2198
3032
  return agent.role === "planner" || agent.id === "architect";
2199
3033
  }
2200
- async applyTaskPlan(agents2) {
3034
+ async applyTaskPlan(agents2, executedIds) {
2201
3035
  const taskPlanPath = path5.join(this.workDir, "task-plan.json");
2202
3036
  if (!fs4.existsSync(taskPlanPath)) return;
2203
3037
  let taskPlan;
@@ -2209,16 +3043,37 @@ var Orchestrator = class {
2209
3043
  );
2210
3044
  return;
2211
3045
  }
2212
- if (!Array.isArray(taskPlan.tasks)) {
3046
+ if (typeof taskPlan !== "object" || taskPlan === null || !("tasks" in taskPlan) || !Array.isArray(taskPlan.tasks)) {
2213
3047
  this.warn("task-plan.json has no `tasks` array \u2014 ignoring it.");
2214
3048
  return;
2215
3049
  }
2216
- const taskMap = new Map(taskPlan.tasks.map((t) => [t.id, t]));
3050
+ const taskMap = /* @__PURE__ */ new Map();
2217
3051
  const selected = new Set(agents2.map((a) => a.id));
3052
+ for (const [index, entry3] of taskPlan.tasks.entries()) {
3053
+ if (typeof entry3 !== "object" || entry3 === null || Array.isArray(entry3)) {
3054
+ this.warn(`task-plan.json contains a malformed task at index ${index} \u2014 ignoring it.`);
3055
+ continue;
3056
+ }
3057
+ const task = entry3;
3058
+ if (typeof task.id !== "string" || !task.id.trim() || task.title !== void 0 && typeof task.title !== "string" || task.scope !== void 0 && typeof task.scope !== "string" || task.dependsOn !== void 0 && (!Array.isArray(task.dependsOn) || !task.dependsOn.every((dep) => typeof dep === "string")) || task.plan !== void 0 && (!Array.isArray(task.plan) || !task.plan.every((step) => typeof step === "string"))) {
3059
+ this.warn(`task-plan.json contains a malformed task at index ${index} \u2014 ignoring it.`);
3060
+ continue;
3061
+ }
3062
+ if (!selected.has(task.id)) {
3063
+ this.warn(
3064
+ `The task plan names "${task.id}", which is not in this run \u2014 ignoring that task.`
3065
+ );
3066
+ continue;
3067
+ }
3068
+ if (taskMap.has(task.id)) {
3069
+ this.warn(`The task plan contains a duplicate task for "${task.id}" \u2014 ignoring that task.`);
3070
+ continue;
3071
+ }
3072
+ taskMap.set(task.id, task);
3073
+ }
2218
3074
  for (const agent of agents2) {
2219
3075
  const task = taskMap.get(agent.id);
2220
3076
  if (task) {
2221
- agent.taskTitle = task.title;
2222
3077
  const dependsOn = task.dependsOn?.filter((dep) => selected.has(dep));
2223
3078
  for (const dep of task.dependsOn ?? []) {
2224
3079
  if (!selected.has(dep)) {
@@ -2227,6 +3082,8 @@ var Orchestrator = class {
2227
3082
  );
2228
3083
  }
2229
3084
  }
3085
+ if (executedIds.has(agent.id)) continue;
3086
+ agent.taskTitle = task.title;
2230
3087
  agent.dependsOn = dependsOn;
2231
3088
  agent.agentScope = task.scope;
2232
3089
  if (task.plan?.length) {
@@ -2243,7 +3100,7 @@ var Orchestrator = class {
2243
3100
  * and a requirement that never reaches the report cannot be resumed.
2244
3101
  */
2245
3102
  requirementField() {
2246
- const requirement = this.config.context?.requirement;
3103
+ const requirement = this.contextManager.get("requirement");
2247
3104
  return requirement !== void 0 ? { requirement } : {};
2248
3105
  }
2249
3106
  /**
@@ -2288,6 +3145,25 @@ var Orchestrator = class {
2288
3145
  payload: { message }
2289
3146
  });
2290
3147
  }
3148
+ finishCancelled(startTime) {
3149
+ const endTime = /* @__PURE__ */ new Date();
3150
+ const result = {
3151
+ name: this.config.name,
3152
+ success: false,
3153
+ cancelled: true,
3154
+ totalDuration: endTime.getTime() - startTime.getTime(),
3155
+ agentResults: [],
3156
+ startTime: startTime.toISOString(),
3157
+ endTime: endTime.toISOString(),
3158
+ ...this.requirementField()
3159
+ };
3160
+ this.eventBus.emit({
3161
+ type: "orchestration:complete",
3162
+ timestamp: endTime.toISOString(),
3163
+ payload: result
3164
+ });
3165
+ return result;
3166
+ }
2291
3167
  /**
2292
3168
  * Close out a turn that scheduled no agents.
2293
3169
  *
@@ -2295,15 +3171,17 @@ var Orchestrator = class {
2295
3171
  * only honest outcome is to say that none was done. Running the whole team
2296
3172
  * instead would contradict the leader's explicit choice.
2297
3173
  */
2298
- finishEmpty(startTime) {
3174
+ finishEmpty(startTime, acceptance) {
2299
3175
  const usedLeader = this.config.leader !== void 0 && this.config.useLeader !== false;
2300
- this.warn(
2301
- usedLeader ? "The leader selected none of the configured agents \u2014 nothing to run." : "The config lists no agents \u2014 nothing to run."
2302
- );
3176
+ if (!acceptance?.reason)
3177
+ this.warn(
3178
+ usedLeader ? "The leader selected none of the configured agents \u2014 nothing to run." : "The config lists no agents \u2014 nothing to run."
3179
+ );
2303
3180
  const endTime = /* @__PURE__ */ new Date();
2304
3181
  const result = {
2305
3182
  name: this.config.name,
2306
3183
  success: false,
3184
+ ...acceptance ? { acceptance } : {},
2307
3185
  totalDuration: endTime.getTime() - startTime.getTime(),
2308
3186
  agentResults: [],
2309
3187
  startTime: startTime.toISOString(),
@@ -2318,6 +3196,31 @@ var Orchestrator = class {
2318
3196
  return result;
2319
3197
  }
2320
3198
  };
3199
+ function parseReviewVerdict(output) {
3200
+ let text = output.trim();
3201
+ if (text.includes("```")) {
3202
+ const blocks = [...text.matchAll(/```(?:json)?\s*\n([\s\S]*?)\n```/g)];
3203
+ if (blocks.length !== 1 || !text.endsWith(blocks[0][0])) return void 0;
3204
+ const prefix = text.slice(0, blocks[0].index);
3205
+ if (prefix.includes("```") || /"verdict"\s*:/.test(prefix)) return void 0;
3206
+ text = blocks[0][1];
3207
+ }
3208
+ try {
3209
+ const value = JSON.parse(text);
3210
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
3211
+ const item = value;
3212
+ if (item.verdict !== "approved" && item.verdict !== "changes_required" || typeof item.summary !== "string" || !item.summary.trim() || !Array.isArray(item.issues) || !item.issues.every((issue) => typeof issue === "string" && issue.trim()))
3213
+ return void 0;
3214
+ if (item.verdict === "approved" !== (item.issues.length === 0)) return void 0;
3215
+ return {
3216
+ verdict: item.verdict,
3217
+ summary: item.summary.trim(),
3218
+ issues: item.issues.map((issue) => issue.trim())
3219
+ };
3220
+ } catch {
3221
+ return void 0;
3222
+ }
3223
+ }
2321
3224
  function errorMessage2(err) {
2322
3225
  return err instanceof Error ? err.message : String(err);
2323
3226
  }
@@ -2335,6 +3238,14 @@ function sumCosts(results) {
2335
3238
 
2336
3239
  // src/providers/detect.ts
2337
3240
  import { spawn as spawn2 } from "child_process";
3241
+ function parseVersion(raw) {
3242
+ if (!raw) return void 0;
3243
+ const plain = raw.replace(SGR_RE, "");
3244
+ const firstLine = plain.split("\n")[0].trim();
3245
+ if (!firstLine) return void 0;
3246
+ return firstLine.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] ?? firstLine;
3247
+ }
3248
+ var SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
2338
3249
  async function versionFor(command) {
2339
3250
  return new Promise((resolve4) => {
2340
3251
  let out = "";
@@ -2374,7 +3285,7 @@ async function detectProviders(opts) {
2374
3285
  const v = await versionFor(exe);
2375
3286
  if (v && spec.confirm(v)) {
2376
3287
  matched = cmd;
2377
- version = v;
3288
+ version = parseVersion(v);
2378
3289
  source = bundled ? "bundled" : "path";
2379
3290
  resolvedPath = bundled ? exe : void 0;
2380
3291
  break;
@@ -2386,7 +3297,7 @@ async function detectProviders(opts) {
2386
3297
  source = bundled ? "bundled" : "path";
2387
3298
  resolvedPath = bundled ? exe : void 0;
2388
3299
  if (opts?.withVersion) {
2389
- version = await versionFor(exe);
3300
+ version = parseVersion(await versionFor(exe));
2390
3301
  }
2391
3302
  break;
2392
3303
  }
@@ -2435,6 +3346,7 @@ function describeResumePlan({ agents: agents2, sessions = {}, team }) {
2435
3346
  export {
2436
3347
  ABORT_ERROR_NAME,
2437
3348
  AcpRuntime,
3349
+ AgentSessionManager,
2438
3350
  BRIDGE_PACKAGES,
2439
3351
  EventBus,
2440
3352
  Orchestrator,
@@ -2461,5 +3373,6 @@ export {
2461
3373
  runtimeRegistry,
2462
3374
  specForCommand,
2463
3375
  specForProvider,
2464
- tierModel
3376
+ tierModel,
3377
+ validateRuntimeQuestionContent
2465
3378
  };