@abelxiaoxing/cadence 1.0.3 → 1.0.5

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.
@@ -1,6 +1,21 @@
1
- # Copy to <project>/.pi/pi-abel-workflow/.env or ~/.pi/agent/pi-abel-workflow/.env.
1
+ # Copy to <project>/.pi/cadence/.env or ~/.pi/agent/cadence/.env.
2
2
  # The project file wins as a whole file; values are not merged or interpolated.
3
3
 
4
+ # Optional Subagent endpoint. Any non-empty key commits the whole layer, which
5
+ # then requires MODEL and API_URL. Empty API/bounds use the documented defaults.
6
+ SUBAGENT_API_URL=
7
+ SUBAGENT_API_KEY=
8
+ SUBAGENT_MODEL=
9
+ # Dialect: openai-completions (default), openai-responses, anthropic-messages.
10
+ SUBAGENT_API=
11
+ # Internal defaults: context window 256000, max output tokens 128000.
12
+ SUBAGENT_CONTEXT_WINDOW=
13
+ SUBAGENT_MAX_TOKENS=
14
+
15
+ # Per-role layers use the same six suffixes and override the global layer whole:
16
+ # SUBAGENT_<ROLE>_<SUFFIX>, where ROLE is DESIGN_EXPLORER, CONTRACT_REVIEWER,
17
+ # IMPLEMENTATION_WORKER, or DIAGNOSIS_WORKER. Partial/invalid layers fail closed.
18
+
4
19
  CONTEXT7_API_URL=https://context7.com/api/v2
5
20
  CONTEXT7_API_KEY=
6
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Prompts, skills, and a private orchestration extension exposing the Abel four-stage workflow (/abel-init, /abel-design, /abel-implement, /abel-diagnose) with package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,7 @@
1
+ export declare function parseEnvFile(content: string): Record<string, string>;
2
+
3
+ export declare function loadConfig(options: {
4
+ cwd: string;
5
+ home: string;
6
+ required?: readonly string[];
7
+ }): { path: string; values: Record<string, string> };
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import type { Model, Usage } from "@earendil-works/pi-ai";
2
+ import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
3
3
  import {
4
4
  createAgentSession,
5
5
  defineTool,
@@ -137,6 +137,26 @@ function wrapScopedTools(
137
137
 
138
138
  export type ChildFailureKind = "failed" | "cancelled" | "timed-out";
139
139
 
140
+ function finalDeliveryContent(message: AssistantMessage | undefined) {
141
+ return (
142
+ message?.content.filter(
143
+ (content) =>
144
+ content.type !== "thinking" &&
145
+ !(content.type === "text" && content.text.trim().length === 0),
146
+ ) ?? []
147
+ );
148
+ }
149
+
150
+ function isSingleStructuralSubmit(message: AssistantMessage | undefined) {
151
+ const content = finalDeliveryContent(message);
152
+ return (
153
+ message?.role === "assistant" &&
154
+ content.length === 1 &&
155
+ content[0]?.type === "toolCall" &&
156
+ content[0].name === "abel_submit_result"
157
+ );
158
+ }
159
+
140
160
  export type ChildSessionResult =
141
161
  | {
142
162
  ok: true;
@@ -218,17 +238,15 @@ export async function runChildSession(input: {
218
238
  let finalCategory: FinalCategory;
219
239
  if (!last) {
220
240
  finalCategory = "no-final-assistant";
221
- } else if (
222
- last.content.length === 1 &&
223
- last.content[0].type === "toolCall" &&
224
- last.content[0].name === "abel_submit_result"
225
- ) {
241
+ } else if (isSingleStructuralSubmit(last)) {
226
242
  finalCategory =
227
243
  submit.getAttempts() > 1 ? "multiple-submit" : "single-submit-only";
228
- } else if (last.content.length === 1 && last.content[0].type === "text") {
229
- finalCategory = "text-only";
230
244
  } else {
231
- finalCategory = "mixed";
245
+ const content = finalDeliveryContent(last);
246
+ finalCategory =
247
+ content.length === 1 && content[0]?.type === "text"
248
+ ? "text-only"
249
+ : "mixed";
232
250
  }
233
251
  return {
234
252
  finalCategory,
@@ -336,12 +354,7 @@ export async function runChildSession(input: {
336
354
  }
337
355
  const assistants = session.messages.filter((m) => m.role === "assistant");
338
356
  const final = assistants.at(-1);
339
- if (
340
- final?.role !== "assistant" ||
341
- final.content.length !== 1 ||
342
- final.content[0]?.type !== "toolCall" ||
343
- final.content[0]?.name !== "abel_submit_result"
344
- ) {
357
+ if (!isSingleStructuralSubmit(final)) {
345
358
  disposeOnce();
346
359
  return {
347
360
  ok: false,
package/src/contracts.ts CHANGED
@@ -114,6 +114,7 @@ export const ENVIRONMENT_FAILURE_CODES = [
114
114
  "git-apply-unavailable",
115
115
  "git-ignore-check-failed",
116
116
  "git-index-unavailable",
117
+ "invalid-subagent-endpoint",
117
118
  "root-unavailable",
118
119
  "sandbox-runtime-unavailable",
119
120
  ] as const;
@@ -133,10 +134,16 @@ export type EnvironmentFailureCode = (typeof ENVIRONMENT_FAILURE_CODES)[number];
133
134
  export type ApprovalBoundaryCode = (typeof APPROVAL_BOUNDARY_CODES)[number];
134
135
  export type ChildTransportCode = (typeof CHILD_TRANSPORT_CODES)[number];
135
136
 
137
+ export type EnvironmentFailure = {
138
+ kind: "environment";
139
+ code: EnvironmentFailureCode;
140
+ message?: string;
141
+ };
142
+
136
143
  export type CandidateFailure =
137
144
  | { kind: "artifact"; code: ArtifactFailureCode; evidence?: string[] }
138
145
  | { kind: "stale"; code: StaleFailureCode }
139
- | { kind: "environment"; code: EnvironmentFailureCode }
146
+ | EnvironmentFailure
140
147
  | { kind: "approval-boundary"; code: ApprovalBoundaryCode }
141
148
  | { kind: "cancelled"; code: "cancelled" }
142
149
  | { kind: "result-limit"; limitBytes: number };
@@ -163,7 +170,7 @@ export type TaskFailure =
163
170
  kind: "checkpoint-attempts-exhausted";
164
171
  cause: "artifact" | "stale";
165
172
  }
166
- | { kind: "environment"; code: EnvironmentFailureCode }
173
+ | EnvironmentFailure
167
174
  | { kind: "result-limit"; limitBytes: number };
168
175
 
169
176
  export interface CandidateApplyEvidence {
@@ -2,7 +2,13 @@ import {
2
2
  InMemoryCredentialStore,
3
3
  type Model,
4
4
  type Provider,
5
+ type StreamOptions,
5
6
  } from "@earendil-works/pi-ai";
7
+ import {
8
+ createProvider,
9
+ stream as subagentStream,
10
+ streamSimple as subagentStreamSimple,
11
+ } from "@earendil-works/pi-ai/compat";
6
12
  import {
7
13
  type ExtensionContext,
8
14
  ModelRuntime,
@@ -13,6 +19,120 @@ import type {
13
19
  ParentPayloadCallback,
14
20
  ParentPayloadCapture,
15
21
  } from "./parent-payload-bridge.ts";
22
+ import type { SubagentEndpoint } from "./subagent-endpoint.ts";
23
+
24
+ const SUBAGENT_PROVIDER_ID = "abel-subagent";
25
+
26
+ function customStreamOptions<T extends StreamOptions>(
27
+ model: Model<string>,
28
+ options?: T,
29
+ ): T | undefined {
30
+ if (model.api !== "openai-responses") return options;
31
+ const childOnPayload = options?.onPayload;
32
+ return {
33
+ ...options,
34
+ onPayload: async (payload, requestModel) => {
35
+ const transformed = await childOnPayload?.(payload, requestModel);
36
+ const current = transformed === undefined ? payload : transformed;
37
+ if (!current || typeof current !== "object" || Array.isArray(current))
38
+ return current;
39
+ const normalized = { ...current } as Record<string, unknown>;
40
+ delete normalized.max_output_tokens;
41
+ return normalized;
42
+ },
43
+ } as T;
44
+ }
45
+
46
+ // The compat dispatchers resolve the concrete API implementation from
47
+ // model.api at call time, so one pair serves all supported dialects.
48
+ const SUBAGENT_STREAMS = {
49
+ stream(
50
+ model: Parameters<typeof subagentStream>[0],
51
+ context: Parameters<typeof subagentStream>[1],
52
+ options?: Parameters<typeof subagentStream>[2],
53
+ ) {
54
+ return subagentStream(
55
+ model,
56
+ context,
57
+ customStreamOptions(model as Model<string>, options),
58
+ );
59
+ },
60
+ streamSimple(
61
+ model: Parameters<typeof subagentStreamSimple>[0],
62
+ context: Parameters<typeof subagentStreamSimple>[1],
63
+ options?: Parameters<typeof subagentStreamSimple>[2],
64
+ ) {
65
+ return subagentStreamSimple(
66
+ model,
67
+ context,
68
+ customStreamOptions(model as Model<string>, options),
69
+ );
70
+ },
71
+ };
72
+
73
+ function customSubagentModel(endpoint: SubagentEndpoint): Model<string> {
74
+ return {
75
+ id: endpoint.model,
76
+ name: endpoint.model,
77
+ api: endpoint.dialect,
78
+ provider: SUBAGENT_PROVIDER_ID,
79
+ baseUrl: endpoint.url,
80
+ reasoning: true,
81
+ input: ["text"],
82
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
83
+ contextWindow: endpoint.contextWindow,
84
+ maxTokens: endpoint.maxTokens,
85
+ ...(endpoint.dialect === "openai-completions"
86
+ ? { compat: { supportsDeveloperRole: false } }
87
+ : {}),
88
+ } as Model<string>;
89
+ }
90
+
91
+ function customSubagentProvider(
92
+ endpoint: SubagentEndpoint,
93
+ model: Model<string>,
94
+ ): Provider {
95
+ return createProvider({
96
+ id: SUBAGENT_PROVIDER_ID,
97
+ name: "Abel Subagent Endpoint",
98
+ baseUrl: endpoint.url,
99
+ auth: {
100
+ apiKey: {
101
+ name: "Abel subagent endpoint auth",
102
+ resolve: async () =>
103
+ endpoint.apiKey
104
+ ? { auth: { apiKey: endpoint.apiKey }, source: "subagent-endpoint" }
105
+ : {
106
+ auth: {
107
+ apiKey: "unused",
108
+ headers: { authorization: null, "x-api-key": null },
109
+ },
110
+ source: "subagent-endpoint-keyless",
111
+ },
112
+ },
113
+ },
114
+ models: [model],
115
+ api: SUBAGENT_STREAMS,
116
+ }) as Provider;
117
+ }
118
+
119
+ export async function customPhaseRuntime(
120
+ endpoint: SubagentEndpoint,
121
+ signal?: AbortSignal,
122
+ ): Promise<PhaseRuntimeResult> {
123
+ if (signal?.aborted) return cancelledPhaseRuntime();
124
+ const model = customSubagentModel(endpoint);
125
+ const provider = customSubagentProvider(endpoint, model);
126
+ let modelRuntime: ModelRuntime;
127
+ try {
128
+ modelRuntime = await runtimeForProvider(provider, signal);
129
+ } catch (error) {
130
+ if (isCancellation(error, signal)) return cancelledPhaseRuntime();
131
+ throw error;
132
+ }
133
+ if (signal?.aborted) return cancelledPhaseRuntime();
134
+ return { ok: true, modelRuntime, model };
135
+ }
16
136
 
17
137
  export interface PhasePayloadBridge {
18
138
  readonly bridge: ParentPayloadBridge;
package/src/runtime.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  snapshotFiles,
43
43
  } from "./file-snapshot.ts";
44
44
  import type { ParentPayloadBridge } from "./parent-payload-bridge.ts";
45
- import { runtimeFromContext } from "./parent-provider.ts";
45
+ import { customPhaseRuntime, runtimeFromContext } from "./parent-provider.ts";
46
46
  import { applyAgentsCheckpoint, applyRetainedPatch } from "./patch.ts";
47
47
  import {
48
48
  type BoundRetainedResult,
@@ -51,6 +51,11 @@ import {
51
51
  type RetainedCandidateIdentity,
52
52
  } from "./result-store.ts";
53
53
  import { declarationsConflict, Scheduler } from "./scheduler.ts";
54
+ import {
55
+ describeInvalidSubagentEndpoint,
56
+ resolveSubagentEndpoint,
57
+ type SubagentEndpoint,
58
+ } from "./subagent-endpoint.ts";
54
59
  import {
55
60
  type TaskRecord,
56
61
  taskConflictOf,
@@ -1309,11 +1314,30 @@ export class Runtime {
1309
1314
  ...cancellationError(signal),
1310
1315
  failureKind: "cancelled",
1311
1316
  };
1312
- const phase = await runtimeFromContext(
1313
- ctx,
1314
- this.parentPayloadBridge,
1315
- signal,
1316
- );
1317
+ const task = this.taskRecords.get(envelope);
1318
+ const endpoint = task
1319
+ ? task.subagentEndpoint === null
1320
+ ? ({ kind: "inherited" } as const)
1321
+ : ({ kind: "custom", endpoint: task.subagentEndpoint } as const)
1322
+ : resolveSubagentEndpoint(envelope.role, { cwd: ctx.cwd });
1323
+ if (endpoint.kind === "invalid") {
1324
+ const message = describeInvalidSubagentEndpoint(endpoint);
1325
+ return {
1326
+ ok: false,
1327
+ error: message,
1328
+ failure: {
1329
+ kind: "environment",
1330
+ code: "invalid-subagent-endpoint",
1331
+ message,
1332
+ },
1333
+ failureKind: "failed",
1334
+ failureClass: "environment",
1335
+ };
1336
+ }
1337
+ const phase =
1338
+ endpoint.kind === "custom"
1339
+ ? await customPhaseRuntime(endpoint.endpoint, signal)
1340
+ : await runtimeFromContext(ctx, this.parentPayloadBridge, signal);
1317
1341
  if (!phase.ok) {
1318
1342
  return {
1319
1343
  ok: false,
@@ -1425,7 +1449,6 @@ export class Runtime {
1425
1449
  usage: child.usage,
1426
1450
  };
1427
1451
  }
1428
- const task = this.taskRecords.get(envelope);
1429
1452
  const retainedRoot = task?.workspaceRoot ?? ctx.cwd;
1430
1453
  const retainedSnapshot =
1431
1454
  prepared?.snapshot ??
@@ -1547,11 +1570,43 @@ export class Runtime {
1547
1570
  const identity = workerIdentity(ctx.model);
1548
1571
  let record: TaskRecord;
1549
1572
  if (runRequest.kind === "open-task") {
1573
+ const endpoint = resolveSubagentEndpoint("implementation-worker", {
1574
+ cwd: ctx.cwd,
1575
+ });
1576
+ if (endpoint.kind === "invalid") {
1577
+ const failure = {
1578
+ kind: "environment",
1579
+ code: "invalid-subagent-endpoint",
1580
+ message: describeInvalidSubagentEndpoint(endpoint),
1581
+ } as const;
1582
+ record = this.registry.open(
1583
+ runRequest.boundary,
1584
+ identity,
1585
+ workspaceRoot,
1586
+ attempt,
1587
+ );
1588
+ record.state = {
1589
+ kind: "blocked",
1590
+ phase: attempt.phase,
1591
+ failure,
1592
+ };
1593
+ return blockedOutcome(
1594
+ {
1595
+ taskId: attempt.taskId,
1596
+ requestId: attempt.requestId,
1597
+ phase: attempt.phase,
1598
+ },
1599
+ failure,
1600
+ );
1601
+ }
1602
+ const subagentEndpoint: Readonly<SubagentEndpoint> | null =
1603
+ endpoint.kind === "custom" ? endpoint.endpoint : null;
1550
1604
  record = this.registry.open(
1551
1605
  runRequest.boundary,
1552
1606
  identity,
1553
1607
  workspaceRoot,
1554
1608
  attempt,
1609
+ subagentEndpoint,
1555
1610
  );
1556
1611
  } else {
1557
1612
  const existing = this.registry.get(key);
@@ -0,0 +1,158 @@
1
+ // Per-role subagent endpoint resolution from the cadence env file.
2
+ // Three-tier whole-layer semantics: role layer, global layer, inherited.
3
+
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+ import { parseEnvFile } from "../skills/_shared/load-config.mjs";
8
+ import { ROLES } from "./contracts.ts";
9
+
10
+ export type SubagentRole = (typeof ROLES)[number];
11
+
12
+ export type SubagentDialect =
13
+ | "openai-completions"
14
+ | "openai-responses"
15
+ | "anthropic-messages";
16
+
17
+ const LAYER_FIELDS = [
18
+ "API_URL",
19
+ "API_KEY",
20
+ "MODEL",
21
+ "API",
22
+ "CONTEXT_WINDOW",
23
+ "MAX_TOKENS",
24
+ ] as const;
25
+ type LayerField = (typeof LAYER_FIELDS)[number];
26
+ type Layer = Partial<Record<LayerField, string>>;
27
+
28
+ const GLOBAL_PREFIX = "SUBAGENT_";
29
+ const DEFAULT_DIALECT: SubagentDialect = "openai-completions";
30
+ const DIALECTS: readonly SubagentDialect[] = [
31
+ "openai-completions",
32
+ "openai-responses",
33
+ "anthropic-messages",
34
+ ];
35
+
36
+ export const DEFAULT_CONTEXT_WINDOW = 256000;
37
+ export const DEFAULT_MAX_TOKENS = 128000;
38
+
39
+ export interface SubagentEndpoint {
40
+ readonly url: string;
41
+ readonly apiKey?: string;
42
+ readonly model: string;
43
+ readonly dialect: SubagentDialect;
44
+ readonly contextWindow: number;
45
+ readonly maxTokens: number;
46
+ }
47
+
48
+ export type SubagentEndpointResolution =
49
+ | { kind: "inherited" }
50
+ | { kind: "custom"; endpoint: Readonly<SubagentEndpoint> }
51
+ | { kind: "invalid"; keys: string[]; file?: string };
52
+
53
+ export function describeInvalidSubagentEndpoint(
54
+ resolution: Extract<SubagentEndpointResolution, { kind: "invalid" }>,
55
+ ): string {
56
+ return resolution.keys.length > 0
57
+ ? `invalid subagent endpoint configuration keys: ${resolution.keys.join(", ")}`
58
+ : `invalid subagent endpoint configuration file: ${resolution.file ?? "unknown"}`;
59
+ }
60
+
61
+ function layerOf(values: Record<string, string>, prefix: string): Layer {
62
+ const layer: Layer = {};
63
+ for (const field of LAYER_FIELDS) {
64
+ const value = values[prefix + field];
65
+ if (value !== undefined && value !== "") layer[field] = value;
66
+ }
67
+ return layer;
68
+ }
69
+
70
+ function isCommitted(layer: Layer): boolean {
71
+ return LAYER_FIELDS.some((field) => layer[field] !== undefined);
72
+ }
73
+
74
+ function isHttpUrl(value: string): boolean {
75
+ try {
76
+ const parsed = new URL(value);
77
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function isPositiveInteger(value: string): boolean {
84
+ const number = Number(value);
85
+ return /^[0-9]+$/.test(value) && Number.isSafeInteger(number) && number > 0;
86
+ }
87
+
88
+ function offendingKeys(prefix: string, layer: Layer): string[] {
89
+ const keys: string[] = [];
90
+ if (layer.API_URL === undefined || !isHttpUrl(layer.API_URL))
91
+ keys.push(`${prefix}API_URL`);
92
+ if (layer.MODEL === undefined) keys.push(`${prefix}MODEL`);
93
+ if (
94
+ layer.API !== undefined &&
95
+ !DIALECTS.includes(layer.API as SubagentDialect)
96
+ )
97
+ keys.push(`${prefix}API`);
98
+ if (
99
+ layer.CONTEXT_WINDOW !== undefined &&
100
+ !isPositiveInteger(layer.CONTEXT_WINDOW)
101
+ )
102
+ keys.push(`${prefix}CONTEXT_WINDOW`);
103
+ if (layer.MAX_TOKENS !== undefined && !isPositiveInteger(layer.MAX_TOKENS))
104
+ keys.push(`${prefix}MAX_TOKENS`);
105
+ return keys;
106
+ }
107
+
108
+ function selectEnvFile(cwd: string, home: string): string | null {
109
+ const projectPath = path.join(cwd, ".pi", "cadence", ".env");
110
+ if (existsSync(projectPath)) return projectPath;
111
+ const userPath = path.join(home, ".pi", "agent", "cadence", ".env");
112
+ if (existsSync(userPath)) return userPath;
113
+ return null;
114
+ }
115
+
116
+ export function resolveSubagentEndpoint(
117
+ role: string,
118
+ options?: { cwd?: string; home?: string },
119
+ ): SubagentEndpointResolution {
120
+ if (!(ROLES as readonly string[]).includes(role))
121
+ return { kind: "invalid", keys: ["SUBAGENT_ROLE"] };
122
+ const cwd = options?.cwd ?? process.cwd();
123
+ const home = options?.home ?? homedir();
124
+ const file = selectEnvFile(cwd, home);
125
+ if (!file) return { kind: "inherited" };
126
+ let values: Record<string, string>;
127
+ try {
128
+ values = parseEnvFile(readFileSync(file, "utf8"));
129
+ } catch {
130
+ return { kind: "invalid", keys: [], file };
131
+ }
132
+ const rolePrefix = `SUBAGENT_${role.toUpperCase().replaceAll("-", "_")}_`;
133
+ const roleLayer = layerOf(values, rolePrefix);
134
+ let layer: Layer | undefined = isCommitted(roleLayer) ? roleLayer : undefined;
135
+ if (!layer) {
136
+ const globalLayer = layerOf(values, GLOBAL_PREFIX);
137
+ if (isCommitted(globalLayer)) layer = globalLayer;
138
+ }
139
+ if (!layer) return { kind: "inherited" };
140
+ const prefix = layer === roleLayer ? rolePrefix : GLOBAL_PREFIX;
141
+ const keys = offendingKeys(prefix, layer);
142
+ if (keys.length > 0) return { kind: "invalid", keys, file };
143
+ const endpoint: Readonly<SubagentEndpoint> = Object.freeze({
144
+ url: layer.API_URL ?? "",
145
+ ...(layer.API_KEY === undefined ? {} : { apiKey: layer.API_KEY }),
146
+ model: layer.MODEL ?? "",
147
+ dialect: (layer.API as SubagentDialect | undefined) ?? DEFAULT_DIALECT,
148
+ contextWindow:
149
+ layer.CONTEXT_WINDOW === undefined
150
+ ? DEFAULT_CONTEXT_WINDOW
151
+ : Number(layer.CONTEXT_WINDOW),
152
+ maxTokens:
153
+ layer.MAX_TOKENS === undefined
154
+ ? DEFAULT_MAX_TOKENS
155
+ : Number(layer.MAX_TOKENS),
156
+ });
157
+ return { kind: "custom", endpoint };
158
+ }
package/src/worker.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  TaskFailure,
9
9
  } from "./contracts.ts";
10
10
  import type { ConflictDeclaration } from "./scheduler.ts";
11
+ import type { SubagentEndpoint } from "./subagent-endpoint.ts";
11
12
 
12
13
  export type TaskState =
13
14
  | {
@@ -35,6 +36,7 @@ export interface TaskRecord {
35
36
  key: string;
36
37
  workspaceRoot: string;
37
38
  workerIdentity: string;
39
+ subagentEndpoint: Readonly<SubagentEndpoint> | null;
38
40
  boundary: TaskBoundary;
39
41
  conflict: ConflictDeclaration;
40
42
  state: TaskState;
@@ -118,6 +120,7 @@ export class WorkerRegistry {
118
120
  identity: string,
119
121
  workspaceRoot: string,
120
122
  attempt: PhaseAttempt,
123
+ subagentEndpoint: Readonly<SubagentEndpoint> | null = null,
121
124
  ): TaskRecord {
122
125
  const key = taskRecordKey(
123
126
  workspaceRoot,
@@ -130,6 +133,10 @@ export class WorkerRegistry {
130
133
  key,
131
134
  workspaceRoot,
132
135
  workerIdentity: identity,
136
+ subagentEndpoint:
137
+ subagentEndpoint === null
138
+ ? null
139
+ : Object.freeze({ ...subagentEndpoint }),
133
140
  boundary: storedBoundary,
134
141
  conflict: taskConflictOf(storedBoundary),
135
142
  state: { kind: "ready", phase: attempt.phase, launchIndex: 0 },