agent-relay-server 0.109.0 → 0.111.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 (95) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-XgN61quY.js → MessageBubble-WYqSyKPM.js} +2 -2
  4. package/public/assets/{MessageBubble-XgN61quY.js.map → MessageBubble-WYqSyKPM.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Dvu17jGe.js +2 -0
  6. package/public/assets/PlanGraphPanel-Dvu17jGe.js.map +1 -0
  7. package/public/assets/{activity-D0EotVU9.js → activity-wAN_upn7.js} +2 -2
  8. package/public/assets/{activity-D0EotVU9.js.map → activity-wAN_upn7.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CAGvryb9.js → agent-profiles-D8WLdTCs.js} +2 -2
  10. package/public/assets/{agent-profiles-CAGvryb9.js.map → agent-profiles-D8WLdTCs.js.map} +1 -1
  11. package/public/assets/{agents-CXXZ_sU9.js → agents-C5FajNPw.js} +2 -2
  12. package/public/assets/{agents-CXXZ_sU9.js.map → agents-C5FajNPw.js.map} +1 -1
  13. package/public/assets/{automation-zig0cp23.js → automation-ShdXg0pX.js} +2 -2
  14. package/public/assets/{automation-zig0cp23.js.map → automation-ShdXg0pX.js.map} +1 -1
  15. package/public/assets/chat-aRCk0pLJ.js +2 -0
  16. package/public/assets/{chat-C3QlsFBd.js.map → chat-aRCk0pLJ.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-DCQSVyWB.js → formatted-body-impl-DZc7C4-2.js} +2 -2
  18. package/public/assets/{formatted-body-impl-DCQSVyWB.js.map → formatted-body-impl-DZc7C4-2.js.map} +1 -1
  19. package/public/assets/{index-D6hQkte1.css → index-D-Ntiy3M.css} +1 -1
  20. package/public/assets/{index-RCbIyk26.js → index-DXEYz_JV.js} +13 -13
  21. package/public/assets/index-DXEYz_JV.js.map +1 -0
  22. package/public/assets/{insights-CsLs6NbL.js → insights-FzTX2wNh.js} +2 -2
  23. package/public/assets/{insights-CsLs6NbL.js.map → insights-FzTX2wNh.js.map} +1 -1
  24. package/public/assets/{maintenance-BN5F0Lnc.js → maintenance-C8Uw8XiI.js} +2 -2
  25. package/public/assets/{maintenance-BN5F0Lnc.js.map → maintenance-C8Uw8XiI.js.map} +1 -1
  26. package/public/assets/{managed-agents-R_LI9qS5.js → managed-agents-DVbJ2hqh.js} +2 -2
  27. package/public/assets/{managed-agents-R_LI9qS5.js.map → managed-agents-DVbJ2hqh.js.map} +1 -1
  28. package/public/assets/{markdown-preview-impl-DQlmK_ev.js → markdown-preview-impl-C5D-B-9S.js} +2 -2
  29. package/public/assets/{markdown-preview-impl-DQlmK_ev.js.map → markdown-preview-impl-C5D-B-9S.js.map} +1 -1
  30. package/public/assets/{memory-kn4bgMXC.js → memory-Bp-D_V80.js} +2 -2
  31. package/public/assets/{memory-kn4bgMXC.js.map → memory-Bp-D_V80.js.map} +1 -1
  32. package/public/assets/{messages-Dv3Tv7zC.js → messages-DE1QSeHI.js} +2 -2
  33. package/public/assets/{messages-Dv3Tv7zC.js.map → messages-DE1QSeHI.js.map} +1 -1
  34. package/public/assets/{orchestrators-DcNLRMzk.js → orchestrators-B0u2lQfw.js} +2 -2
  35. package/public/assets/{orchestrators-DcNLRMzk.js.map → orchestrators-B0u2lQfw.js.map} +1 -1
  36. package/public/assets/{overview-BOLzPSSC.js → overview-DpaEvvLT.js} +2 -2
  37. package/public/assets/{overview-BOLzPSSC.js.map → overview-DpaEvvLT.js.map} +1 -1
  38. package/public/assets/{pairs-B4r0egpx.js → pairs-DRp_qAu8.js} +2 -2
  39. package/public/assets/{pairs-B4r0egpx.js.map → pairs-DRp_qAu8.js.map} +1 -1
  40. package/public/assets/{prompt-settings-E7-6Osj4.js → prompt-settings-CNoBiymk.js} +2 -2
  41. package/public/assets/{prompt-settings-E7-6Osj4.js.map → prompt-settings-CNoBiymk.js.map} +1 -1
  42. package/public/assets/{registry-CTN3jOnb.js → registry-0A4QFMWC.js} +2 -2
  43. package/public/assets/{registry-CTN3jOnb.js.map → registry-0A4QFMWC.js.map} +1 -1
  44. package/public/assets/{security-BctIcBZb.js → security-C9P6yiQ3.js} +2 -2
  45. package/public/assets/{security-BctIcBZb.js.map → security-C9P6yiQ3.js.map} +1 -1
  46. package/public/assets/{select-BndvVb0A.js → select-B1PVUj6D.js} +2 -2
  47. package/public/assets/{select-BndvVb0A.js.map → select-B1PVUj6D.js.map} +1 -1
  48. package/public/assets/{settings-B_tRq-P4.js → settings-TnHlB9-t.js} +2 -2
  49. package/public/assets/{settings-B_tRq-P4.js.map → settings-TnHlB9-t.js.map} +1 -1
  50. package/public/assets/{tasks-BgTTxVET.js → tasks-1oK_ggFC.js} +2 -2
  51. package/public/assets/{tasks-BgTTxVET.js.map → tasks-1oK_ggFC.js.map} +1 -1
  52. package/public/assets/{teams-DJiLBEgF.js → teams-BBb0ZYiF.js} +2 -2
  53. package/public/assets/{teams-DJiLBEgF.js.map → teams-BBb0ZYiF.js.map} +1 -1
  54. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js → terminal-viewer-impl-DzvaRjkp.js} +2 -2
  55. package/public/assets/{terminal-viewer-impl-BLdiyCNR.js.map → terminal-viewer-impl-DzvaRjkp.js.map} +1 -1
  56. package/public/assets/types-DSwhOFcJ.js.map +1 -1
  57. package/public/assets/{user-avatar-rt59cEsc.js → user-avatar-X1pfizLv.js} +2 -2
  58. package/public/assets/{user-avatar-rt59cEsc.js.map → user-avatar-X1pfizLv.js.map} +1 -1
  59. package/public/assets/{work-queue-BYRdBuLR.js → work-queue-DQMbkSD8.js} +2 -2
  60. package/public/assets/{work-queue-BYRdBuLR.js.map → work-queue-DQMbkSD8.js.map} +1 -1
  61. package/public/assets/{workspaces-DCRT3l9H.js → workspaces-BNLP4IlV.js} +3 -3
  62. package/public/assets/{workspaces-DCRT3l9H.js.map → workspaces-BNLP4IlV.js.map} +1 -1
  63. package/public/index.html +2 -2
  64. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  65. package/runner/src/config.ts +5 -0
  66. package/src/bus.ts +3 -1
  67. package/src/db/agents.ts +3 -0
  68. package/src/db/merge-lease.ts +5 -1
  69. package/src/db/notification-subscriptions.ts +15 -0
  70. package/src/db/task-detail-projection.ts +66 -0
  71. package/src/db/task-entity.ts +53 -0
  72. package/src/mcp/tools-spawn.ts +1 -1
  73. package/src/mcp-dispatch-tools.ts +4 -1
  74. package/src/mcp-notification-subscription-tools.ts +2 -1
  75. package/src/notification-rules.ts +17 -1
  76. package/src/notification-types.ts +18 -0
  77. package/src/reviewer-pipeline.ts +13 -0
  78. package/src/routes/agents-spawn.ts +3 -2
  79. package/src/routes/agents.ts +2 -0
  80. package/src/routes/commands.ts +14 -0
  81. package/src/routes/index.ts +2 -1
  82. package/src/routes/tasks.ts +19 -3
  83. package/src/runtime-tokens.ts +4 -0
  84. package/src/security.ts +4 -0
  85. package/src/services/register-agent.ts +46 -2
  86. package/src/services/spawn-agent.ts +116 -30
  87. package/src/services/task-entity.ts +28 -6
  88. package/src/services/task-lifecycle.ts +36 -7
  89. package/src/services/task-semantic-events.ts +241 -13
  90. package/src/spawn-command.ts +3 -0
  91. package/src/workspace-auto-merge.ts +17 -0
  92. package/public/assets/PlanGraphPanel-LY-msOsr.js +0 -2
  93. package/public/assets/PlanGraphPanel-LY-msOsr.js.map +0 -1
  94. package/public/assets/chat-C3QlsFBd.js +0 -2
  95. package/public/assets/index-RCbIyk26.js.map +0 -1
package/src/bus.ts CHANGED
@@ -7,7 +7,7 @@ import { getCommand, updateCommand } from "./commands-db";
7
7
  import { emitCommandEvent } from "./command-events";
8
8
  import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
9
9
  import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
10
- import { registerAgent } from "./services/register-agent";
10
+ import { bindSpawnedTaskToReadyAgent, registerAgent } from "./services/register-agent";
11
11
  import { authContextFromBus } from "./services/auth-context";
12
12
  import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
13
13
  import { auditProviderStateTransition } from "./services/provider-state-audit";
@@ -182,6 +182,7 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
182
182
  if (!setStatus(conn.agentId, frame.payload.agentStatus, guard)) {
183
183
  return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
184
184
  }
185
+ const readyTransition = frame.payload.ready === true && !before?.ready;
185
186
  if (frame.payload.ready !== undefined && !markReady(conn.agentId, frame.payload.ready, guard)) {
186
187
  return sendError(ws, frame.id, "AGENT_NOT_FOUND", "agent not found");
187
188
  }
@@ -192,6 +193,7 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
192
193
  auditProviderStateTransition(conn.agentId, before, after);
193
194
  const timelineEvent = agentProviderTimelineEvent(after);
194
195
  auditRunnerTimelineEvent(conn.agentId, timelineEvent);
196
+ if (readyTransition) bindSpawnedTaskToReadyAgent(conn.agentId, "bus-ready");
195
197
  // A real PreCompact/SessionStart hook arrives as a timelineEvent in the
196
198
  // merged meta — clears any pending stall watch (stale events ignored).
197
199
  noteAgentTimelineEvent(conn.agentId, timelineEvent);
package/src/db/agents.ts CHANGED
@@ -22,6 +22,7 @@ import { ValidationError, getDb } from "./connection.ts";
22
22
  import { rowToAgent, rowToContextSnapshot, rowToQuotaSnapshot } from "./mappers.ts";
23
23
  import { closeOpenPairsForAgent } from "./pair-maintenance.ts";
24
24
  import { parseStringArray } from "./shared.ts";
25
+ import { removeSubscriptionsForAgent } from "./notification-subscriptions.ts";
25
26
  import { listAgentQuotaSnapshotsFromProviderStore } from "./provider-quotas.ts";
26
27
  import { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
27
28
  import { claimTaskWhere } from "./task-core.ts";
@@ -544,6 +545,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
544
545
  // Offline past the prune horizon and being deleted — a hard kill, not recoverable.
545
546
  revokeRuntimeTokensForAgent(row.id, now, "pruned");
546
547
  closeOpenPairsForAgent(row.id, now);
548
+ removeSubscriptionsForAgent(row.id);
547
549
  }
548
550
 
549
551
  getDb()
@@ -570,6 +572,7 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
570
572
  getDb().query(`UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, claim_expires_at = NULL, updated_at = ? WHERE ${claimTaskWhere()} AND claimed_by = ? AND status IN ('claimed', 'in_progress', 'blocked')`).run(now, id);
571
573
  revokeRuntimeTokensForAgent(id, now);
572
574
  closeOpenPairsForAgent(id, now);
575
+ removeSubscriptionsForAgent(id);
573
576
  return getDb().query("DELETE FROM agents WHERE id = ?").run(id).changes > 0;
574
577
  })();
575
578
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
@@ -231,7 +231,11 @@ export function acquireMergePause(input: {
231
231
 
232
232
  export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
233
233
  const current = getMergePause(id);
234
- if (!current || current.expiresAt <= now) return null;
234
+ if (!current) return null;
235
+ // Re-establish even when the pause has just expired: the maintenance reaper runs on a
236
+ // ~60s cadence, so a tardy renew that arrives before the sweep can still extend the
237
+ // hold. Returns null only when the record is already gone (reaped or replaced by a
238
+ // concurrent acquirer via ON CONFLICT UPDATE, which changes the row id).
235
239
  getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
236
240
  return getMergePause(id);
237
241
  }
@@ -109,6 +109,21 @@ export function removeSubscription(input: {
109
109
  return getDb().query(`DELETE FROM notification_subscriptions WHERE ${conditions.join(" AND ")}`).run(...params).changes;
110
110
  }
111
111
 
112
+ export function removeSubscriptionsForAgent(agentId: string): number {
113
+ return getDb()
114
+ .query("DELETE FROM notification_subscriptions WHERE agent_id = ?")
115
+ .run(cleanAgentId(agentId))
116
+ .changes;
117
+ }
118
+
119
+ export function removeSubscriptionsForTask(taskId: number): number {
120
+ if (!Number.isSafeInteger(taskId) || taskId <= 0) throw new ValidationError("taskId must be a positive integer");
121
+ return getDb()
122
+ .query("DELETE FROM notification_subscriptions WHERE json_extract(scope_match, '$.taskId') = ?")
123
+ .run(String(taskId))
124
+ .changes;
125
+ }
126
+
112
127
  export function listSubscriptions(agentId: string): NotificationSubscription[] {
113
128
  const rows = getDb().query(`
114
129
  SELECT * FROM notification_subscriptions
@@ -0,0 +1,66 @@
1
+ import type {
2
+ TaskDetail,
3
+ TaskDispatch,
4
+ TaskDispatchDecisionProjection,
5
+ TaskDispatchWorkShapeProjection,
6
+ } from "../types";
7
+ import { parseJson } from "../utils";
8
+ import { getAgent } from "./agents.ts";
9
+ import { getDb } from "./connection.ts";
10
+
11
+ function isObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ export function taskDispatchWorkShape(dispatch: TaskDispatch | undefined): TaskDispatchWorkShapeProjection {
16
+ return {
17
+ specified: dispatch !== undefined,
18
+ complexity: dispatch?.complexity ?? null,
19
+ type: dispatch?.type ?? null,
20
+ contextFootprint: dispatch?.contextFootprint ?? null,
21
+ criticality: dispatch?.criticality ?? null,
22
+ };
23
+ }
24
+
25
+ export function latestTaskDispatchDecision(id: number): TaskDispatchDecisionProjection | undefined {
26
+ const row = getDb().query(`
27
+ SELECT source, metadata, created_at FROM task_events
28
+ WHERE task_id = ? AND type = 'dispatch.resolved'
29
+ ORDER BY id DESC LIMIT 1
30
+ `).get(id) as { source: string; metadata: string; created_at: number } | undefined;
31
+ if (!row) return undefined;
32
+ const meta = parseJson<Record<string, unknown>>(row.metadata, {});
33
+ const decision = isObject(meta.decision) ? meta.decision : {};
34
+ const target = isObject(decision.target) ? decision.target : {};
35
+ const warnings = Array.isArray(decision.warnings)
36
+ ? decision.warnings.filter((item): item is string => typeof item === "string")
37
+ : [];
38
+ return {
39
+ target: {
40
+ ...(typeof target.provider === "string" ? { provider: target.provider } : {}),
41
+ ...(typeof target.model === "string" ? { model: target.model } : {}),
42
+ ...(typeof target.effort === "string" ? { effort: target.effort } : {}),
43
+ },
44
+ rationale: typeof decision.rationale === "string" ? decision.rationale : row.source,
45
+ pinned: decision.pinned === true,
46
+ ...(isObject(decision.factors) ? { factors: decision.factors } : {}),
47
+ warnings,
48
+ source: typeof meta.source === "string" ? meta.source : row.source,
49
+ decidedAt: row.created_at,
50
+ };
51
+ }
52
+
53
+ export function taskBoundWorkerModel(agentId: string | undefined): TaskDetail["boundWorkerModel"] {
54
+ if (!agentId) return undefined;
55
+ const agent = getAgent(agentId);
56
+ const model = agent?.providerCapabilities?.model;
57
+ return {
58
+ agentId,
59
+ ...(typeof model?.provider === "string" ? { provider: model.provider } : {}),
60
+ ...(typeof model?.id === "string" ? { model: model.id } : typeof model?.alias === "string" ? { model: model.alias } : {}),
61
+ ...(typeof model?.effort === "string" ? { effort: model.effort } : {}),
62
+ ...(typeof model?.source === "string" ? { source: model.source } : {}),
63
+ ...(typeof model?.confidence === "string" ? { confidence: model.confidence } : {}),
64
+ ...(typeof model?.lastUpdatedAt === "number" ? { lastUpdatedAt: model.lastUpdatedAt } : {}),
65
+ };
66
+ }
@@ -28,6 +28,7 @@ import {
28
28
  import { parseJson } from "../utils";
29
29
  import { getDb, ValidationError } from "./connection.ts";
30
30
  import { rowToTask } from "./mappers.ts";
31
+ import { latestTaskDispatchDecision, taskBoundWorkerModel, taskDispatchWorkShape } from "./task-detail-projection.ts";
31
32
  import { TASK_SELECT, insertTaskEvent, DELIVERABLE_TASK_KIND } from "./task-core.ts";
32
33
  import { associateIssue, listIssuesForEntity, removeAssociation } from "./issue-associations.ts";
33
34
  import { resolveIssueRefFromString } from "./issue-refs.ts";
@@ -483,6 +484,18 @@ export interface RecordTaskSpawnInput {
483
484
  steer?: string;
484
485
  }
485
486
 
487
+ export interface RecordTaskDispatchDecisionInput {
488
+ decision: {
489
+ target?: unknown;
490
+ rationale?: string;
491
+ pinned?: boolean;
492
+ factors?: unknown;
493
+ warnings?: readonly unknown[];
494
+ };
495
+ source: string;
496
+ actor: string;
497
+ }
498
+
486
499
  // Idempotent by spawnRequestId: a retried spawn (orchestrator re-dispatch, client retry) reusing
487
500
  // the SAME spawnRequestId must NOT append a second attribution event. The append-only `task_events`
488
501
  // is the ledger, so we check-before-insert on the spawnRequestId stamped in metadata (mirrors the
@@ -512,6 +525,35 @@ export function recordTaskSpawnRequested(id: number, input: RecordTaskSpawnInput
512
525
  return getTaskDetail(id)!;
513
526
  }
514
527
 
528
+ export function recordTaskDispatchDecision(id: number, input: RecordTaskDispatchDecisionInput): TaskDetail {
529
+ requireDeliverable(id);
530
+ const source = requireText(input.source, "source", 120);
531
+ const actor = requireText(input.actor, "actor", 200);
532
+ const target = isObject(input.decision.target) ? input.decision.target : {};
533
+ const rationale = optionalText(input.decision.rationale, "rationale", 4000) ?? "No dispatch rationale recorded";
534
+ const warnings = Array.isArray(input.decision.warnings)
535
+ ? input.decision.warnings.filter((item): item is string => typeof item === "string")
536
+ : [];
537
+ insertTaskEvent(id, {
538
+ source: actor,
539
+ type: "dispatch.resolved",
540
+ severity: warnings.length ? "warning" : "info",
541
+ title: `Dispatch resolved: ${typeof target.provider === "string" ? target.provider : "provider"}`,
542
+ body: rationale,
543
+ metadata: {
544
+ source,
545
+ decision: {
546
+ target,
547
+ rationale,
548
+ pinned: input.decision.pinned === true,
549
+ ...(isObject(input.decision.factors) ? { factors: input.decision.factors } : {}),
550
+ warnings,
551
+ },
552
+ },
553
+ }, Date.now());
554
+ return getTaskDetail(id)!;
555
+ }
556
+
515
557
  // --- Reads / projections.
516
558
  function issueLinks(id: number): TaskIssueLink[] {
517
559
  return listIssuesForEntity("task", taskEntityId(String(id))).map((item) => ({
@@ -525,15 +567,24 @@ function issueLinks(id: number): TaskIssueLink[] {
525
567
  }));
526
568
  }
527
569
 
570
+ function isObject(value: unknown): value is Record<string, unknown> {
571
+ return typeof value === "object" && value !== null && !Array.isArray(value);
572
+ }
573
+
528
574
  export function getTaskDetail(id: number): TaskDetail | null {
529
575
  const task = getRawTask(id);
530
576
  if (!task || task.kind !== DELIVERABLE_TASK_KIND) return null;
577
+ const decision = latestTaskDispatchDecision(id);
578
+ const workerModel = taskBoundWorkerModel(task.ownerAgentId);
531
579
  return {
532
580
  ...task,
533
581
  issues: issueLinks(id),
534
582
  dependsOn: listTaskDependencies(id),
535
583
  dependents: listTaskDependents(id),
536
584
  gates: latestGates(id),
585
+ dispatchWorkShape: taskDispatchWorkShape(task.dispatch),
586
+ ...(decision ? { dispatchDecision: decision } : {}),
587
+ ...(workerModel ? { boundWorkerModel: workerModel } : {}),
537
588
  };
538
589
  }
539
590
 
@@ -541,6 +592,7 @@ export interface ListDeliverableTasksFilter {
541
592
  status?: string;
542
593
  deliverableStage?: string;
543
594
  teamId?: string;
595
+ createdBy?: string;
544
596
  limit?: number;
545
597
  }
546
598
 
@@ -550,6 +602,7 @@ export function listDeliverableTasks(filter: ListDeliverableTasksFilter = {}): T
550
602
  if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
551
603
  if (filter.deliverableStage) { conditions.push("deliverable_stage = ?"); params.push(filter.deliverableStage); }
552
604
  if (filter.teamId) { conditions.push("team_id = ?"); params.push(filter.teamId); }
605
+ if (filter.createdBy) { conditions.push("created_by = ?"); params.push(filter.createdBy); }
553
606
  const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
554
607
  params.push(limit);
555
608
  return (getDb().query(`${TASK_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`).all(...params as any[]) as any[]).map(rowToTask);
@@ -24,7 +24,7 @@ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
24
24
  export const SPAWN_TOOLS: ToolDefinition[] = [
25
25
  {
26
26
  name: "relay_spawn_agent",
27
- description: `Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via \`prompt\` in the same call. Defaults to your own host (override with orchestratorId), applies profile \`${DEFAULT_SPAWN_PROFILE_NAME}\` when profile is omitted, and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).`,
27
+ description: `Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via \`prompt\` or binding it to a durable Task via \`taskId\` + \`steer\` so Relay hydrates the first turn server-side. Defaults to your own host (override with orchestratorId), applies profile \`${DEFAULT_SPAWN_PROFILE_NAME}\` when profile is omitted, and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).`,
28
28
  requiredScopes: ["command:spawn"],
29
29
  inputSchema: {
30
30
  type: "object",
@@ -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 {
@@ -145,6 +145,9 @@ export function runTaskDispatch(input: RunTaskDispatchInput): RunTaskDispatchRes
145
145
  if (!shape && !inlineShape) shape = {};
146
146
 
147
147
  const decision = dispatchTask(shape, pin);
148
+ if (taskId !== undefined) {
149
+ recordTaskEntityDispatchDecision(taskId, { decision, source: "dispatch", actor });
150
+ }
148
151
 
149
152
  let reviewTaskId: number | undefined;
150
153
  if (decision.review && createReview && taskId !== undefined) {
@@ -21,8 +21,9 @@ import { getTaskEntity } from "./services/task-entity";
21
21
  import { cleanString } from "./validation";
22
22
  import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
23
23
  import { previewNotificationRouting, whyNotified } from "./notification-router";
24
+ import { TASK_SEMANTIC_NOTIFICATION_TYPES } from "./services/task-semantic-events";
24
25
 
25
- const TASK_NOTIFICATION_TYPES = ["task.created", "task.started", "task.blocked", "task.needs_decision", "task.landed", "task.closed"] as const satisfies readonly NotificationType[];
26
+ const TASK_NOTIFICATION_TYPES = TASK_SEMANTIC_NOTIFICATION_TYPES as readonly NotificationType[];
26
27
 
27
28
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
28
29
  {
@@ -272,6 +272,22 @@ registerDefaultAudienceRules("message.new", [
272
272
  audiencePredicates.threadParticipant(),
273
273
  ]);
274
274
 
275
- for (const type of ["task.created", "task.started", "task.blocked", "task.needs_decision", "task.landed", "task.closed"] as const) {
275
+ for (const type of [
276
+ "task.created",
277
+ "task.started",
278
+ "task.blocked",
279
+ "task.needs_decision",
280
+ "task.gate_failed",
281
+ "task.committed_on_branch",
282
+ "task.review_requested",
283
+ "task.land_conflict",
284
+ "task.landed",
285
+ "task.landed_on_main",
286
+ "task.closed",
287
+ "task.working",
288
+ "task.done",
289
+ "task.deploying",
290
+ "task.deployed",
291
+ ] as const) {
276
292
  registerDefaultAudienceRules(type, []);
277
293
  }
@@ -7,8 +7,17 @@ export type NotificationType =
7
7
  | "task.started"
8
8
  | "task.blocked"
9
9
  | "task.needs_decision"
10
+ | "task.gate_failed"
11
+ | "task.committed_on_branch"
12
+ | "task.review_requested"
13
+ | "task.land_conflict"
10
14
  | "task.landed"
15
+ | "task.landed_on_main"
11
16
  | "task.closed"
17
+ | "task.working"
18
+ | "task.done"
19
+ | "task.deploying"
20
+ | "task.deployed"
12
21
  | "agent.ready"
13
22
  | "agent.exited"
14
23
  | "agent.spawn_failed"
@@ -94,8 +103,17 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
94
103
  "task.started": null,
95
104
  "task.blocked": null,
96
105
  "task.needs_decision": null,
106
+ "task.gate_failed": null,
107
+ "task.committed_on_branch": null,
108
+ "task.review_requested": null,
109
+ "task.land_conflict": null,
97
110
  "task.landed": null,
111
+ "task.landed_on_main": null,
98
112
  "task.closed": null,
113
+ "task.working": null,
114
+ "task.done": null,
115
+ "task.deploying": null,
116
+ "task.deployed": null,
99
117
  "agent.ready": null,
100
118
  "agent.exited": null,
101
119
  "agent.spawn_failed": null,
@@ -5,6 +5,7 @@ import { getSpawnPolicy } from "./config-store";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
6
  import { routeNotification } from "./notification-router";
7
7
  import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
8
+ import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
8
9
  import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
9
10
 
10
11
  type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
@@ -59,6 +60,18 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
59
60
  compareRef,
60
61
  });
61
62
  if (!updated) return false;
63
+ emitWorkspaceTaskSemanticNotifications({
64
+ workspace: updated,
65
+ transition: "review-requested",
66
+ actor: "reviewer-pipeline",
67
+ occurrenceKey: `workspace-review:${workspace.id}:${command.id}`,
68
+ outcome: {
69
+ reviewer: target,
70
+ prUrl,
71
+ prNumber,
72
+ compareRef,
73
+ },
74
+ });
62
75
 
63
76
  // Obligation (ttl null by type default): a review request must reach the reviewer even if they're
64
77
  // offline a while — it's not a bystander notice.
@@ -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!);
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "../compaction-watch";
11
11
  import { isRecord } from "agent-relay-sdk";
12
12
  import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
+ import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
14
15
  import { ServiceAuthError } from "../services/errors";
15
16
  import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
17
  import { applyIdleRefreshResult } from "../maintenance/workspaces";
@@ -305,6 +306,19 @@ export const patchCommand: Handler = async (req, params) => {
305
306
  mergeError: command.error ?? "merge failed",
306
307
  ...noteMergeNoProgress(current.metadata),
307
308
  });
309
+ if (nextStatus === "conflict") {
310
+ emitWorkspaceTaskSemanticNotifications({
311
+ workspace: current,
312
+ transition: "land-conflict",
313
+ actor: "workspace-merge",
314
+ occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
315
+ outcome: {
316
+ reason: command.error ?? "merge failed",
317
+ commandId: command.id,
318
+ status: nextStatus,
319
+ },
320
+ });
321
+ }
308
322
  notifyBranchLandFailed({
309
323
  workspace: current,
310
324
  commandId: command.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, getTaskMemoryRoute, getTaskHistoryRoute, patchTaskEntity, postTaskEntityStatus, postTaskEntityStage, postTaskEntityGate, postTaskDependency, deleteTaskDependency, postTaskRef, deleteTaskRef, postTaskAssign, postTaskComment, getTaskCloseBridge, postTaskDispatch } 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,6 +350,7 @@ 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),
@@ -1,7 +1,8 @@
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 } from "../security";
4
+ import { getComponentAuth, isFullReachRequest } from "../security";
5
+ import { authContextFromRequest } from "../services/auth-context";
5
6
  import { agentIdFromComponent, isTaskDispatchAuthorized, isTaskReadAuthorized } from "../task-authz";
6
7
  import { dispatchShapeArg, pinArg, runTaskDispatch } from "../mcp-dispatch-tools";
7
8
  import { ValidationError, claimTask, getMessage, getTask, listTaskEvents, listTasks, recordTaskEvent, renewTaskClaim, updateTaskStatus } from "../db";
@@ -18,7 +19,9 @@ import { errMessage, isRecord, TASK_DELIVERABLE_STAGES, type TaskBlocker, type T
18
19
  import { type AgentSessionGuard, type TaskStatus, type TaskStatusInput } from "../types";
19
20
 
20
21
  function actorOf(req: Request, fallback?: unknown): string {
21
- 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");
22
25
  }
23
26
 
24
27
  function taskDetailOr404(id: number) {
@@ -65,6 +68,19 @@ export const getTasks: Handler = (req) => {
65
68
  }));
66
69
  };
67
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
+ createdBy: url.searchParams.get("createdBy") ?? undefined,
80
+ limit: limitRaw ?? 100,
81
+ }));
82
+ };
83
+
68
84
  // --- #834 durable Task entity (kind='deliverable') HTTP surface ---
69
85
 
70
86
  export const postTaskCreate: Handler = async (req) => {
@@ -328,7 +344,7 @@ export const postTaskDispatch: Handler = async (req, params) => {
328
344
  const component = getComponentAuth(req);
329
345
  const agentId = component ? agentIdFromComponent(component) : undefined;
330
346
  const scopes = component?.scope ?? [];
331
- if (!isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
347
+ if (!isFullReachRequest(req) && !isTaskDispatchAuthorized(id, agentId, scopes)) return error("forbidden", 403);
332
348
  const parsed = await parseBody<Record<string, unknown>>(req);
333
349
  if (!parsed.ok) return error(parsed.error, parsed.status);
334
350
  const body = parsed.body ?? {};
@@ -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 = {
@@ -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