agent-relay-server 0.43.0 → 0.45.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 (79) hide show
  1. package/docs/openapi.json +92 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity--LYUyJtT.js → activity-Dkzmef0Z.js} +2 -2
  4. package/public/assets/{activity--LYUyJtT.js.map → activity-Dkzmef0Z.js.map} +1 -1
  5. package/public/assets/{agent-profiles-BWKpVoNw.js → agent-profiles-DQYHMAyu.js} +2 -2
  6. package/public/assets/{agent-profiles-BWKpVoNw.js.map → agent-profiles-DQYHMAyu.js.map} +1 -1
  7. package/public/assets/{agents-6mhIWvF-.js → agents-atr6HZ1e.js} +2 -2
  8. package/public/assets/{agents-6mhIWvF-.js.map → agents-atr6HZ1e.js.map} +1 -1
  9. package/public/assets/{analytics-W-oZ8bsY.js → analytics-azFfAy4R.js} +2 -2
  10. package/public/assets/{analytics-W-oZ8bsY.js.map → analytics-azFfAy4R.js.map} +1 -1
  11. package/public/assets/{automation-DouRxEN7.js → automation-Bk4umHIm.js} +2 -2
  12. package/public/assets/{automation-DouRxEN7.js.map → automation-Bk4umHIm.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-dN2mQ2gs.js → branch-state-badge-DymObp8d.js} +2 -2
  14. package/public/assets/{branch-state-badge-dN2mQ2gs.js.map → branch-state-badge-DymObp8d.js.map} +1 -1
  15. package/public/assets/channels-B_0wDy0s.js +2 -0
  16. package/public/assets/{channels-CQy-JL7o.js.map → channels-B_0wDy0s.js.map} +1 -1
  17. package/public/assets/chat-DxV4MyYF.js +2 -0
  18. package/public/assets/chat-DxV4MyYF.js.map +1 -0
  19. package/public/assets/{connectors-ByNxzq2k.js → connectors-DLqYF2_3.js} +2 -2
  20. package/public/assets/{connectors-ByNxzq2k.js.map → connectors-DLqYF2_3.js.map} +1 -1
  21. package/public/assets/display-ConJ9cJB.js.map +1 -1
  22. package/public/assets/{formatted-body-impl-CthzMcxz.js → formatted-body-impl-LK0QcFLC.js} +2 -2
  23. package/public/assets/{formatted-body-impl-CthzMcxz.js.map → formatted-body-impl-LK0QcFLC.js.map} +1 -1
  24. package/public/assets/{index-B1s-hGRR.js → index-CT10XH5Y.js} +8 -8
  25. package/public/assets/{index-B1s-hGRR.js.map → index-CT10XH5Y.js.map} +1 -1
  26. package/public/assets/{integrations-BBmL-PnQ.js → integrations-CdeQuRSQ.js} +2 -2
  27. package/public/assets/{integrations-BBmL-PnQ.js.map → integrations-CdeQuRSQ.js.map} +1 -1
  28. package/public/assets/{maintenance-CdDtYHE9.js → maintenance-DbqSE_ps.js} +2 -2
  29. package/public/assets/{maintenance-CdDtYHE9.js.map → maintenance-DbqSE_ps.js.map} +1 -1
  30. package/public/assets/{managed-agents-DXigb0pj.js → managed-agents-BxhRiBVn.js} +2 -2
  31. package/public/assets/{managed-agents-DXigb0pj.js.map → managed-agents-BxhRiBVn.js.map} +1 -1
  32. package/public/assets/{markdown-preview-impl-BVES_oc1.js → markdown-preview-impl-Dz2Spmat.js} +2 -2
  33. package/public/assets/{markdown-preview-impl-BVES_oc1.js.map → markdown-preview-impl-Dz2Spmat.js.map} +1 -1
  34. package/public/assets/{memory-DWAF794H.js → memory-BTjUKTyA.js} +2 -2
  35. package/public/assets/{memory-DWAF794H.js.map → memory-BTjUKTyA.js.map} +1 -1
  36. package/public/assets/{messages-D63r1IRE.js → messages-C8z05Y3O.js} +2 -2
  37. package/public/assets/{messages-D63r1IRE.js.map → messages-C8z05Y3O.js.map} +1 -1
  38. package/public/assets/{orchestrators-rbEOs7pu.js → orchestrators-DQqtYryc.js} +2 -2
  39. package/public/assets/{orchestrators-rbEOs7pu.js.map → orchestrators-DQqtYryc.js.map} +1 -1
  40. package/public/assets/{overview-CeUZzyTa.js → overview-CGYu6qQr.js} +2 -2
  41. package/public/assets/{overview-CeUZzyTa.js.map → overview-CGYu6qQr.js.map} +1 -1
  42. package/public/assets/pairs-BgqXzCto.js +2 -0
  43. package/public/assets/{pairs-DJ_e1R5S.js.map → pairs-BgqXzCto.js.map} +1 -1
  44. package/public/assets/{security-DyeypCyX.js → security-BqfRVw-p.js} +2 -2
  45. package/public/assets/{security-DyeypCyX.js.map → security-BqfRVw-p.js.map} +1 -1
  46. package/public/assets/{settings-CVW329oA.js → settings-63whklIq.js} +3 -3
  47. package/public/assets/{settings-CVW329oA.js.map → settings-63whklIq.js.map} +1 -1
  48. package/public/assets/store-B6qT5_de.js +9 -0
  49. package/public/assets/store-B6qT5_de.js.map +1 -0
  50. package/public/assets/tasks-DiZ_4TYW.js +2 -0
  51. package/public/assets/{tasks-CR8BxsPc.js.map → tasks-DiZ_4TYW.js.map} +1 -1
  52. package/public/assets/{terminal-viewer-impl-CISUjIl9.js → terminal-viewer-impl-D3T1F77z.js} +2 -2
  53. package/public/assets/{terminal-viewer-impl-CISUjIl9.js.map → terminal-viewer-impl-D3T1F77z.js.map} +1 -1
  54. package/public/assets/work-queue-C1p2Hybt.js +2 -0
  55. package/public/assets/{work-queue-ipT9bYqc.js.map → work-queue-C1p2Hybt.js.map} +1 -1
  56. package/public/assets/{workspaces-BD1P6J21.js → workspaces-DqVr0uN7.js} +2 -2
  57. package/public/assets/{workspaces-BD1P6J21.js.map → workspaces-DqVr0uN7.js.map} +1 -1
  58. package/public/index.html +2 -2
  59. package/src/db/blackboard.ts +171 -0
  60. package/src/db/index.ts +1 -0
  61. package/src/db/migrations.ts +37 -1
  62. package/src/db/schema.ts +16 -1
  63. package/src/mcp-blackboard-tools.ts +131 -0
  64. package/src/mcp-plan-tools.ts +6 -3
  65. package/src/mcp.ts +3 -0
  66. package/src/routes/blackboard.ts +54 -0
  67. package/src/routes/index.ts +3 -0
  68. package/src/routes/plans.ts +9 -3
  69. package/src/security.ts +2 -0
  70. package/src/services/plan-graph.ts +16 -4
  71. package/src/token-db.ts +3 -3
  72. package/public/assets/channels-CQy-JL7o.js +0 -2
  73. package/public/assets/chat-DVu6bXmE.js +0 -2
  74. package/public/assets/chat-DVu6bXmE.js.map +0 -1
  75. package/public/assets/pairs-DJ_e1R5S.js +0 -2
  76. package/public/assets/store-CtKmqMfx.js +0 -9
  77. package/public/assets/store-CtKmqMfx.js.map +0 -1
  78. package/public/assets/tasks-CR8BxsPc.js +0 -2
  79. package/public/assets/work-queue-ipT9bYqc.js +0 -2
@@ -302,6 +302,23 @@ export function applyMigrations(): void {
302
302
  `);
303
303
  getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id)");
304
304
  getDb().run("CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status)");
305
+ getDb().run(`
306
+ CREATE TABLE IF NOT EXISTS blackboard_entries (
307
+ id TEXT PRIMARY KEY,
308
+ team_id TEXT NOT NULL,
309
+ kind TEXT NOT NULL,
310
+ title TEXT NOT NULL,
311
+ body TEXT,
312
+ status TEXT NOT NULL DEFAULT 'active',
313
+ supersedes TEXT,
314
+ tags TEXT,
315
+ author_id TEXT NOT NULL,
316
+ created_at INTEGER NOT NULL,
317
+ updated_at INTEGER NOT NULL
318
+ )
319
+ `);
320
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id)");
321
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind)");
305
322
  getDb().run(`
306
323
  CREATE TABLE IF NOT EXISTS plans (
307
324
  id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
@@ -319,11 +336,30 @@ export function applyMigrations(): void {
319
336
  getDb().run("CREATE INDEX IF NOT EXISTS idx_plans_team ON plans(team_id)");
320
337
  getDb().run(`
321
338
  CREATE TABLE IF NOT EXISTS plan_versions (
322
- id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
339
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
323
340
  version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
324
341
  changed_by TEXT, changed_at INTEGER NOT NULL
325
342
  )
326
343
  `);
344
+ const planVersionFks = getDb().query("PRAGMA foreign_key_list(plan_versions)").all() as any[];
345
+ if (planVersionFks.some((fk) => fk.table === "plans" && String(fk.on_delete).toUpperCase() === "CASCADE")) {
346
+ getDb().transaction(() => {
347
+ getDb().run("ALTER TABLE plan_versions RENAME TO plan_versions_old");
348
+ getDb().run(`
349
+ CREATE TABLE plan_versions (
350
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
351
+ version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
352
+ changed_by TEXT, changed_at INTEGER NOT NULL
353
+ )
354
+ `);
355
+ getDb().run(`
356
+ INSERT INTO plan_versions (id, plan_id, version, title, objective, nodes, edges, changed_by, changed_at)
357
+ SELECT id, plan_id, version, title, objective, nodes, edges, changed_by, changed_at
358
+ FROM plan_versions_old
359
+ `);
360
+ getDb().run("DROP TABLE plan_versions_old");
361
+ })();
362
+ }
327
363
  getDb().run("CREATE INDEX IF NOT EXISTS idx_plan_versions_plan ON plan_versions(plan_id, version DESC)");
328
364
 
329
365
  // Backfill thread_id for pre-migration rows (self-threaded).
package/src/db/schema.ts CHANGED
@@ -171,6 +171,21 @@ export function initDb(path: string = "agent-relay.db"): Database {
171
171
  );
172
172
  CREATE INDEX IF NOT EXISTS idx_teams_coordinator ON teams(coordinator_id);
173
173
  CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status);
174
+ CREATE TABLE IF NOT EXISTS blackboard_entries (
175
+ id TEXT PRIMARY KEY,
176
+ team_id TEXT NOT NULL,
177
+ kind TEXT NOT NULL,
178
+ title TEXT NOT NULL,
179
+ body TEXT,
180
+ status TEXT NOT NULL DEFAULT 'active',
181
+ supersedes TEXT,
182
+ tags TEXT,
183
+ author_id TEXT NOT NULL,
184
+ created_at INTEGER NOT NULL,
185
+ updated_at INTEGER NOT NULL
186
+ );
187
+ CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id);
188
+ CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind);
174
189
  CREATE TABLE IF NOT EXISTS plans (
175
190
  id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
176
191
  title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',
@@ -179,7 +194,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
179
194
  CREATE UNIQUE INDEX IF NOT EXISTS idx_plans_thread_channel ON plans(thread_id, coalesce(channel, ''));
180
195
  CREATE INDEX IF NOT EXISTS idx_plans_updated ON plans(updated_at);
181
196
  CREATE TABLE IF NOT EXISTS plan_versions (
182
- id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
197
+ id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id TEXT NOT NULL,
183
198
  version INTEGER NOT NULL, title TEXT, objective TEXT, nodes TEXT NOT NULL, edges TEXT NOT NULL,
184
199
  changed_by TEXT, changed_at INTEGER NOT NULL
185
200
  );
@@ -0,0 +1,131 @@
1
+ import { getAgent, getEntry, getEntryChain, listEntries, retractEntry, searchEntries, ValidationError, writeEntry } from "./db";
2
+ import { McpNotFoundError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
5
+ import type { BlackboardEntryKind } from "./types";
6
+
7
+ const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
8
+
9
+ export const BLACKBOARD_TOOLS = [
10
+ {
11
+ name: "relay_blackboard_write",
12
+ description: "Create a team-scoped blackboard entry. The caller's token identity determines the team and author.",
13
+ requiredScopes: ["blackboard:write"],
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ kind: { type: "string", enum: BLACKBOARD_KINDS },
18
+ title: { type: "string" },
19
+ body: { type: "string" },
20
+ tags: { type: "array", items: { type: "string" } },
21
+ supersedes: { type: "string" },
22
+ },
23
+ required: ["kind", "title"],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ {
28
+ name: "relay_blackboard_get",
29
+ description: "Fetch one blackboard entry and its supersession chain for the caller's team.",
30
+ requiredScopes: ["blackboard:read"],
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: { id: { type: "string" } },
34
+ required: ["id"],
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ {
39
+ name: "relay_blackboard_read",
40
+ description: "Read the caller team's active blackboard working set, optionally filtered by kind.",
41
+ requiredScopes: ["blackboard:read"],
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { kind: { type: "string", enum: BLACKBOARD_KINDS } },
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: "relay_blackboard_search",
50
+ description: "Search active blackboard entries for the caller's team by title, body, or tags.",
51
+ requiredScopes: ["blackboard:read"],
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ query: { type: "string" },
56
+ kind: { type: "string", enum: BLACKBOARD_KINDS },
57
+ },
58
+ required: ["query"],
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ {
63
+ name: "relay_blackboard_retract",
64
+ description: "Retract a blackboard entry for the caller's team without deleting its ledger row.",
65
+ requiredScopes: ["blackboard:write"],
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: { id: { type: "string" } },
69
+ required: ["id"],
70
+ additionalProperties: false,
71
+ },
72
+ },
73
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
74
+
75
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
76
+
77
+ function callerTeam(auth: McpAuthLike): { agentId: string; teamId: string } {
78
+ const ctx = authContextFromMcp(auth);
79
+ if (!ctx.callerAgentId) throw new ValidationError("blackboard operations require a single caller agent identity");
80
+ const agent = getAgent(ctx.callerAgentId);
81
+ if (!agent?.teamId) throw new ValidationError("caller has no team");
82
+ return { agentId: ctx.callerAgentId, teamId: agent.teamId };
83
+ }
84
+
85
+ function cleanKind(value: unknown): BlackboardEntryKind | undefined {
86
+ return optionalEnum(value, "kind", BLACKBOARD_KINDS) as BlackboardEntryKind | undefined;
87
+ }
88
+
89
+ function ensureSameTeam(entryId: string, teamId: string): void {
90
+ const entry = getEntry(entryId);
91
+ if (!entry) throw new McpNotFoundError("blackboard entry not found");
92
+ if (entry.teamId !== teamId) throw new McpNotFoundError("blackboard entry not found");
93
+ }
94
+
95
+ export function relayBlackboardTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
96
+ const { agentId, teamId } = callerTeam(auth);
97
+ if (name === "relay_blackboard_write") {
98
+ const supersedes = cleanString(args.supersedes, "supersedes", { max: 120 });
99
+ const kind = cleanKind(args.kind);
100
+ if (!kind) throw new ValidationError("kind required");
101
+ if (supersedes) ensureSameTeam(supersedes, teamId);
102
+ return writeEntry({
103
+ teamId,
104
+ authorId: agentId,
105
+ kind,
106
+ title: cleanString(args.title, "title", { required: true, max: 240 })!,
107
+ body: cleanString(args.body, "body", { max: 16_000 }),
108
+ tags: cleanStringArray(args.tags, "tags", { itemMax: 80, maxItems: 32 }),
109
+ supersedes,
110
+ }) as unknown as Record<string, unknown>;
111
+ }
112
+ if (name === "relay_blackboard_get") {
113
+ const id = cleanString(args.id, "id", { required: true, max: 120 })!;
114
+ ensureSameTeam(id, teamId);
115
+ return { entry: getEntry(id), chain: getEntryChain(id) };
116
+ }
117
+ if (name === "relay_blackboard_read") {
118
+ return { entries: listEntries(teamId, { kind: cleanKind(args.kind) }) };
119
+ }
120
+ if (name === "relay_blackboard_search") {
121
+ return { entries: searchEntries(teamId, cleanString(args.query, "query", { required: true, max: 240 })!, { kind: cleanKind(args.kind) }) };
122
+ }
123
+ if (name === "relay_blackboard_retract") {
124
+ const id = cleanString(args.id, "id", { required: true, max: 120 })!;
125
+ ensureSameTeam(id, teamId);
126
+ const entry = retractEntry(id);
127
+ if (!entry) throw new McpNotFoundError("blackboard entry not found");
128
+ return entry as unknown as Record<string, unknown>;
129
+ }
130
+ throw new ValidationError(`unknown tool: ${name}`);
131
+ }
@@ -5,6 +5,7 @@ import {
5
5
  addPlanEdge,
6
6
  addPlanNode,
7
7
  createPlan,
8
+ deletePlanGraph,
8
9
  getPlanGraph,
9
10
  removePlanEdge,
10
11
  removePlanNode,
@@ -82,13 +83,14 @@ export const PLAN_TOOLS = [
82
83
  { name: "relay_plan_add_edge", description: "Add a dependency edge to a plan DAG; rejects cycles.", requiredScopes: ["plans:write"], inputSchema: { type: "object", properties: { planId: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["planId", "from", "to"], additionalProperties: false } },
83
84
  { 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 } },
84
85
  { 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 } },
86
+ { 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 } },
85
87
  {
86
88
  name: "relay_plan_get",
87
- description: "Fetch a plan by planId or by threadId/channel.",
89
+ description: "Fetch a plan by planId, teamId, or by threadId/channel.",
88
90
  requiredScopes: ["plans:read"],
89
91
  inputSchema: {
90
92
  type: "object",
91
- properties: { planId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" } },
93
+ properties: { planId: { type: "string" }, teamId: { type: "string" }, threadId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] }, channel: { type: "string" } },
92
94
  additionalProperties: false,
93
95
  },
94
96
  },
@@ -112,8 +114,9 @@ export function relayPlanTool(auth: McpAuthLike, name: string, args: Record<stri
112
114
  if (name === "relay_plan_add_edge") return addPlanEdge(stringArg(args.planId, "planId"), { from: args.from, to: args.to }, ctx) as unknown as Record<string, unknown>;
113
115
  if (name === "relay_plan_remove_node") return removePlanNode(stringArg(args.planId, "planId"), stringArg(args.nodeId, "nodeId"), ctx) as unknown as Record<string, unknown>;
114
116
  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>;
117
+ if (name === "relay_plan_delete") return deletePlanGraph(stringArg(args.planId, "planId"), ctx);
115
118
  if (name === "relay_plan_get") {
116
- const plan = getPlanGraph({ planId: args.planId, threadId: args.threadId, channel: args.channel });
119
+ const plan = getPlanGraph({ planId: args.planId, teamId: args.teamId, threadId: args.threadId, channel: args.channel });
117
120
  if (!plan) throw new McpNotFoundError("plan not found");
118
121
  return plan as unknown as Record<string, unknown>;
119
122
  }
package/src/mcp.ts CHANGED
@@ -48,6 +48,7 @@ import { sendMessageService } from "./services/send-message";
48
48
  import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
+ import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
51
52
  import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
52
53
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
53
54
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
@@ -460,6 +461,7 @@ const TOOLS: ToolDefinition[] = [
460
461
  },
461
462
  ...TEAM_TOOLS,
462
463
  ...PLAN_TOOLS,
464
+ ...BLACKBOARD_TOOLS,
463
465
  ];
464
466
 
465
467
  export async function postMcp(req: Request): Promise<Response> {
@@ -573,6 +575,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
573
575
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
574
576
  else if (name.startsWith("relay_team_")) result = relayTeamTool(auth, name, args);
575
577
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
578
+ else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
576
579
  else throw new ValidationError(`unknown tool: ${name}`);
577
580
 
578
581
  auditToolCall(auth, name, "ok", result);
@@ -0,0 +1,54 @@
1
+ import { getAgent, getEntry, getEntryChain, listEntries } from "../db";
2
+ import { authContextFromRequest } from "../services/auth-context";
3
+ import { cleanString, optionalEnum } from "../validation";
4
+ import { error, json, type Handler } from "./_shared";
5
+ import type { BlackboardEntryKind } from "../types";
6
+
7
+ const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
8
+
9
+ function isAdminContext(ctx: ReturnType<typeof authContextFromRequest>): boolean {
10
+ return ctx.scopes.includes("*") || ctx.scopes.includes("admin:*");
11
+ }
12
+
13
+ function routeTeamId(req: Request): string | Response {
14
+ const ctx = authContextFromRequest(req);
15
+ if (ctx.component && ctx.callerAgentId) {
16
+ const callerId = ctx.callerAgentId;
17
+ const agent = getAgent(callerId);
18
+ return agent?.teamId ?? error("caller has no team", 403);
19
+ }
20
+ if (ctx.component && !isAdminContext(ctx)) return error("caller has no team", 403);
21
+ const teamId = cleanString(new URL(req.url).searchParams.get("team") ?? undefined, "team", { required: true, max: 120 });
22
+ return teamId!;
23
+ }
24
+
25
+ function routeKind(req: Request): BlackboardEntryKind | undefined {
26
+ return optionalEnum(new URL(req.url).searchParams.get("kind") ?? undefined, "kind", BLACKBOARD_KINDS) as BlackboardEntryKind | undefined;
27
+ }
28
+
29
+ export const getBlackboardRoute: Handler = (req) => {
30
+ try {
31
+ const teamId = routeTeamId(req);
32
+ if (teamId instanceof Response) return teamId;
33
+ return json({ entries: listEntries(teamId, { kind: routeKind(req) }) });
34
+ } catch (err) {
35
+ return error(err instanceof Error ? err.message : "blackboard route failed");
36
+ }
37
+ };
38
+
39
+ export const getBlackboardEntryRoute: Handler = (req, params) => {
40
+ try {
41
+ const id = cleanString(params.id, "id", { required: true, max: 120 })!;
42
+ const entry = getEntry(id);
43
+ if (!entry) return error("blackboard entry not found", 404);
44
+ const ctx = authContextFromRequest(req);
45
+ if (ctx.component && ctx.callerAgentId) {
46
+ const agent = getAgent(ctx.callerAgentId);
47
+ if (!agent?.teamId || agent.teamId !== entry.teamId) return error("blackboard entry not found", 404);
48
+ }
49
+ if (ctx.component && !ctx.callerAgentId && !isAdminContext(ctx)) return error("blackboard entry not found", 404);
50
+ return json({ entry, chain: getEntryChain(id) });
51
+ } catch (err) {
52
+ return error(err instanceof Error ? err.message : "blackboard route failed");
53
+ }
54
+ };
@@ -27,6 +27,7 @@ import { getInsightsConfigRoute, getInsightsObservationsRoute, postInsightsObser
27
27
  import { getOrchestratorDirectories, getOrchestratorFileRead, getOrchestratorFileStat, getOrchestratorFilesList, getOrchestratorLogs, getOrchestratorProviders, getOrchestratorSessions, getOrchestratorTerminal, getOrchestratorVersion, getOrchestratorWorkspaceProbe, postOrchestratorCreateDirectory, postOrchestratorTerminalInput, postOrchestratorTerminalResize } from "./orchestrator-proxy";
28
28
  import { getPairById, getPairs, postAcceptPair, postHangupPair, postPair, postPairMessage, postRejectPair } from "./pairs";
29
29
  import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
+ import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
30
31
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
31
32
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
32
33
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
@@ -267,6 +268,8 @@ const routes: Route[] = [
267
268
  route("POST", "/api/pairs/:id/messages", postPairMessage),
268
269
  route("GET", "/api/plans", getPlansRoute),
269
270
  route("GET", "/api/plans/:id", getPlanByIdRoute),
271
+ route("GET", "/api/blackboard", getBlackboardRoute),
272
+ route("GET", "/api/blackboard/:id", getBlackboardEntryRoute),
270
273
 
271
274
  route("GET", "/api/spec", getApiSpec),
272
275
  route("GET", "/api/docs", getApiDocs),
@@ -5,10 +5,16 @@ import { error, json, parseQueryInt, type Handler } from "./_shared";
5
5
 
6
6
  export const getPlansRoute: Handler = (req) => {
7
7
  const url = new URL(req.url);
8
- const threadId = parseQueryInt(url.searchParams.get("threadId"), { min: 1, max: Number.MAX_SAFE_INTEGER });
9
- if (threadId === null) return error("threadId required");
10
- if (Number.isNaN(threadId)) return error("threadId must be a positive integer");
11
8
  const channel = cleanString(url.searchParams.get("channel") ?? undefined, "channel", { max: 120 });
9
+ const teamId = cleanString(url.searchParams.get("teamId") ?? undefined, "teamId", { max: 120 });
10
+ if (teamId) {
11
+ const plan = getPlanGraph({ teamId, channel });
12
+ return plan ? json(plan) : error("plan not found", 404);
13
+ }
14
+ const rawThreadId = url.searchParams.get("threadId");
15
+ if (rawThreadId === null) return error("threadId or teamId required");
16
+ const threadId = parseQueryInt(rawThreadId, { min: 1, max: Number.MAX_SAFE_INTEGER });
17
+ if (Number.isNaN(threadId)) return error("threadId must be a positive integer");
12
18
  const plan = getPlanGraph({ threadId, channel });
13
19
  return plan ? json(plan) : error("plan not found", 404);
14
20
  };
package/src/security.ts CHANGED
@@ -205,6 +205,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
205
205
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
206
206
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
207
207
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
208
+ if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
208
209
  // Insights config (the feature toggle) stays admin-only via the default; only the
209
210
  // mechanical observation feed is writable by lower-privilege callers.
210
211
  if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
280
281
  if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
281
282
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
282
283
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
284
+ if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
283
285
  // The Runner posts the #184 context-gathering signal here (source:"server") via its
284
286
  // provider token. Without this case the path fell through to the system:admin default
285
287
  // below and every observation was 403-dropped — the whole Insights feed stayed empty.
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { createPlanRow, getPlan, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
2
+ import { createPlanRow, deletePlan, getAgent, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
4
  import { cleanString, optionalEnum } from "../validation";
5
5
  import type { AuthContext } from "./auth-context";
@@ -173,7 +173,7 @@ function existingPlan(id: string): Plan {
173
173
  return plan;
174
174
  }
175
175
 
176
- function emitPlan(type: "plan.created" | "plan.changed", plan: Plan, actor: string): void {
176
+ function emitPlan(type: "plan.created" | "plan.changed" | "plan.deleted", plan: Plan, actor: string): void {
177
177
  emitRelayEvent({ type, source: actor, subject: plan.id, data: { plan, actor } });
178
178
  }
179
179
 
@@ -185,9 +185,11 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
185
185
  const edges = cleanEdges(input.edges);
186
186
  validateEdges(nodes, edges);
187
187
  const actor = actorId(ctx);
188
+ const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
189
+ const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
188
190
  const plan = createPlanRow({
189
191
  threadId,
190
- teamId: cleanString(input.teamId, "teamId", { max: 120 }),
192
+ teamId,
191
193
  channel,
192
194
  title: cleanString(input.title, "title", { max: 240 }),
193
195
  objective: cleanString(input.objective, "objective", { max: 4000 }),
@@ -264,9 +266,19 @@ export function removePlanEdge(planId: string, edgeInput: unknown, ctx: AuthCont
264
266
  return updated;
265
267
  }
266
268
 
267
- export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; channel?: unknown }): Plan | null {
269
+ export function deletePlanGraph(planId: string, ctx: AuthContext): { id: string; deleted: true } {
270
+ const plan = existingPlan(planId);
271
+ const actor = actorId(ctx);
272
+ deletePlan(plan.id, actor);
273
+ emitPlan("plan.deleted", plan, actor);
274
+ return { id: plan.id, deleted: true };
275
+ }
276
+
277
+ export function getPlanGraph(input: { planId?: unknown; threadId?: unknown; teamId?: unknown; channel?: unknown }): Plan | null {
268
278
  const planId = cleanString(input.planId, "planId", { max: 120 });
269
279
  if (planId) return getPlan(planId);
280
+ const teamId = cleanString(input.teamId, "teamId", { max: 120 });
281
+ if (teamId) return getPlanByTeam(teamId, cleanString(input.channel, "channel", { max: 120 }));
270
282
  const threadId = cleanThreadId(input.threadId);
271
283
  return getPlanByThread(threadId, cleanString(input.channel, "channel", { max: 120 }));
272
284
  }
package/src/token-db.ts CHANGED
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
57
57
  name: "Provider Agent",
58
58
  description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
59
59
  role: "provider",
60
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "insights:write"],
60
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "insights:write"],
61
61
  ttlSeconds: 24 * 60 * 60,
62
62
  builtIn: true,
63
63
  createdBy: "system",
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Provider Child Agent",
68
68
  description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
69
69
  role: "provider",
70
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
70
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "insights:write"],
71
71
  constraints: { canDelegate: false },
72
72
  ttlSeconds: 2 * 60 * 60,
73
73
  builtIn: true,
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
78
78
  name: "Provider Interactive Agent",
79
79
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
80
80
  role: "provider",
81
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "insights:write"],
81
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "insights:write"],
82
82
  constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
83
83
  ttlSeconds: 30 * 24 * 60 * 60,
84
84
  builtIn: true,
@@ -1,2 +0,0 @@
1
- import{Cn as e,Pt as t,Sn as n,jn as r,q as i,xn as a}from"./lucide-react-ChOGD4vc.js";import{a as o,t as s}from"./store-CtKmqMfx.js";import{G as c,H as l,W as u,c as d,s as f}from"./display-ConJ9cJB.js";import{t as p}from"./badge-CFVr4fG9.js";import{t as m}from"./button-BuTu0MHx.js";import{i as h,n as g,r as _,t as v}from"./card-C3ErQ372.js";var y=r();function b({direction:t}){return t===`inbound`?(0,y.jsx)(n,{className:`w-3 h-3`}):t===`outbound`?(0,y.jsx)(a,{className:`w-3 h-3`}):(0,y.jsx)(e,{className:`w-3 h-3`})}function x({channel:e}){let n=o(),r=s(e=>e.openChannelDetail),a=d(e),f=e.status!==`offline`;return(0,y.jsxs)(v,{className:`${f?``:`opacity-60`} hover:border-zinc-600 transition-colors`,children:[(0,y.jsx)(_,{className:`pb-2 pt-4 px-4`,children:(0,y.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,y.jsx)(`div`,{className:`p-1.5 rounded-md ${f?`bg-blue-500/10`:`bg-zinc-800`}`,children:(0,y.jsx)(i,{className:`w-4 h-4 ${f?`text-blue-400`:`text-zinc-500`}`})}),(0,y.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,y.jsx)(h,{className:`text-sm font-medium truncate`,children:e.name||e.id}),(0,y.jsxs)(`div`,{className:`text-xs text-muted-foreground mt-0.5`,children:[e.type,` · `,e.transport]})]}),(0,y.jsx)(p,{className:`text-[10px] shrink-0 ${u(a.tone)}`,children:a.label})]})}),(0,y.jsxs)(g,{className:`px-4 pb-4 space-y-2.5`,children:[(0,y.jsxs)(`div`,{className:`flex items-center gap-1.5 text-xs text-muted-foreground`,children:[(0,y.jsx)(b,{direction:e.direction}),(0,y.jsx)(`span`,{className:`capitalize`,children:e.direction}),e.target&&(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(`span`,{children:`·`}),(0,y.jsx)(`span`,{className:`truncate text-foreground`,children:e.target})]})]}),e.targetHealth&&e.targetHealth.status!==`ok`&&(0,y.jsx)(`div`,{className:`text-xs px-2 py-1 rounded ${e.targetHealth.status===`error`?`bg-red-500/10 text-red-400`:`bg-yellow-500/10 text-yellow-400`}`,children:e.targetHealth.detail}),c(e.capabilities).length>0&&(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:c(e.capabilities).map(e=>(0,y.jsx)(p,{variant:`secondary`,className:`text-[9px] px-1 py-0 h-4`,children:e},e))}),e.tags.length>0&&(0,y.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:e.tags.map(e=>(0,y.jsx)(p,{variant:`outline`,className:`text-[9px] px-1 py-0 h-4`,children:e},e))}),(0,y.jsxs)(`div`,{className:`flex items-center justify-between pt-0.5`,children:[(0,y.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:l(n,e.lastSeen)}),(0,y.jsxs)(m,{size:`sm`,variant:`outline`,className:`h-7 text-xs gap-1`,onClick:()=>r(e),children:[(0,y.jsx)(t,{className:`w-3 h-3`}),`Details`]})]})]})]})}function S(){let e=s(e=>e.channels),t=e.filter(f).length,n=e.filter(e=>e.targetHealth?.status===`error`).length,r=e.filter(e=>e.targetHealth?.status===`warning`).length;return(0,y.jsxs)(`div`,{className:`space-y-4`,children:[(0,y.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,y.jsxs)(`div`,{children:[(0,y.jsx)(`h2`,{className:`text-base font-semibold`,children:`Channels`}),(0,y.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Inbound / outbound message integrations`})]}),(0,y.jsxs)(`div`,{className:`flex items-center gap-2 ml-auto`,children:[t>0&&(0,y.jsxs)(p,{className:`text-xs bg-emerald-500/15 text-emerald-400 border-emerald-500/30`,children:[t,` ready`]}),r>0&&(0,y.jsxs)(p,{className:`text-xs bg-yellow-500/15 text-yellow-400 border-yellow-500/30`,children:[r,` warning`]}),n>0&&(0,y.jsxs)(p,{className:`text-xs bg-red-500/15 text-red-400 border-red-500/30`,children:[n,` error`]})]})]}),e.length===0?(0,y.jsx)(`div`,{className:`text-center py-16 text-muted-foreground text-sm`,children:`No channels registered.`}):(0,y.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4`,children:e.map(e=>(0,y.jsx)(x,{channel:e},e.id))})]})}export{S as ChannelsView};
2
- //# sourceMappingURL=channels-CQy-JL7o.js.map