agent-relay-server 0.108.0 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/docs/openapi.json +143 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-CmqJMGYF.js +4 -0
  4. package/public/assets/{MessageBubble-DYJezSjU.js.map → MessageBubble-CmqJMGYF.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-K6aFJmdd.js +2 -0
  6. package/public/assets/PlanGraphPanel-K6aFJmdd.js.map +1 -0
  7. package/public/assets/activity-DyqKrJe_.js +2 -0
  8. package/public/assets/activity-DyqKrJe_.js.map +1 -0
  9. package/public/assets/{agent-profiles-Di2w5U1o.js → agent-profiles-DRQ5vbPo.js} +2 -2
  10. package/public/assets/{agent-profiles-Di2w5U1o.js.map → agent-profiles-DRQ5vbPo.js.map} +1 -1
  11. package/public/assets/agents-BJ9sm23O.js +2 -0
  12. package/public/assets/{agents-GSJXvmU1.js.map → agents-BJ9sm23O.js.map} +1 -1
  13. package/public/assets/{analytics-B9USQLl5.js → analytics-Q6Dl_CE9.js} +3 -3
  14. package/public/assets/{analytics-B9USQLl5.js.map → analytics-Q6Dl_CE9.js.map} +1 -1
  15. package/public/assets/{automation-XRePs1dT.js → automation-CJ2-uiYt.js} +2 -2
  16. package/public/assets/{automation-XRePs1dT.js.map → automation-CJ2-uiYt.js.map} +1 -1
  17. package/public/assets/{badge-D8fABzMs.js → badge-kdQDWO6e.js} +2 -2
  18. package/public/assets/{badge-D8fABzMs.js.map → badge-kdQDWO6e.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-C_VNyER3.js → branch-state-badge-BRjUWyMT.js} +2 -2
  20. package/public/assets/{branch-state-badge-C_VNyER3.js.map → branch-state-badge-BRjUWyMT.js.map} +1 -1
  21. package/public/assets/{button-KASZcscA.js → button-BKFvNXa3.js} +2 -2
  22. package/public/assets/{button-KASZcscA.js.map → button-BKFvNXa3.js.map} +1 -1
  23. package/public/assets/card-B0y1i_2D.js +2 -0
  24. package/public/assets/{card-Cds0MWME.js.map → card-B0y1i_2D.js.map} +1 -1
  25. package/public/assets/{channels-DACvNoC2.js → channels-DOYB1iiA.js} +2 -2
  26. package/public/assets/{channels-DACvNoC2.js.map → channels-DOYB1iiA.js.map} +1 -1
  27. package/public/assets/chat-D0VtHShz.js +2 -0
  28. package/public/assets/{chat-D_0uHgB6.js.map → chat-D0VtHShz.js.map} +1 -1
  29. package/public/assets/{connectors-C0B38LUK.js → connectors-C5CeK-2M.js} +2 -2
  30. package/public/assets/{connectors-C0B38LUK.js.map → connectors-C5CeK-2M.js.map} +1 -1
  31. package/public/assets/{copy-button-d9DFNX9v.js → copy-button-aLaiAkv2.js} +2 -2
  32. package/public/assets/{copy-button-d9DFNX9v.js.map → copy-button-aLaiAkv2.js.map} +1 -1
  33. package/public/assets/{display-1J__Ti1K.js → display-_5kFrHJh.js} +3 -3
  34. package/public/assets/{display-1J__Ti1K.js.map → display-_5kFrHJh.js.map} +1 -1
  35. package/public/assets/{dist-DOAjFOS4.js → dist-BDYU37NZ.js} +2 -2
  36. package/public/assets/{dist-DOAjFOS4.js.map → dist-BDYU37NZ.js.map} +1 -1
  37. package/public/assets/dist-BnfqT2cl.js +2 -0
  38. package/public/assets/{dist-DC-TqTz2.js.map → dist-BnfqT2cl.js.map} +1 -1
  39. package/public/assets/dist-CF28Y5wI.js +2 -0
  40. package/public/assets/{dist-CBVZk9U8.js.map → dist-CF28Y5wI.js.map} +1 -1
  41. package/public/assets/{dist-DTXKbk2K.js → dist-DDh8a_SK.js} +2 -2
  42. package/public/assets/{dist-DTXKbk2K.js.map → dist-DDh8a_SK.js.map} +1 -1
  43. package/public/assets/{dist-BbUJ1Vht.js → dist-DKoRwEZq.js} +2 -2
  44. package/public/assets/{dist-BbUJ1Vht.js.map → dist-DKoRwEZq.js.map} +1 -1
  45. package/public/assets/{dist-UhEAFdvZ.js → dist-DQPz52Zx.js} +2 -2
  46. package/public/assets/{dist-UhEAFdvZ.js.map → dist-DQPz52Zx.js.map} +1 -1
  47. package/public/assets/{dist-CgqfdHSt.js → dist-D_qcpfbI.js} +2 -2
  48. package/public/assets/{dist-CgqfdHSt.js.map → dist-D_qcpfbI.js.map} +1 -1
  49. package/public/assets/{dist-DybScJmm.js → dist-DnyQI0qt.js} +2 -2
  50. package/public/assets/{dist-DybScJmm.js.map → dist-DnyQI0qt.js.map} +1 -1
  51. package/public/assets/{dist-BwwlmyBq.js → dist-EO8jbi2F.js} +2 -2
  52. package/public/assets/{dist-BwwlmyBq.js.map → dist-EO8jbi2F.js.map} +1 -1
  53. package/public/assets/{dist-BlZvUZZB.js → dist-avJPT3qF.js} +2 -2
  54. package/public/assets/{dist-BlZvUZZB.js.map → dist-avJPT3qF.js.map} +1 -1
  55. package/public/assets/dist-cyag2tir.js +2 -0
  56. package/public/assets/{dist-DreO5AcZ.js.map → dist-cyag2tir.js.map} +1 -1
  57. package/public/assets/{es2015-DUjFyjDp.js → es2015-C9FSEGd7.js} +2 -2
  58. package/public/assets/{es2015-DUjFyjDp.js.map → es2015-C9FSEGd7.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-BKWCAjBd.js → formatted-body-impl-ChCxpVtI.js} +2 -2
  60. package/public/assets/{formatted-body-impl-BKWCAjBd.js.map → formatted-body-impl-ChCxpVtI.js.map} +1 -1
  61. package/public/assets/index-BIRtwnUI.js +23 -0
  62. package/public/assets/index-BIRtwnUI.js.map +1 -0
  63. package/public/assets/index-D-Ntiy3M.css +2 -0
  64. package/public/assets/{input-CtHfLn1i.js → input-DfN6sUbX.js} +2 -2
  65. package/public/assets/{input-CtHfLn1i.js.map → input-DfN6sUbX.js.map} +1 -1
  66. package/public/assets/{insights-BzNoYvDe.js → insights-BGuB8DWZ.js} +2 -2
  67. package/public/assets/{insights-BzNoYvDe.js.map → insights-BGuB8DWZ.js.map} +1 -1
  68. package/public/assets/{integrations-DLzJpt9G.js → integrations-CztQfrI7.js} +2 -2
  69. package/public/assets/{integrations-DLzJpt9G.js.map → integrations-CztQfrI7.js.map} +1 -1
  70. package/public/assets/{lib-l-JkMVR9.js → lib-DRRHEqRZ.js} +2 -2
  71. package/public/assets/{lib-l-JkMVR9.js.map → lib-DRRHEqRZ.js.map} +1 -1
  72. package/public/assets/{lucide-react-C4eXYCwx.js → lucide-react-DmbfpRvJ.js} +2 -2
  73. package/public/assets/{lucide-react-C4eXYCwx.js.map → lucide-react-DmbfpRvJ.js.map} +1 -1
  74. package/public/assets/{maintenance-B6_0-ECm.js → maintenance-BFyuZuv5.js} +2 -2
  75. package/public/assets/{maintenance-B6_0-ECm.js.map → maintenance-BFyuZuv5.js.map} +1 -1
  76. package/public/assets/managed-agents-BIXZUkrA.js +4 -0
  77. package/public/assets/{managed-agents-CpzKXDtV.js.map → managed-agents-BIXZUkrA.js.map} +1 -1
  78. package/public/assets/markdown-preview-impl-DTI--VmH.js +2 -0
  79. package/public/assets/{markdown-preview-impl-DxBnlJ2k.js.map → markdown-preview-impl-DTI--VmH.js.map} +1 -1
  80. package/public/assets/memory-OoM2E30h.js +2 -0
  81. package/public/assets/{memory-Bnm7ILWL.js.map → memory-OoM2E30h.js.map} +1 -1
  82. package/public/assets/{messages-D9LkocTc.js → messages-BHvC3SNR.js} +2 -2
  83. package/public/assets/{messages-D9LkocTc.js.map → messages-BHvC3SNR.js.map} +1 -1
  84. package/public/assets/{orchestrators-C__JfrNr.js → orchestrators-9pzQqf6-.js} +2 -2
  85. package/public/assets/{orchestrators-C__JfrNr.js.map → orchestrators-9pzQqf6-.js.map} +1 -1
  86. package/public/assets/overview-L8iYOYmR.js +2 -0
  87. package/public/assets/{overview-BQ0b0V7Q.js.map → overview-L8iYOYmR.js.map} +1 -1
  88. package/public/assets/{pairs-ChyeBs0Z.js → pairs-BCejxa6_.js} +2 -2
  89. package/public/assets/{pairs-ChyeBs0Z.js.map → pairs-BCejxa6_.js.map} +1 -1
  90. package/public/assets/projects-cOeuiMjl.js +2 -0
  91. package/public/assets/{projects-v5Kp1Oaq.js.map → projects-cOeuiMjl.js.map} +1 -1
  92. package/public/assets/prompt-settings-D9y9hhSy.js +2 -0
  93. package/public/assets/{prompt-settings-YhAOlYc6.js.map → prompt-settings-D9y9hhSy.js.map} +1 -1
  94. package/public/assets/{react-dom-BMF4hNzG.js → react-dom-jQ4Vs380.js} +2 -2
  95. package/public/assets/{react-dom-BMF4hNzG.js.map → react-dom-jQ4Vs380.js.map} +1 -1
  96. package/public/assets/registry-BwBg64Vj.js +2 -0
  97. package/public/assets/{registry-DZoAfrzx.js.map → registry-BwBg64Vj.js.map} +1 -1
  98. package/public/assets/{security-DRCj00eA.js → security-upd2zf8l.js} +2 -2
  99. package/public/assets/{security-DRCj00eA.js.map → security-upd2zf8l.js.map} +1 -1
  100. package/public/assets/{select-ZOpLw2Fd.js → select-Cu55Vi-R.js} +2 -2
  101. package/public/assets/{select-ZOpLw2Fd.js.map → select-Cu55Vi-R.js.map} +1 -1
  102. package/public/assets/settings-Dk5iNqCZ.js +6 -0
  103. package/public/assets/{settings-DGgwr9WH.js.map → settings-Dk5iNqCZ.js.map} +1 -1
  104. package/public/assets/store-DDWg67Pg.js +9 -0
  105. package/public/assets/store-DDWg67Pg.js.map +1 -0
  106. package/public/assets/tasks-BoDuvHnZ.js +2 -0
  107. package/public/assets/tasks-BoDuvHnZ.js.map +1 -0
  108. package/public/assets/teams-C6Pn-gpw.js +2 -0
  109. package/public/assets/{teams-BJpaoG2m.js.map → teams-C6Pn-gpw.js.map} +1 -1
  110. package/public/assets/{terminal-viewer-impl-CQhh6oXa.js → terminal-viewer-impl-afW96xEF.js} +3 -3
  111. package/public/assets/{terminal-viewer-impl-CQhh6oXa.js.map → terminal-viewer-impl-afW96xEF.js.map} +1 -1
  112. package/public/assets/{types-BiJFuTiL.js → types-DSwhOFcJ.js} +2 -2
  113. package/public/assets/types-DSwhOFcJ.js.map +1 -0
  114. package/public/assets/{use-keyboard-viewport-RYMALvu1.js → use-keyboard-viewport-Dw2CHZ50.js} +2 -2
  115. package/public/assets/{use-keyboard-viewport-RYMALvu1.js.map → use-keyboard-viewport-Dw2CHZ50.js.map} +1 -1
  116. package/public/assets/user-avatar-tSPnqEk5.js +2 -0
  117. package/public/assets/{user-avatar-BYGrUx1U.js.map → user-avatar-tSPnqEk5.js.map} +1 -1
  118. package/public/assets/{work-queue-BH2-mARc.js → work-queue-DP3UdDwC.js} +2 -2
  119. package/public/assets/{work-queue-BH2-mARc.js.map → work-queue-DP3UdDwC.js.map} +1 -1
  120. package/public/assets/workspaces-Dt2xndG4.js +3 -0
  121. package/public/assets/workspaces-Dt2xndG4.js.map +1 -0
  122. package/public/index.html +23 -23
  123. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  124. package/runner/src/config.ts +5 -0
  125. package/src/bus.ts +3 -1
  126. package/src/db/merge-lease.ts +5 -1
  127. package/src/db/migrations.ts +21 -1
  128. package/src/db/schema.ts +7 -1
  129. package/src/db/task-detail-projection.ts +66 -0
  130. package/src/db/task-entity.ts +51 -0
  131. package/src/mcp/tools-spawn.ts +1 -1
  132. package/src/mcp-dispatch-tools.ts +49 -17
  133. package/src/mcp-task-memory-tools.ts +4 -21
  134. package/src/routes/agents-spawn.ts +3 -2
  135. package/src/routes/agents.ts +2 -0
  136. package/src/routes/index.ts +5 -1
  137. package/src/routes/messages.ts +6 -6
  138. package/src/routes/tasks.ts +76 -2
  139. package/src/runtime-tokens.ts +4 -0
  140. package/src/security.ts +8 -0
  141. package/src/services/register-agent.ts +46 -2
  142. package/src/services/spawn-agent.ts +114 -30
  143. package/src/services/task-entity.ts +8 -0
  144. package/src/spawn-command.ts +3 -0
  145. package/src/task-authz.ts +52 -0
  146. package/public/assets/MessageBubble-DYJezSjU.js +0 -4
  147. package/public/assets/PlanGraphPanel-MWcVw38W.js +0 -2
  148. package/public/assets/PlanGraphPanel-MWcVw38W.js.map +0 -1
  149. package/public/assets/activity-Db3X3dg8.js +0 -2
  150. package/public/assets/activity-Db3X3dg8.js.map +0 -1
  151. package/public/assets/agents-GSJXvmU1.js +0 -2
  152. package/public/assets/card-Cds0MWME.js +0 -2
  153. package/public/assets/chat-D_0uHgB6.js +0 -2
  154. package/public/assets/dist-CBVZk9U8.js +0 -2
  155. package/public/assets/dist-DC-TqTz2.js +0 -2
  156. package/public/assets/dist-DreO5AcZ.js +0 -2
  157. package/public/assets/index-BjjzI2LT.css +0 -2
  158. package/public/assets/index-DlBSuzd2.js +0 -23
  159. package/public/assets/index-DlBSuzd2.js.map +0 -1
  160. package/public/assets/managed-agents-CpzKXDtV.js +0 -4
  161. package/public/assets/markdown-preview-impl-DxBnlJ2k.js +0 -2
  162. package/public/assets/memory-Bnm7ILWL.js +0 -2
  163. package/public/assets/overview-BQ0b0V7Q.js +0 -2
  164. package/public/assets/projects-v5Kp1Oaq.js +0 -2
  165. package/public/assets/prompt-settings-YhAOlYc6.js +0 -2
  166. package/public/assets/registry-DZoAfrzx.js +0 -2
  167. package/public/assets/settings-DGgwr9WH.js +0 -6
  168. package/public/assets/store-Bnag1kY-.js +0 -9
  169. package/public/assets/store-Bnag1kY-.js.map +0 -1
  170. package/public/assets/tasks-DUTu8ZhA.js +0 -2
  171. package/public/assets/tasks-DUTu8ZhA.js.map +0 -1
  172. package/public/assets/teams-BJpaoG2m.js +0 -2
  173. package/public/assets/types-BiJFuTiL.js.map +0 -1
  174. package/public/assets/user-avatar-BYGrUx1U.js +0 -2
  175. package/public/assets/workspaces-DldYyXZR.js +0 -3
  176. package/public/assets/workspaces-DldYyXZR.js.map +0 -1
@@ -9,7 +9,7 @@
9
9
  import { McpNotFoundError } from "./mcp-errors";
10
10
  import { ValidationError } from "./db";
11
11
  import { authContextFromMcp } from "./services/auth-context";
12
- import { addTaskEntityDependency, createTaskEntity, getTaskEntity } from "./services/task-entity";
12
+ import { addTaskEntityDependency, createTaskEntity, getTaskEntity, recordTaskEntityDispatchDecision } from "./services/task-entity";
13
13
  import { dispatchTask, type DispatchPin } from "./task-dispatcher";
14
14
  import { cleanString } from "./validation";
15
15
  import {
@@ -75,7 +75,7 @@ function taskIdArg(value: unknown): number {
75
75
  return n;
76
76
  }
77
77
 
78
- function pinArg(value: unknown): DispatchPin {
78
+ export function pinArg(value: unknown): DispatchPin {
79
79
  if (value === undefined || value === null) return {};
80
80
  if (!isRecord(value)) throw new ValidationError("pin must be an object");
81
81
  const pin: DispatchPin = {};
@@ -93,7 +93,7 @@ function pinArg(value: unknown): DispatchPin {
93
93
  return pin;
94
94
  }
95
95
 
96
- function dispatchShapeArg(value: unknown): TaskDispatch | undefined {
96
+ export function dispatchShapeArg(value: unknown): TaskDispatch | undefined {
97
97
  if (value === undefined || value === null) return undefined;
98
98
  if (!isRecord(value)) throw new ValidationError("dispatch must be an object");
99
99
  return value as TaskDispatch;
@@ -108,37 +108,49 @@ function findExistingReviewDependent(dependentIds: number[]): number | undefined
108
108
  return undefined;
109
109
  }
110
110
 
111
- export function relayDispatchTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
112
- const actor = authContextFromMcp(auth).actor.id;
113
- if (!isRecord(args)) throw new ValidationError("arguments must be an object");
114
- if (name !== "relay_task_dispatch") throw new ValidationError(`unknown tool: ${name}`);
111
+ export interface RunTaskDispatchInput {
112
+ taskId?: number;
113
+ dispatch?: TaskDispatch;
114
+ pin?: DispatchPin;
115
+ createReview?: boolean;
116
+ actor: string;
117
+ }
115
118
 
116
- const hasTaskId = args.taskId !== undefined && args.taskId !== null;
117
- const taskId = hasTaskId ? taskIdArg(args.taskId) : undefined;
118
- const inlineShape = dispatchShapeArg(args.dispatch);
119
+ export interface RunTaskDispatchResult {
120
+ taskId?: number;
121
+ decision: ReturnType<typeof dispatchTask>;
122
+ reviewTaskId?: number;
123
+ }
124
+
125
+ /**
126
+ * Core dispatch logic shared between the MCP tool (relay_task_dispatch) and the HTTP route
127
+ * (POST /api/tasks/:id/dispatch). Both paths call this function — do NOT fork it (#845).
128
+ */
129
+ export function runTaskDispatch(input: RunTaskDispatchInput): RunTaskDispatchResult {
130
+ const { taskId, dispatch: inlineShape, pin = {}, createReview = true, actor } = input;
119
131
 
120
132
  let parentTitle: string | undefined;
121
133
  let parentTeamId: string | undefined;
122
134
  let parentDependents: number[] = [];
123
135
  let shape = inlineShape;
136
+
124
137
  if (taskId !== undefined) {
125
138
  const task = getTaskEntity(taskId);
126
139
  if (!task) throw new McpNotFoundError("task not found");
127
140
  parentTitle = task.title;
128
141
  parentTeamId = task.teamId;
129
142
  parentDependents = task.dependents;
130
- // Inline dispatch (if supplied) overrides the stored work-shape for this call.
131
143
  shape = inlineShape ?? task.dispatch;
132
144
  }
133
145
  if (!shape && !inlineShape) shape = {};
134
146
 
135
- const decision = dispatchTask(shape, pinArg(args.pin));
147
+ const decision = dispatchTask(shape, pin);
148
+ if (taskId !== undefined) {
149
+ recordTaskEntityDispatchDecision(taskId, { decision, source: "dispatch", actor });
150
+ }
136
151
 
137
152
  let reviewTaskId: number | undefined;
138
- const createReview = args.createReview !== false; // default true
139
153
  if (decision.review && createReview && taskId !== undefined) {
140
- // Idempotent: if this build Task already has a review dependent, reuse it rather than
141
- // spawning a duplicate on every re-dispatch.
142
154
  const existing = findExistingReviewDependent(parentDependents);
143
155
  if (existing !== undefined) {
144
156
  reviewTaskId = existing;
@@ -150,7 +162,6 @@ export function relayDispatchTool(auth: McpAuthLike, name: string, args: Record<
150
162
  createdBy: actor,
151
163
  ...(parentTeamId ? { teamId: parentTeamId } : {}),
152
164
  });
153
- // The review Task depends on the build Task (build must complete first).
154
165
  addTaskEntityDependency(review.id, taskId, actor);
155
166
  reviewTaskId = review.id;
156
167
  }
@@ -158,7 +169,28 @@ export function relayDispatchTool(auth: McpAuthLike, name: string, args: Record<
158
169
 
159
170
  return {
160
171
  ...(taskId !== undefined ? { taskId } : {}),
161
- decision: decision as unknown as Record<string, unknown>,
172
+ decision,
162
173
  ...(reviewTaskId !== undefined ? { reviewTaskId } : {}),
163
174
  };
164
175
  }
176
+
177
+ export function relayDispatchTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
178
+ const actor = authContextFromMcp(auth).actor.id;
179
+ if (!isRecord(args)) throw new ValidationError("arguments must be an object");
180
+ if (name !== "relay_task_dispatch") throw new ValidationError(`unknown tool: ${name}`);
181
+
182
+ const hasTaskId = args.taskId !== undefined && args.taskId !== null;
183
+ const result = runTaskDispatch({
184
+ taskId: hasTaskId ? taskIdArg(args.taskId) : undefined,
185
+ dispatch: dispatchShapeArg(args.dispatch),
186
+ pin: pinArg(args.pin),
187
+ createReview: args.createReview !== false,
188
+ actor,
189
+ });
190
+
191
+ return {
192
+ ...(result.taskId !== undefined ? { taskId: result.taskId } : {}),
193
+ decision: result.decision as unknown as Record<string, unknown>,
194
+ ...(result.reviewTaskId !== undefined ? { reviewTaskId: result.reviewTaskId } : {}),
195
+ };
196
+ }
@@ -1,10 +1,6 @@
1
- import { stringValue } from "agent-relay-sdk";
2
1
  import {
3
2
  appendTaskMemoryDecision,
4
3
  appendTaskMemoryRuledOut,
5
- deliverableTaskIdsByOwnerAgent,
6
- deliverableTaskIdsBySpawnRequestId,
7
- getAgent,
8
4
  readTaskMemoryEnvelope,
9
5
  replaceTaskMemoryWorkingState,
10
6
  ValidationError,
@@ -13,6 +9,7 @@ import { McpAuthError } from "./mcp-errors";
13
9
  import { callerAgentId } from "./mcp/identity";
14
10
  import { optionalPositiveInt, stringField } from "./mcp/validation";
15
11
  import { renderContinuation } from "./self-resume";
12
+ import { agentBoundTaskIds, hasTaskReadAdminScope } from "./task-authz";
16
13
  import type { McpAuthContext, ToolDefinition } from "./mcp/types";
17
14
 
18
15
  const TASK_ID_PROP = { type: "integer", minimum: 1, description: "Task id. Optional when the caller is bound to exactly one Task." } as const;
@@ -96,11 +93,12 @@ export function relayTaskMemoryTool(auth: McpAuthContext, name: string, args: Re
96
93
 
97
94
  function resolveTaskId(auth: McpAuthContext, args: Record<string, unknown>): number {
98
95
  const explicit = optionalPositiveInt(args.taskId, "taskId");
99
- const ids = boundTaskIds(auth);
96
+ const agentId = callerAgentId(auth);
97
+ const ids = agentId ? agentBoundTaskIds(agentId) : new Set<number>();
100
98
  if (explicit !== undefined) {
101
99
  // Deliberate privileged escape hatch: admin/server tooling can inspect or repair any
102
100
  // task memory row, but ordinary provider tokens must be bound to the explicit task id.
103
- if (hasTaskMemoryAdminScope(auth) || ids.has(explicit)) return explicit;
101
+ if (hasTaskReadAdminScope(auth.scopes) || ids.has(explicit)) return explicit;
104
102
  throw new McpAuthError("taskId is not bound to the caller");
105
103
  }
106
104
  if (ids.size === 1) return [...ids][0]!;
@@ -108,21 +106,6 @@ function resolveTaskId(auth: McpAuthContext, args: Record<string, unknown>): num
108
106
  throw new McpAuthError("caller is bound to multiple Tasks; pass taskId explicitly");
109
107
  }
110
108
 
111
- function boundTaskIds(auth: McpAuthContext): Set<number> {
112
- const agentId = callerAgentId(auth);
113
- if (!agentId) return new Set();
114
- const ids = new Set<number>(deliverableTaskIdsByOwnerAgent(agentId));
115
- const spawnRequestId = stringValue(getAgent(agentId)?.meta?.spawnRequestId);
116
- if (spawnRequestId) {
117
- for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
118
- }
119
- return ids;
120
- }
121
-
122
- function hasTaskMemoryAdminScope(auth: McpAuthContext): boolean {
123
- return auth.scopes.includes("*") || auth.scopes.includes("admin:*") || auth.scopes.includes("system:admin") || auth.scopes.includes("task:admin");
124
- }
125
-
126
109
  function response(envelope: ReturnType<typeof readTaskMemoryEnvelope>): Record<string, unknown> {
127
110
  return {
128
111
  taskId: envelope.taskId,
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -15,7 +15,7 @@ import { authContextFromRequest } from "../services/auth-context";
15
15
  import { listHostDirectories } from "../agent-spawn";
16
16
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
17
17
  import { quotaStateFromUnknown } from "../quota";
18
- import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
18
+ import { type AgentKind, type AgentLifecycle, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
19
19
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
20
20
 
21
21
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
@@ -128,6 +128,7 @@ export const postAgentSpawn: Handler = async (req) => {
128
128
  effort: cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
129
129
  approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
130
130
  workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
131
+ lifecycle: optionalEnum(parsed.body.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined,
131
132
  label: cleanString(parsed.body.label, "label", { max: 120 }),
132
133
  policyName: cleanString(parsed.body.policyName, "policyName", { max: 120 }),
133
134
  profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
@@ -7,6 +7,7 @@ import { cleanStringArray } from "../validation";
7
7
  import { clearActiveMemories, memoryBroker } from "../memory-service";
8
8
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
9
9
  import { notifyAgentReady } from "../agent-lifecycle-events";
10
+ import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
10
11
  import { flushQueuedDirectMessages } from "../services/managed-running";
11
12
  import { isAgentUnavailable } from "../db/agent-predicates";
12
13
  import { getCompactionWatch } from "../compaction-watch";
@@ -299,6 +300,7 @@ export const patchAgentReady: Handler = async (req, params) => {
299
300
  // agents and dedups repeat readies.
300
301
  if (body.ready && !before?.ready) {
301
302
  notifyAgentReady(params.id!);
303
+ bindSpawnedTaskToReadyAgent(params.id!, "agent-ready");
302
304
  // #416 — also flush queued direct messages: a not-ready agent that becomes ready
303
305
  // may have queued messages waiting (e.g. starting up, clearing context).
304
306
  flushQueuedDirectMessages(params.id!);
@@ -45,7 +45,7 @@ import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, get
45
45
  import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
46
46
  import { postNotificationPreviewRoute } from "./notifications";
47
47
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
48
- import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge } from "./tasks";
48
+ import { getTaskById, getTaskEntities, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim, postTaskCreate, getTaskDetailRoute, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } from "./tasks";
49
49
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
50
50
  import { postTeamDissolveByIdRoute } from "./teams";
51
51
  import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
@@ -350,10 +350,13 @@ const routes: Route[] = [
350
350
  route("PATCH", "/api/automations/:id", patchAutomation),
351
351
  route("DELETE", "/api/automations/:id", deleteAutomationById),
352
352
  route("POST", "/api/automations/:id/run", postAutomationRun),
353
+ route("GET", "/api/task-entities", getTaskEntities),
353
354
  route("GET", "/api/tasks", getTasks),
354
355
  route("POST", "/api/tasks", postTaskCreate),
355
356
  route("GET", "/api/tasks/:id", getTaskById),
356
357
  route("GET", "/api/tasks/:id/detail", getTaskDetailRoute),
358
+ route("GET", "/api/tasks/:id/memory", getTaskMemoryRoute),
359
+ route("GET", "/api/tasks/:id/history", getTaskHistoryRoute),
357
360
  route("GET", "/api/tasks/:id/artifacts", getTaskArtifactsRoute),
358
361
  route("GET", "/api/tasks/:id/events", getTaskEvents),
359
362
  route("POST", "/api/tasks/:id/claim", postClaimTask),
@@ -370,6 +373,7 @@ const routes: Route[] = [
370
373
  route("POST", "/api/tasks/:id/assign", postTaskAssign),
371
374
  route("POST", "/api/tasks/:id/comments", postTaskComment),
372
375
  route("GET", "/api/tasks/:id/close-bridge", getTaskCloseBridge),
376
+ route("POST", "/api/tasks/:id/dispatch", postTaskDispatch),
373
377
 
374
378
  route("POST", "/api/pairs", postPair),
375
379
  route("GET", "/api/pairs", getPairs),
@@ -162,12 +162,12 @@ export const postMessage: Handler = async (req) => {
162
162
  // injection needs request-derived context; the "message sent" audit is the HTTP domain row.
163
163
  const autoMemoryTarget = automaticMemoryTarget(message);
164
164
  if (autoMemoryTarget) {
165
- try {
166
- const memoryInjection = await injectMemoryForMessageDelivery(message, autoMemoryTarget, memoryContext(req));
167
- if (memoryInjection) emitCommand(memoryInjection.command);
168
- } catch (e) {
169
- console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
170
- }
165
+ // Fire-and-forget: capture context synchronously then inject off the response path so
166
+ // POST latency is not gated on memory assembly (A1 fix, #847).
167
+ const ctx = memoryContext(req);
168
+ void injectMemoryForMessageDelivery(message, autoMemoryTarget, ctx)
169
+ .then((memoryInjection) => { if (memoryInjection) emitCommand(memoryInjection.command); })
170
+ .catch((e) => { console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`); });
171
171
  }
172
172
  const isWork = message.kind === "task";
173
173
  auditEvent({
@@ -1,10 +1,15 @@
1
1
  // Auto-split from routes.ts (#299). Domain: tasks.
2
2
  import { MAX_BODY_BYTES } from "../config";
3
3
  import { VALID_TASK_STATUSES, agentSessionStatus, auditEvent, dispatchTaskCallbacks, emitCommand, error, json, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, resolveActorLabel, type Handler } from "./_shared";
4
+ import { getComponentAuth, isFullReachRequest } from "../security";
5
+ import { authContextFromRequest } from "../services/auth-context";
6
+ import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
7
+ import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
4
8
  import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
9
+ import { readTaskMemoryEnvelope } from "../db/task-memory";
5
10
  import {
6
11
  addTaskEntityDependency, addTaskEntityRef, assignTaskEntityAgent, createTaskEntity, getTaskEntity,
7
- listTaskEntities, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, updateTaskEntity,
12
+ listTaskEntities, recordTaskEntityGate, removeTaskEntityDependency, removeTaskEntityRef, setTaskEntityStage, setTaskEntityStatus, taskEntityHistory, updateTaskEntity,
8
13
  } from "../services/task-entity";
9
14
  import { CommentThroughError, commentOnTaskThrough, taskCloseBridge } from "../services/task-issue-weave";
10
15
  import { captureTaskResultMemory, injectMemoryForTaskClaim } from "../memory-service";
@@ -14,7 +19,9 @@ import { errMessage, isRecord, TASK_DELIVERABLE_STAGES, type TaskBlocker, type T
14
19
  import { type AgentSessionGuard, type TaskStatus, type TaskStatusInput } from "../types";
15
20
 
16
21
  function actorOf(req: Request, fallback?: unknown): string {
17
- return resolveActorLabel(req) ?? (typeof fallback === "string" && fallback.trim() ? fallback.trim() : "dashboard");
22
+ if (typeof fallback === "string" && fallback.trim()) return fallback.trim();
23
+ const ctx = authContextFromRequest(req);
24
+ return ctx.callerAgentId ?? resolveActorLabel(req) ?? (ctx.actor.id !== "system" ? ctx.actor.id : "dashboard");
18
25
  }
19
26
 
20
27
  function taskDetailOr404(id: number) {
@@ -61,6 +68,18 @@ export const getTasks: Handler = (req) => {
61
68
  }));
62
69
  };
63
70
 
71
+ export const getTaskEntities: Handler = (req) => {
72
+ const url = new URL(req.url);
73
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
74
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
75
+ return json(listTaskEntities({
76
+ status: url.searchParams.get("status") ?? undefined,
77
+ deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
78
+ teamId: url.searchParams.get("teamId") ?? undefined,
79
+ limit: limitRaw ?? 100,
80
+ }));
81
+ };
82
+
64
83
  // --- #834 durable Task entity (kind='deliverable') HTTP surface ---
65
84
 
66
85
  export const postTaskCreate: Handler = async (req) => {
@@ -101,6 +120,28 @@ export const getTaskDetailRoute: Handler = (_req, params) => {
101
120
  return taskDetailOr404(id);
102
121
  };
103
122
 
123
+ export const getTaskMemoryRoute: Handler = (req, params) => {
124
+ const id = parseId(params.id);
125
+ if (id === null) return error("invalid task id");
126
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
127
+ const component = getComponentAuth(req);
128
+ const agentId = component ? agentIdFromComponent(component) : undefined;
129
+ const scopes = component?.scope ?? [];
130
+ if (!isTaskReadAuthorized(id, agentId, scopes)) return error("forbidden", 403);
131
+ return json(readTaskMemoryEnvelope(id));
132
+ };
133
+
134
+ export const getTaskHistoryRoute: Handler = (req, params) => {
135
+ const id = parseId(params.id);
136
+ if (id === null) return error("invalid task id");
137
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
138
+ const component = getComponentAuth(req);
139
+ const agentId = component ? agentIdFromComponent(component) : undefined;
140
+ const scopes = component?.scope ?? [];
141
+ if (!isTaskReadAuthorized(id, agentId, scopes)) return error("forbidden", 403);
142
+ return json(taskEntityHistory(id));
143
+ };
144
+
104
145
  export const patchTaskEntity: Handler = async (req, params) => {
105
146
  const id = parseId(params.id);
106
147
  if (id === null) return error("invalid task id");
@@ -291,6 +332,39 @@ export const postTaskComment: Handler = async (req, params) => {
291
332
  }
292
333
  };
293
334
 
335
+ // #845 — HTTP dispatch decision endpoint. Invokes the SAME path as the MCP relay_task_dispatch
336
+ // tool (via runTaskDispatch). Accepts an optional inline dispatch override + pin; always returns
337
+ // the decision + rationale so the UI can show the #839 guard before any spawn. Never spawns.
338
+ // Auth: admin scope OR a bound agent (#848: no scope-only gate).
339
+ export const postTaskDispatch: Handler = async (req, params) => {
340
+ const id = parseId(params.id);
341
+ if (id === null) return error("invalid task id");
342
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
343
+ const component = getComponentAuth(req);
344
+ const agentId = component ? agentIdFromComponent(component) : undefined;
345
+ const scopes = component?.scope ?? [];
346
+ if (!isFullReachRequest(req) && !isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
347
+ const parsed = await parseBody<Record<string, unknown>>(req);
348
+ if (!parsed.ok) return error(parsed.error, parsed.status);
349
+ const body = parsed.body ?? {};
350
+ try {
351
+ const result = runTaskDispatch({
352
+ taskId: id,
353
+ dispatch: dispatchShapeArg(body.dispatch),
354
+ pin: pinArg(body.pin),
355
+ createReview: body.createReview !== false,
356
+ actor: actorOf(req),
357
+ });
358
+ return json({
359
+ taskId: result.taskId,
360
+ decision: result.decision,
361
+ ...(result.reviewTaskId !== undefined ? { reviewTaskId: result.reviewTaskId } : {}),
362
+ });
363
+ } catch (e) {
364
+ return entityError(e);
365
+ }
366
+ };
367
+
294
368
  // #833 — read the #482/#474 close-bridge projection for a Task's linked issues.
295
369
  export const getTaskCloseBridge: Handler = (_req, params) => {
296
370
  const id = parseId(params.id);
@@ -94,6 +94,7 @@ export function issueRunnerRuntimeToken(input: {
94
94
  label?: string;
95
95
  policyName?: string;
96
96
  spawnRequestId?: string;
97
+ taskId?: number;
97
98
  createdBy?: string;
98
99
  /**
99
100
  * Agent profile name. The spawn grant (the `command:spawn`/`command:shutdown` scope + the
@@ -128,6 +129,7 @@ export function issueRunnerRuntimeToken(input: {
128
129
  cwdPrefixes: [input.cwd],
129
130
  ...(input.policyName ? { policies: [input.policyName] } : {}),
130
131
  ...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
132
+ ...(input.taskId !== undefined ? { taskIds: [String(input.taskId)] } : {}),
131
133
  ...(grant.canSpawn && grant.maxSpawnedAgents ? { maxSpawnedAgents: grant.maxSpawnedAgents } : {}),
132
134
  ...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
133
135
  ...(input.teamId ? { teamId: input.teamId } : {}),
@@ -250,6 +252,7 @@ export function runnerRuntimeTokenEnv(input: {
250
252
  label?: string;
251
253
  policyName?: string;
252
254
  spawnRequestId?: string;
255
+ taskId?: number;
253
256
  createdBy?: string;
254
257
  /** Agent profile name — spawn grant is resolved from it in the mint (see issueRunnerRuntimeToken). */
255
258
  profile?: string;
@@ -266,6 +269,7 @@ export function runnerRuntimeTokenEnv(input: {
266
269
  AGENT_RELAY_TOKEN_JTI: issued.record.jti,
267
270
  AGENT_RELAY_TOKEN_PROFILE: issued.record.profileId ?? "provider-agent",
268
271
  ...(issued.record.expiresAt ? { AGENT_RELAY_TOKEN_EXPIRES_AT: String(issued.record.expiresAt) } : {}),
272
+ ...(input.taskId !== undefined ? { AGENT_RELAY_TASK_ID: String(input.taskId) } : {}),
269
273
  };
270
274
  }
271
275
 
package/src/security.ts CHANGED
@@ -87,6 +87,10 @@ export function isAuthorized(req: Request): boolean {
87
87
  return Boolean(presented && verifyComponentToken(presented));
88
88
  }
89
89
 
90
+ export function isFullReachRequest(req: Request): boolean {
91
+ return Boolean(extractToken(req)) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
92
+ }
93
+
90
94
  type IntegrationAuth = IntegrationTokenConfig;
91
95
 
92
96
  type AuthorizationResource = {
@@ -195,6 +199,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
195
199
  if (pathname === "/api/tokens/me") return null;
196
200
  if (pathname.startsWith("/api/tokens") || pathname.startsWith("/api/token-profiles")) return method === "GET" ? "token:read" : "token:write";
197
201
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
202
+ // Dispatch is spawn-class — requires command:spawn, not generic task:write (#845/#848).
203
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/dispatch$/)) return "command:spawn";
198
204
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
199
205
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
200
206
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
@@ -304,6 +310,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
304
310
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
305
311
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
306
312
  if (pathname.startsWith("/api/provisioning")) return method === "GET" ? "provisioning:read" : "provisioning:write";
313
+ // Dispatch is spawn-class — requires command:spawn, not generic task:write (#845/#848).
314
+ if (pathname.match(/^\/api\/tasks\/[^/]+\/dispatch$/)) return "command:spawn";
307
315
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
308
316
  if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
309
317
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
@@ -9,8 +9,8 @@
9
9
  // managed came-up-running reconcile (delegated to managed-running.ts), the single
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
- import { isReservedAgentId } from "agent-relay-sdk";
13
- import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
12
+ import { errMessage, isReservedAgentId } from "agent-relay-sdk";
13
+ import { ValidationError, createActivityEvent, deliverableTaskIdsBySpawnRequestId, getAgent, getTaskDetail, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
14
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
16
  import { agentProviderTimelineEvent } from "../provider-timeline-event";
@@ -19,6 +19,7 @@ import { resolveSpawnLineage, resolveTeamLineage } from "../security";
19
19
  import { flushQueuedDirectMessages, markManagedAgentRunning } from "./managed-running";
20
20
  import { isAgentUnavailable } from "../db/agent-predicates";
21
21
  import { recordSpawnPromptIssueActivity } from "./spawn-issue-activity";
22
+ import { assignTaskEntityAgent, setTaskEntityStatus } from "./task-entity";
22
23
  import type { AuthContext } from "./auth-context";
23
24
  import type { AgentCard, RegisterAgentInput } from "../types";
24
25
 
@@ -27,6 +28,48 @@ function metaStr(meta: Record<string, unknown> | undefined, key: string): string
27
28
  return typeof value === "string" && value.length > 0 ? value : undefined;
28
29
  }
29
30
 
31
+ function metaTaskId(meta: Record<string, unknown> | undefined): number | undefined {
32
+ const value = meta?.taskId;
33
+ const parsed = typeof value === "string" ? Number(value) : value;
34
+ return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
35
+ }
36
+
37
+ function taskIdsForReadyAgent(agent: AgentCard): number[] {
38
+ const ids = new Set<number>();
39
+ const directTaskId = metaTaskId(agent.meta);
40
+ if (directTaskId !== undefined) ids.add(directTaskId);
41
+ const spawnRequestId = metaStr(agent.meta, "spawnRequestId");
42
+ if (spawnRequestId) {
43
+ for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
44
+ }
45
+ return [...ids].sort((a, b) => a - b);
46
+ }
47
+
48
+ export function bindSpawnedTaskToReadyAgent(agentId: string, actor = "agent-relay"): void {
49
+ const agent = getAgent(agentId);
50
+ if (!agent?.ready) return;
51
+ const taskIds = taskIdsForReadyAgent(agent);
52
+ for (const taskId of taskIds) {
53
+ try {
54
+ const before = getTaskDetail(taskId);
55
+ if (!before) continue;
56
+ if (before.ownerAgentId && before.ownerAgentId !== agent.id) continue;
57
+ const assigned = before.ownerAgentId === agent.id
58
+ ? before
59
+ : assignTaskEntityAgent(taskId, agent.id, "worker", agent.spawnedBy ?? actor);
60
+ if (assigned.status === "open" || assigned.status === "claimed") {
61
+ setTaskEntityStatus(taskId, {
62
+ status: "in_progress",
63
+ note: `worker ${agent.id} ready`,
64
+ actor: agent.spawnedBy ?? actor,
65
+ });
66
+ }
67
+ } catch (error) {
68
+ console.warn(`task ${taskId} ready binding skipped for ${agent.id}: ${errMessage(error)}`);
69
+ }
70
+ }
71
+ }
72
+
30
73
  export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): AgentCard {
31
74
  // Reserved built-in identities — the `system` lifecycle sender and the whole `user:` human
32
75
  // namespace (legacy bare `'user'`, `user:default`, every `user:<id>`) — are mechanical sinks,
@@ -73,6 +116,7 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
73
116
  // case) wakes its block-polling parent. Fire on the first-ever ready, regardless of
74
117
  // transport. notifyAgentReady no-ops for non-spawned agents and dedups repeats.
75
118
  if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
119
+ if (agent.ready && !existing?.ready) bindSpawnedTaskToReadyAgent(agent.id, ctx.actor.id);
76
120
 
77
121
  // #416 — flush queued direct messages when a re-registering agent was previously unavailable
78
122
  // (stale, compacting, blocked) or a new agent becomes ready. No-op for brand-new agents