agent-relay-server 0.107.0 → 0.108.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 (143) hide show
  1. package/docs/openapi.json +475 -1
  2. package/package.json +4 -4
  3. package/public/assets/{MessageBubble-PEdvVh_m.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DSts7Rtq.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DSts7Rtq.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-BcYf8YNO.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-BcYf8YNO.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-CgRFJyks.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-CgRFJyks.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-C3OTFIXo.js → analytics-B9USQLl5.js} +2 -2
  14. package/public/assets/{analytics-C3OTFIXo.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-CH9YGYoE.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-CH9YGYoE.js.map → automation-XRePs1dT.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
  18. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
  19. package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
  20. package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
  21. package/public/assets/{chat-BLcm2Aht.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-BLcm2Aht.js.map → chat-D_0uHgB6.js.map} +1 -1
  23. package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
  24. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
  25. package/public/assets/display-1J__Ti1K.js +3 -0
  26. package/public/assets/display-1J__Ti1K.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
  29. package/public/assets/index-DlBSuzd2.js +23 -0
  30. package/public/assets/index-DlBSuzd2.js.map +1 -0
  31. package/public/assets/{insights-DklrIhb6.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-DklrIhb6.js.map → insights-BzNoYvDe.js.map} +1 -1
  33. package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
  34. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
  35. package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-CVca-s3t.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-BPW4OQo9.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-BzuDTHC5.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-BzuDTHC5.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-_W24PNvT.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-_W24PNvT.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-Dyvm1qpT.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-Dyvm1qpT.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-B8x1RCNd.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-B8x1RCNd.js.map → pairs-ChyeBs0Z.js.map} +1 -1
  51. package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
  52. package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
  53. package/public/assets/{prompt-settings-xVXmvxB5.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-ypiICauZ.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-ypiICauZ.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-BfOQ6T5r.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-BfOQ6T5r.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-Dx8Ro-A3.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-Dx8Ro-A3.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/{settings-87clTx4N.js → settings-DGgwr9WH.js} +2 -2
  62. package/public/assets/{settings-87clTx4N.js.map → settings-DGgwr9WH.js.map} +1 -1
  63. package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
  64. package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
  65. package/public/assets/{tasks-1Kvp53wh.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-1Kvp53wh.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-DJWp4AKi.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-DJWp4AKi.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
  71. package/public/assets/types-BiJFuTiL.js +2 -0
  72. package/public/assets/types-BiJFuTiL.js.map +1 -0
  73. package/public/assets/{user-avatar-CfAUS3PZ.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BH2-mARc.js.map} +1 -1
  77. package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
  78. package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
  79. package/public/index.html +4 -4
  80. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  81. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  82. package/src/branch-landed.ts +17 -3
  83. package/src/config-store.ts +12 -1
  84. package/src/db/agents.ts +3 -2
  85. package/src/db/continuations.ts +13 -3
  86. package/src/db/index.ts +3 -0
  87. package/src/db/issue-lifecycle.ts +12 -0
  88. package/src/db/mappers.ts +17 -0
  89. package/src/db/messages.ts +9 -4
  90. package/src/db/migrations.ts +91 -1
  91. package/src/db/notification-subscriptions.ts +1 -1
  92. package/src/db/prompt-templates.ts +19 -0
  93. package/src/db/schema.ts +67 -1
  94. package/src/db/stats.ts +3 -2
  95. package/src/db/task-comment-through.ts +112 -0
  96. package/src/db/task-core.ts +14 -1
  97. package/src/db/task-entity.ts +629 -0
  98. package/src/db/task-maintenance.ts +4 -3
  99. package/src/db/task-memory.ts +180 -0
  100. package/src/db/tasks.ts +24 -10
  101. package/src/issue-tracker/adapter.ts +15 -0
  102. package/src/issue-tracker/github.ts +40 -2
  103. package/src/issue-tracker/index.ts +1 -0
  104. package/src/mcp/index.ts +12 -1
  105. package/src/mcp/tools-spawn.ts +4 -0
  106. package/src/mcp-dispatch-tools.ts +164 -0
  107. package/src/mcp-notification-subscription-tools.ts +62 -4
  108. package/src/mcp-plan-tools.ts +30 -3
  109. package/src/mcp-release-tools.ts +33 -0
  110. package/src/mcp-task-memory-tools.ts +132 -0
  111. package/src/mcp-task-tools.ts +232 -0
  112. package/src/mcp-task-weave-tools.ts +66 -0
  113. package/src/notification-router.ts +24 -2
  114. package/src/notification-rules.ts +4 -0
  115. package/src/notification-types.ts +23 -0
  116. package/src/prompt-resolver.ts +66 -3
  117. package/src/routes/agents-spawn.ts +10 -0
  118. package/src/routes/index.ts +14 -1
  119. package/src/routes/plans.ts +10 -2
  120. package/src/routes/tasks.ts +276 -2
  121. package/src/routing-policy-store.ts +147 -2
  122. package/src/self-resume.ts +13 -2
  123. package/src/services/issue-lifecycle.ts +9 -1
  124. package/src/services/plan-graph.ts +107 -9
  125. package/src/services/release-readiness.ts +142 -0
  126. package/src/services/send-message.ts +31 -0
  127. package/src/services/spawn-agent.ts +81 -4
  128. package/src/services/task-context-hydration.ts +361 -0
  129. package/src/services/task-entity.ts +149 -0
  130. package/src/services/task-issue-weave.ts +558 -0
  131. package/src/services/task-lifecycle.ts +465 -0
  132. package/src/services/task-plan-view.ts +178 -0
  133. package/src/services/task-semantic-events.ts +137 -0
  134. package/src/services/transient-agent-reaper.ts +55 -22
  135. package/src/spawn-targets.ts +27 -2
  136. package/src/task-dispatcher.ts +461 -0
  137. package/src/upgrade-install-verify.ts +20 -1
  138. package/public/assets/display-BWi8DQCp.js +0 -3
  139. package/public/assets/display-BWi8DQCp.js.map +0 -1
  140. package/public/assets/index-3ydeNO0c.js +0 -23
  141. package/public/assets/index-3ydeNO0c.js.map +0 -1
  142. package/public/assets/types-DorDDvEb.js +0 -2
  143. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -0,0 +1,164 @@
1
+ // #839 — MCP surface for the declarative task dispatcher.
2
+ //
3
+ // `relay_task_dispatch` is the EXPLICIT dispatch entrypoint (the seam the issue asks for):
4
+ // the coordinator declares a Task's work-shape and relay returns a concrete spawn
5
+ // (provider/model/effort) plus the rationale behind it. It does NOT spawn — that stays
6
+ // with the coordinator / #838 (spawn-with-task) — so wiring it can never perturb the live
7
+ // spawn path. When the policy's auto-review threshold is met it also creates the dependent
8
+ // review Task via the #834 dependency API (opt out with createReview:false).
9
+ import { McpNotFoundError } from "./mcp-errors";
10
+ import { ValidationError } from "./db";
11
+ import { authContextFromMcp } from "./services/auth-context";
12
+ import { addTaskEntityDependency, createTaskEntity, getTaskEntity } from "./services/task-entity";
13
+ import { dispatchTask, type DispatchPin } from "./task-dispatcher";
14
+ import { cleanString } from "./validation";
15
+ import {
16
+ isRecord,
17
+ isSpawnProvider,
18
+ SPAWN_PROVIDERS,
19
+ TASK_DISPATCH_COMPLEXITIES,
20
+ TASK_DISPATCH_CRITICALITIES,
21
+ TASK_DISPATCH_FOOTPRINTS,
22
+ TASK_DISPATCH_TYPES,
23
+ type TaskDispatch,
24
+ } from "./types";
25
+
26
+ const DISPATCH_SHAPE_SCHEMA = {
27
+ type: "object",
28
+ properties: {
29
+ complexity: { type: "string", enum: TASK_DISPATCH_COMPLEXITIES },
30
+ type: { type: "string", enum: TASK_DISPATCH_TYPES },
31
+ contextFootprint: { type: "string", enum: TASK_DISPATCH_FOOTPRINTS },
32
+ criticality: { type: "string", enum: TASK_DISPATCH_CRITICALITIES },
33
+ },
34
+ additionalProperties: false,
35
+ } as const;
36
+
37
+ const PIN_SCHEMA = {
38
+ type: "object",
39
+ description: "Explicit per-case override; any field set wins over the policy-derived target.",
40
+ properties: {
41
+ provider: { type: "string", enum: SPAWN_PROVIDERS },
42
+ model: { type: "string" },
43
+ effort: { type: "string" },
44
+ },
45
+ additionalProperties: false,
46
+ } as const;
47
+
48
+ const TASK_ID_PROP = { oneOf: [{ type: "integer", minimum: 1 }, { type: "string" }] } as const;
49
+
50
+ export const DISPATCH_TOOLS = [
51
+ {
52
+ name: "relay_task_dispatch",
53
+ description:
54
+ "Map a Task's work-shape ({complexity,type,contextFootprint,criticality}) + live quota/bands onto a concrete spawn (provider/model/effort) with a rationale. Reads a Task's dispatch field (taskId) and/or an inline dispatch override; overridable by an explicit model/provider pin. Returns the decision — it does NOT spawn (that's the coordinator / #838). When the policy's auto-review threshold is met it creates a dependent review Task (opt out with createReview:false).",
55
+ // Plans a dispatch (read) and may create a dependent review Task (write).
56
+ requiredScopes: ["task:read", "task:write"],
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ taskId: TASK_ID_PROP,
61
+ dispatch: DISPATCH_SHAPE_SCHEMA,
62
+ pin: PIN_SCHEMA,
63
+ createReview: { type: "boolean", description: "When a taskId is given and the auto-review threshold is met, create the dependent review Task. Default true." },
64
+ },
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
69
+
70
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
71
+
72
+ function taskIdArg(value: unknown): number {
73
+ const n = typeof value === "string" ? Number(value) : value;
74
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError("taskId must be a positive integer");
75
+ return n;
76
+ }
77
+
78
+ function pinArg(value: unknown): DispatchPin {
79
+ if (value === undefined || value === null) return {};
80
+ if (!isRecord(value)) throw new ValidationError("pin must be an object");
81
+ const pin: DispatchPin = {};
82
+ if (value.provider !== undefined) {
83
+ const provider = cleanString(value.provider, "pin.provider", { max: 80 });
84
+ if (provider) {
85
+ if (!isSpawnProvider(provider)) throw new ValidationError(`pin.provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
86
+ pin.provider = provider;
87
+ }
88
+ }
89
+ const model = cleanString(value.model, "pin.model", { max: 200 });
90
+ if (model) pin.model = model;
91
+ const effort = cleanString(value.effort, "pin.effort", { max: 80 });
92
+ if (effort) pin.effort = effort;
93
+ return pin;
94
+ }
95
+
96
+ function dispatchShapeArg(value: unknown): TaskDispatch | undefined {
97
+ if (value === undefined || value === null) return undefined;
98
+ if (!isRecord(value)) throw new ValidationError("dispatch must be an object");
99
+ return value as TaskDispatch;
100
+ }
101
+
102
+ // The first dependent Task of the build that is itself a review (dispatch.type='review'), if any.
103
+ // Makes auto-review creation idempotent across repeated dispatches of the same Task.
104
+ function findExistingReviewDependent(dependentIds: number[]): number | undefined {
105
+ for (const id of dependentIds) {
106
+ if (getTaskEntity(id)?.dispatch?.type === "review") return id;
107
+ }
108
+ return undefined;
109
+ }
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}`);
115
+
116
+ const hasTaskId = args.taskId !== undefined && args.taskId !== null;
117
+ const taskId = hasTaskId ? taskIdArg(args.taskId) : undefined;
118
+ const inlineShape = dispatchShapeArg(args.dispatch);
119
+
120
+ let parentTitle: string | undefined;
121
+ let parentTeamId: string | undefined;
122
+ let parentDependents: number[] = [];
123
+ let shape = inlineShape;
124
+ if (taskId !== undefined) {
125
+ const task = getTaskEntity(taskId);
126
+ if (!task) throw new McpNotFoundError("task not found");
127
+ parentTitle = task.title;
128
+ parentTeamId = task.teamId;
129
+ parentDependents = task.dependents;
130
+ // Inline dispatch (if supplied) overrides the stored work-shape for this call.
131
+ shape = inlineShape ?? task.dispatch;
132
+ }
133
+ if (!shape && !inlineShape) shape = {};
134
+
135
+ const decision = dispatchTask(shape, pinArg(args.pin));
136
+
137
+ let reviewTaskId: number | undefined;
138
+ const createReview = args.createReview !== false; // default true
139
+ 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
+ const existing = findExistingReviewDependent(parentDependents);
143
+ if (existing !== undefined) {
144
+ reviewTaskId = existing;
145
+ } else {
146
+ const review = createTaskEntity({
147
+ title: `Review: ${parentTitle ?? `task #${taskId}`}`,
148
+ intent: `Adversarial review of task #${taskId}${parentTitle ? ` — ${parentTitle}` : ""}. Triggered: ${decision.review.reason}.`,
149
+ dispatch: decision.review.dispatch,
150
+ createdBy: actor,
151
+ ...(parentTeamId ? { teamId: parentTeamId } : {}),
152
+ });
153
+ // The review Task depends on the build Task (build must complete first).
154
+ addTaskEntityDependency(review.id, taskId, actor);
155
+ reviewTaskId = review.id;
156
+ }
157
+ }
158
+
159
+ return {
160
+ ...(taskId !== undefined ? { taskId } : {}),
161
+ decision: decision as unknown as Record<string, unknown>,
162
+ ...(reviewTaskId !== undefined ? { reviewTaskId } : {}),
163
+ };
164
+ }
@@ -16,11 +16,14 @@ import {
16
16
  } from "./db/notification-subscriptions";
17
17
  import { ValidationError } from "./db";
18
18
  import { McpAuthError, McpNotFoundError } from "./mcp-errors";
19
- import { authContextFromMcp } from "./services/auth-context";
19
+ import { authContextFromMcp, type AuthContext } from "./services/auth-context";
20
+ import { getTaskEntity } from "./services/task-entity";
20
21
  import { cleanString } from "./validation";
21
22
  import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
22
23
  import { previewNotificationRouting, whyNotified } from "./notification-router";
23
24
 
25
+ const TASK_NOTIFICATION_TYPES = ["task.created", "task.started", "task.blocked", "task.needs_decision", "task.landed", "task.closed"] as const satisfies readonly NotificationType[];
26
+
24
27
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
25
28
  {
26
29
  name: "relay_subscribe",
@@ -32,6 +35,7 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
32
35
  type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
33
36
  mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
34
37
  scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
38
+ taskId: { type: "integer", minimum: 1, description: "Subscribe to semantic transitions for one durable Task." },
35
39
  streams: { type: "array", items: { type: "string", enum: CHILD_STREAMS }, description: "Child streams to opt into. responses is always on and is not persisted." },
36
40
  childAgentId: { type: "string", description: "Limit child stream subscriptions to one spawned child." },
37
41
  teamId: { type: "string", description: "Limit child stream subscriptions to children on a team." },
@@ -51,6 +55,7 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
51
55
  type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
52
56
  mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
53
57
  scope: { type: "object" },
58
+ taskId: { type: "integer", minimum: 1 },
54
59
  streams: { type: "array", items: { type: "string", enum: CHILD_STREAMS } },
55
60
  childAgentId: { type: "string" },
56
61
  teamId: { type: "string" },
@@ -105,7 +110,8 @@ export function relayNotificationSubscriptionTool(
105
110
  name: string,
106
111
  args: Record<string, unknown>,
107
112
  ): Record<string, unknown> {
108
- const agentId = callerAgentId(auth);
113
+ const ctx = authContextFromMcp(auth);
114
+ const agentId = callerAgentId(ctx);
109
115
  if (name === "relay_subscribe") {
110
116
  if (Object.prototype.hasOwnProperty.call(args, "streams")) {
111
117
  const scope = cleanChildStreamScope(args, agentId);
@@ -127,6 +133,17 @@ export function relayNotificationSubscriptionTool(
127
133
  },
128
134
  };
129
135
  }
136
+ if (Object.prototype.hasOwnProperty.call(args, "taskId")) {
137
+ const scope = taskScope(ctx, args.taskId, args.scope);
138
+ const mode = cleanModeOrDefault(args.mode);
139
+ const subscriptions = TASK_NOTIFICATION_TYPES.map((type) => addSubscription({
140
+ agentId,
141
+ type,
142
+ mode,
143
+ scopeMatch: scope,
144
+ }));
145
+ return { subscriptions, taskFilter: scope };
146
+ }
130
147
  if (args.type === undefined || args.type === null) throw new ValidationError("type or streams required");
131
148
  const subscription = addSubscription({
132
149
  agentId,
@@ -153,6 +170,22 @@ export function relayNotificationSubscriptionTool(
153
170
  }
154
171
  return { removed, responsesAlwaysOn: true };
155
172
  }
173
+ if (Object.prototype.hasOwnProperty.call(args, "taskId")) {
174
+ const scope = taskScope(ctx, args.taskId, args.scope);
175
+ const types = args.type === undefined || args.type === null
176
+ ? TASK_NOTIFICATION_TYPES
177
+ : [cleanSubscriptionType(args.type)];
178
+ let removed = 0;
179
+ for (const type of types) {
180
+ removed += removeSubscription({
181
+ agentId,
182
+ type,
183
+ mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
184
+ scopeMatch: scope,
185
+ });
186
+ }
187
+ return { removed };
188
+ }
156
189
  const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
157
190
  if (!id && !type) throw new ValidationError("id or type required");
158
191
  const removed = removeSubscription({
@@ -191,8 +224,7 @@ export function relayNotificationSubscriptionTool(
191
224
  throw new ValidationError(`unknown tool: ${name}`);
192
225
  }
193
226
 
194
- function callerAgentId(auth: McpAuthLike): string {
195
- const ctx = authContextFromMcp(auth);
227
+ function callerAgentId(ctx: AuthContext): string {
196
228
  if (!ctx.callerAgentId) throw new McpAuthError("notification subscriptions require a single caller agent identity");
197
229
  return ctx.callerAgentId;
198
230
  }
@@ -222,6 +254,32 @@ function cleanMessageId(value: unknown): number {
222
254
  return value;
223
255
  }
224
256
 
257
+ function cleanPositiveInteger(value: unknown, field: string): number {
258
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
259
+ throw new ValidationError(`${field} must be a positive integer`);
260
+ }
261
+ return value;
262
+ }
263
+
264
+ function taskScope(ctx: AuthContext, taskIdValue: unknown, explicitScope: unknown): Partial<NotificationScope> {
265
+ const taskId = cleanPositiveInteger(taskIdValue, "taskId");
266
+ assertTaskReadable(ctx, taskId);
267
+ const scope = cleanScope(explicitScope) ?? {};
268
+ if (scope.taskId && scope.taskId !== String(taskId)) throw new ValidationError("scope.taskId must match taskId");
269
+ return { ...scope, taskId: String(taskId) };
270
+ }
271
+
272
+ function assertTaskReadable(ctx: AuthContext, taskId: number): void {
273
+ if (!hasTaskReadScope(ctx)) throw new McpAuthError("task subscription requires task:read");
274
+ if (!getTaskEntity(taskId)) throw new McpNotFoundError("task not found");
275
+ }
276
+
277
+ function hasTaskReadScope(ctx: AuthContext): boolean {
278
+ const scopes = ctx.scopes ?? [];
279
+ if (scopes.includes("*") || scopes.includes("admin:*")) return true;
280
+ return scopes.includes("task:read") || scopes.includes("task:write") || scopes.includes("task:*");
281
+ }
282
+
225
283
  function cleanChildStreams(value: unknown): ChildStream[] {
226
284
  if (!Array.isArray(value) || value.length === 0) throw new ValidationError("streams must be a non-empty array");
227
285
  return [...new Set(value.map(cleanChildStream))];
@@ -4,6 +4,7 @@ import { authContextFromMcp } from "./services/auth-context";
4
4
  import {
5
5
  addPlanEdge,
6
6
  addPlanNode,
7
+ bindPlanNodeToTask,
7
8
  createPlan,
8
9
  deletePlanGraph,
9
10
  getPlanGraph,
@@ -14,6 +15,16 @@ import {
14
15
  import { isRecord } from "agent-relay-sdk";
15
16
 
16
17
  const PLAN_STATUSES = ["pending", "in-progress", "landed", "blocked"] as const;
18
+ // #836: refs.taskId binds a plan node to a durable Task (the node then projects its
19
+ // label/scopeBlurb/status from that Task — no double-bookkeeping); refs.issueId links a GitHub issue.
20
+ const PLAN_NODE_REFS_SCHEMA = {
21
+ type: "object",
22
+ properties: {
23
+ taskId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
24
+ issueId: { type: "string" },
25
+ },
26
+ additionalProperties: false,
27
+ } as const;
17
28
  const PLAN_NODE_SCHEMA = {
18
29
  type: "object",
19
30
  properties: {
@@ -28,6 +39,7 @@ const PLAN_NODE_SCHEMA = {
28
39
  lane: { type: "string" },
29
40
  status: { type: "string", enum: PLAN_STATUSES },
30
41
  statusSource: { type: "string", enum: ["manual", "bound"] },
42
+ refs: PLAN_NODE_REFS_SCHEMA,
31
43
  blackboardRefs: { type: "array", items: { type: "string" } },
32
44
  },
33
45
  required: ["id", "label"],
@@ -76,6 +88,7 @@ export const PLAN_TOOLS = [
76
88
  lane: { oneOf: [{ type: "string" }, { type: "null" }] },
77
89
  status: { type: "string", enum: PLAN_STATUSES },
78
90
  statusSource: { type: "string", enum: ["manual", "bound"] },
91
+ refs: { oneOf: [PLAN_NODE_REFS_SCHEMA, { type: "null" }] },
79
92
  blackboardRefs: { oneOf: [{ type: "array", items: { type: "string" } }, { type: "null" }] },
80
93
  },
81
94
  required: ["planId", "nodeId"],
@@ -86,13 +99,19 @@ export const PLAN_TOOLS = [
86
99
  { name: "relay_plan_remove_node", description: "Remove a node and its connected edges from a plan DAG.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, nodeId: { type: "string" } }, required: ["planId", "nodeId"], additionalProperties: false } },
87
100
  { name: "relay_plan_remove_edge", description: "Remove a dependency edge from a plan DAG.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["planId", "from", "to"], additionalProperties: false } },
88
101
  { name: "relay_plan_delete", description: "Delete a persisted plan DAG and archive its latest version.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" } }, required: ["planId"], additionalProperties: false } },
102
+ {
103
+ name: "relay_plan_bind_node",
104
+ description: "Bind a plan node to a durable Task (#836). Pass taskId to link an existing deliverable Task, or omit it to mint one from the node's label/scopeBlurb/status. The bound node then projects its label/scopeBlurb/status from the Task — one object, no double-bookkeeping — and edges between bound nodes become task→task dependencies.",
105
+ requiredScopes: ["plans:write"],
106
+ inputSchema: { type: "object", properties: { planId: { type: "string" }, nodeId: { type: "string" }, taskId: { type: "integer", minimum: 1 } }, required: ["planId", "nodeId"], additionalProperties: false },
107
+ },
89
108
  {
90
109
  name: "relay_plan_get",
91
- description: "Fetch a plan by planId, teamId, or by threadId/channel.",
110
+ description: "Fetch a plan by planId, teamId, or by threadId/channel. Bound nodes project from their Task. Pass view:'tasks' with a teamId to get the pure task-backed DAG synthesized from deliverable Tasks + task_dependencies (no plan row required).",
92
111
  requiredScopes: ["plans:read"],
93
112
  inputSchema: {
94
113
  type: "object",
95
- properties: { planId: { type: "string" }, teamId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" } },
114
+ properties: { planId: { type: "string" }, teamId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" }, view: { type: "string", enum: ["plan", "tasks"] } },
96
115
  additionalProperties: false,
97
116
  },
98
117
  },
@@ -117,8 +136,16 @@ export function relayPlanTool(auth: McpAuthLike, name: string, args: Record<stri
117
136
  if (name === "relay_plan_remove_node") return removePlanNode(stringArg(args.planId, "planId"), stringArg(args.nodeId, "nodeId"), ctx) as unknown as Record<string, unknown>;
118
137
  if (name === "relay_plan_remove_edge") return removePlanEdge(stringArg(args.planId, "planId"), { from: args.from, to: args.to }, ctx) as unknown as Record<string, unknown>;
119
138
  if (name === "relay_plan_delete") return deletePlanGraph(stringArg(args.planId, "planId"), ctx);
139
+ if (name === "relay_plan_bind_node") {
140
+ return bindPlanNodeToTask(
141
+ stringArg(args.planId, "planId"),
142
+ stringArg(args.nodeId, "nodeId"),
143
+ { ...(typeof args.taskId === "number" ? { taskId: args.taskId } : {}) },
144
+ ctx,
145
+ ) as unknown as Record<string, unknown>;
146
+ }
120
147
  if (name === "relay_plan_get") {
121
- const plan = getPlanGraph({ planId: args.planId, teamId: args.teamId, threadId: args.threadId, channel: args.channel });
148
+ const plan = getPlanGraph({ planId: args.planId, teamId: args.teamId, threadId: args.threadId, channel: args.channel, view: args.view });
122
149
  if (!plan) throw new McpNotFoundError("plan not found");
123
150
  return plan as unknown as Record<string, unknown>;
124
151
  }
@@ -0,0 +1,33 @@
1
+ import { ValidationError } from "./db";
2
+ import { releaseReadinessReport } from "./services/release-readiness";
3
+ import { cleanString } from "./validation";
4
+ import { isRecord } from "./types";
5
+
6
+ export const RELEASE_TOOLS = [
7
+ {
8
+ name: "relay_release_readiness",
9
+ description: "Report landed-on-main deliverable Tasks that have not yet deployed/released, with linked issues, commit refs, and a conservative suggested release bump.",
10
+ requiredScopes: ["task:read"],
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ teamId: { type: "string" },
15
+ limit: { type: "integer", minimum: 1, maximum: 500 },
16
+ },
17
+ additionalProperties: false,
18
+ },
19
+ },
20
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
21
+
22
+ export function relayReleaseTool(name: string, args: Record<string, unknown>): Record<string, unknown> {
23
+ if (name !== "relay_release_readiness") throw new ValidationError(`unknown tool: ${name}`);
24
+ if (!isRecord(args)) throw new ValidationError("arguments must be an object");
25
+ const limit = args.limit === undefined ? undefined : Number(args.limit);
26
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)) {
27
+ throw new ValidationError("limit must be between 1 and 500");
28
+ }
29
+ return releaseReadinessReport({
30
+ teamId: cleanString(args.teamId, "teamId", { max: 200 }),
31
+ limit,
32
+ }) as unknown as Record<string, unknown>;
33
+ }
@@ -0,0 +1,132 @@
1
+ import { stringValue } from "agent-relay-sdk";
2
+ import {
3
+ appendTaskMemoryDecision,
4
+ appendTaskMemoryRuledOut,
5
+ deliverableTaskIdsByOwnerAgent,
6
+ deliverableTaskIdsBySpawnRequestId,
7
+ getAgent,
8
+ readTaskMemoryEnvelope,
9
+ replaceTaskMemoryWorkingState,
10
+ ValidationError,
11
+ } from "./db";
12
+ import { McpAuthError } from "./mcp-errors";
13
+ import { callerAgentId } from "./mcp/identity";
14
+ import { optionalPositiveInt, stringField } from "./mcp/validation";
15
+ import { renderContinuation } from "./self-resume";
16
+ import type { McpAuthContext, ToolDefinition } from "./mcp/types";
17
+
18
+ const TASK_ID_PROP = { type: "integer", minimum: 1, description: "Task id. Optional when the caller is bound to exactly one Task." } as const;
19
+
20
+ export const TASK_MEMORY_TOOLS = [
21
+ {
22
+ name: "relay_task_memory_read",
23
+ description: "Read the task-bound continuation envelope for a durable Task. If taskId is omitted, resolves the caller's bound Task; if provided, the Task must still be bound to the caller unless the caller has an admin scope.",
24
+ requiredScopes: ["task:read"],
25
+ inputSchema: { type: "object", properties: { taskId: TASK_ID_PROP }, additionalProperties: false },
26
+ },
27
+ {
28
+ name: "relay_task_memory_replace_working_state",
29
+ description: "Replace the caller-bound Task's current working-state memory. This is replaceable; later writes overwrite it.",
30
+ requiredScopes: ["task:write"],
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: { taskId: TASK_ID_PROP, workingState: { type: "string" } },
34
+ required: ["workingState"],
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ {
39
+ name: "relay_task_memory_append_ruled_out",
40
+ description: "Append dead ends to the caller-bound Task's ruled-out ledger. Append-only: existing entries cannot be edited through this tool.",
41
+ requiredScopes: ["task:write"],
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ taskId: TASK_ID_PROP,
46
+ ruledOut: {
47
+ type: "array",
48
+ items: {
49
+ oneOf: [
50
+ { type: "string" },
51
+ { type: "object", properties: { ruledOut: { type: "string" }, why: { type: "string" } }, required: ["ruledOut"], additionalProperties: false },
52
+ ],
53
+ },
54
+ },
55
+ },
56
+ required: ["ruledOut"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ {
61
+ name: "relay_task_memory_append_decision",
62
+ description: "Append a decision to the caller-bound Task's decision log. Append-only: use this for durable choices a retry agent must inherit.",
63
+ requiredScopes: ["task:write"],
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: { taskId: TASK_ID_PROP, decision: { type: "string" }, rationale: { type: "string" } },
67
+ required: ["decision"],
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ ] satisfies ToolDefinition[];
72
+
73
+ export function relayTaskMemoryTool(auth: McpAuthContext, name: string, args: Record<string, unknown>): Record<string, unknown> {
74
+ const taskId = resolveTaskId(auth, args);
75
+ if (name === "relay_task_memory_read") return response(readTaskMemoryEnvelope(taskId));
76
+ if (name === "relay_task_memory_replace_working_state") {
77
+ return response(replaceTaskMemoryWorkingState({
78
+ taskId,
79
+ workingState: stringField(args.workingState, "workingState", { required: true, maxBytes: 1_000_000 }),
80
+ }));
81
+ }
82
+ if (name === "relay_task_memory_append_ruled_out") {
83
+ if (!Array.isArray(args.ruledOut)) throw new ValidationError("ruledOut must be an array");
84
+ return response(appendTaskMemoryRuledOut({ taskId, entries: args.ruledOut as Array<string | { ruledOut: string; why?: string }> }));
85
+ }
86
+ if (name === "relay_task_memory_append_decision") {
87
+ return response(appendTaskMemoryDecision({
88
+ taskId,
89
+ decision: stringField(args.decision, "decision", { required: true, maxBytes: 64_000 }),
90
+ rationale: stringField(args.rationale, "rationale", { maxBytes: 64_000 }),
91
+ by: callerAgentId(auth) ?? auth.actor,
92
+ }));
93
+ }
94
+ throw new ValidationError(`unknown tool: ${name}`);
95
+ }
96
+
97
+ function resolveTaskId(auth: McpAuthContext, args: Record<string, unknown>): number {
98
+ const explicit = optionalPositiveInt(args.taskId, "taskId");
99
+ const ids = boundTaskIds(auth);
100
+ if (explicit !== undefined) {
101
+ // Deliberate privileged escape hatch: admin/server tooling can inspect or repair any
102
+ // task memory row, but ordinary provider tokens must be bound to the explicit task id.
103
+ if (hasTaskMemoryAdminScope(auth) || ids.has(explicit)) return explicit;
104
+ throw new McpAuthError("taskId is not bound to the caller");
105
+ }
106
+ if (ids.size === 1) return [...ids][0]!;
107
+ if (ids.size === 0) throw new McpAuthError("caller is not bound to a Task");
108
+ throw new McpAuthError("caller is bound to multiple Tasks; pass taskId explicitly");
109
+ }
110
+
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
+ function response(envelope: ReturnType<typeof readTaskMemoryEnvelope>): Record<string, unknown> {
127
+ return {
128
+ taskId: envelope.taskId,
129
+ memory: envelope,
130
+ rendered: renderContinuation(envelope, { intro: `Task-bound memory — generation ${envelope.generation}.` }),
131
+ };
132
+ }