agent-relay-server 0.44.0 → 0.46.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.
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { parseJson } from "../utils";
4
+ import type { BlackboardEntry, BlackboardEntryKind, BlackboardEntryStatus } from "../types";
5
+
6
+ const BLACKBOARD_KINDS = ["decision", "fact", "ruled_out"] as const;
7
+ const BLACKBOARD_STATUSES = ["active", "superseded", "retracted"] as const;
8
+
9
+ interface BlackboardEntryRow {
10
+ id: string;
11
+ team_id: string;
12
+ kind: BlackboardEntryKind;
13
+ title: string;
14
+ body: string | null;
15
+ status: BlackboardEntryStatus;
16
+ supersedes: string | null;
17
+ tags: string | null;
18
+ author_id: string;
19
+ created_at: number;
20
+ updated_at: number;
21
+ }
22
+
23
+ interface WriteBlackboardEntryInput {
24
+ teamId: string;
25
+ kind: BlackboardEntryKind;
26
+ title: string;
27
+ body?: string;
28
+ tags?: string[];
29
+ authorId: string;
30
+ supersedes?: string;
31
+ }
32
+
33
+ function blackboardId(): string {
34
+ return `bb_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
35
+ }
36
+
37
+ function rowToEntry(row: BlackboardEntryRow): BlackboardEntry {
38
+ const tags = parseJson<string[]>(row.tags, []).filter((tag) => typeof tag === "string");
39
+ return {
40
+ id: row.id,
41
+ teamId: row.team_id,
42
+ kind: row.kind,
43
+ title: row.title,
44
+ ...(row.body ? { body: row.body } : {}),
45
+ status: row.status,
46
+ ...(row.supersedes ? { supersedes: row.supersedes } : {}),
47
+ tags,
48
+ authorId: row.author_id,
49
+ createdAt: row.created_at,
50
+ updatedAt: row.updated_at,
51
+ };
52
+ }
53
+
54
+ function assertKind(kind: BlackboardEntryKind): void {
55
+ if (!BLACKBOARD_KINDS.includes(kind)) {
56
+ throw new ValidationError(`kind must be one of: ${BLACKBOARD_KINDS.join(", ")}`);
57
+ }
58
+ }
59
+
60
+ function assertStatus(status: BlackboardEntryStatus): void {
61
+ if (!BLACKBOARD_STATUSES.includes(status)) {
62
+ throw new ValidationError(`status must be one of: ${BLACKBOARD_STATUSES.join(", ")}`);
63
+ }
64
+ }
65
+
66
+ function escapeLike(value: string): string {
67
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
68
+ }
69
+
70
+ export function writeEntry(input: WriteBlackboardEntryInput): BlackboardEntry {
71
+ assertKind(input.kind);
72
+ const id = blackboardId();
73
+ const now = Date.now();
74
+ getDb().transaction(() => {
75
+ if (input.supersedes) {
76
+ const previous = getEntry(input.supersedes);
77
+ if (!previous) throw new ValidationError(`blackboard entry ${input.supersedes} not found`);
78
+ if (previous.teamId !== input.teamId) throw new ValidationError("superseded entry belongs to another team");
79
+ if (previous.status === "retracted") throw new ValidationError("cannot supersede a retracted entry");
80
+ getDb().query("UPDATE blackboard_entries SET status = 'superseded', updated_at = ? WHERE id = ?")
81
+ .run(now, input.supersedes);
82
+ }
83
+ getDb().query(`
84
+ INSERT INTO blackboard_entries (id, team_id, kind, title, body, status, supersedes, tags, author_id, created_at, updated_at)
85
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
86
+ `).run(
87
+ id,
88
+ input.teamId,
89
+ input.kind,
90
+ input.title,
91
+ input.body ?? null,
92
+ input.supersedes ?? null,
93
+ input.tags?.length ? JSON.stringify([...new Set(input.tags)]) : null,
94
+ input.authorId,
95
+ now,
96
+ now,
97
+ );
98
+ })();
99
+ return getEntry(id)!;
100
+ }
101
+
102
+ export function getEntry(id: string): BlackboardEntry | null {
103
+ const row = getDb().query("SELECT * FROM blackboard_entries WHERE id = ?").get(id) as BlackboardEntryRow | undefined;
104
+ return row ? rowToEntry(row) : null;
105
+ }
106
+
107
+ export function getEntryChain(id: string): BlackboardEntry[] {
108
+ const chain: BlackboardEntry[] = [];
109
+ const seen = new Set<string>();
110
+ let current = getEntry(id);
111
+ while (current && !seen.has(current.id)) {
112
+ chain.push(current);
113
+ seen.add(current.id);
114
+ current = current.supersedes ? getEntry(current.supersedes) : null;
115
+ }
116
+ return chain;
117
+ }
118
+
119
+ export function listEntries(
120
+ teamId: string,
121
+ opts: { kind?: BlackboardEntryKind; includeInactive?: boolean } = {},
122
+ ): BlackboardEntry[] {
123
+ if (opts.kind) assertKind(opts.kind);
124
+ const conditions = ["team_id = ?"];
125
+ const params: string[] = [teamId];
126
+ if (opts.kind) {
127
+ conditions.push("kind = ?");
128
+ params.push(opts.kind);
129
+ }
130
+ if (!opts.includeInactive) conditions.push("status = 'active'");
131
+ const rows = getDb().query(`
132
+ SELECT * FROM blackboard_entries
133
+ WHERE ${conditions.join(" AND ")}
134
+ ORDER BY updated_at DESC, created_at DESC, id DESC
135
+ `).all(...params) as BlackboardEntryRow[];
136
+ return rows.map(rowToEntry);
137
+ }
138
+
139
+ export function searchEntries(
140
+ teamId: string,
141
+ query: string,
142
+ opts: { kind?: BlackboardEntryKind } = {},
143
+ ): BlackboardEntry[] {
144
+ if (opts.kind) assertKind(opts.kind);
145
+ const like = `%${escapeLike(query)}%`;
146
+ const conditions = [
147
+ "team_id = ?",
148
+ "status = 'active'",
149
+ "(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')",
150
+ ];
151
+ const params: string[] = [teamId, like, like, like];
152
+ if (opts.kind) {
153
+ conditions.push("kind = ?");
154
+ params.push(opts.kind);
155
+ }
156
+ const rows = getDb().query(`
157
+ SELECT * FROM blackboard_entries
158
+ WHERE ${conditions.join(" AND ")}
159
+ ORDER BY updated_at DESC, created_at DESC, id DESC
160
+ `).all(...params) as BlackboardEntryRow[];
161
+ return rows.map(rowToEntry);
162
+ }
163
+
164
+ export function retractEntry(id: string): BlackboardEntry | null {
165
+ const existing = getEntry(id);
166
+ if (!existing) return null;
167
+ assertStatus("retracted");
168
+ getDb().query("UPDATE blackboard_entries SET status = 'retracted', updated_at = ? WHERE id = ?")
169
+ .run(Date.now(), id);
170
+ return getEntry(id);
171
+ }
package/src/db/index.ts CHANGED
@@ -25,3 +25,4 @@ export * from "./continuations.ts";
25
25
  export * from "./continuation-archives.ts";
26
26
  export * from "./plans.ts";
27
27
  export * from "./teams.ts";
28
+ export * from "./blackboard.ts";
@@ -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,
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 '[]',
@@ -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
+ }
@@ -28,6 +28,7 @@ const PLAN_NODE_SCHEMA = {
28
28
  lane: { type: "string" },
29
29
  status: { type: "string", enum: PLAN_STATUSES },
30
30
  statusSource: { type: "string", enum: ["manual", "bound"] },
31
+ blackboardRefs: { type: "array", items: { type: "string" } },
31
32
  },
32
33
  required: ["id", "label"],
33
34
  additionalProperties: false,
@@ -75,6 +76,7 @@ export const PLAN_TOOLS = [
75
76
  lane: { oneOf: [{ type: "string" }, { type: "null" }] },
76
77
  status: { type: "string", enum: PLAN_STATUSES },
77
78
  statusSource: { type: "string", enum: ["manual", "bound"] },
79
+ blackboardRefs: { oneOf: [{ type: "array", items: { type: "string" } }, { type: "null" }] },
78
80
  },
79
81
  required: ["planId", "nodeId"],
80
82
  additionalProperties: false,
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),
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,7 +1,7 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { createPlanRow, deletePlan, getAgent, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
2
+ import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, planThreadExists, replacePlan, ValidationError } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
- import { cleanString, optionalEnum } from "../validation";
4
+ import { cleanString, cleanStringArray, optionalEnum } from "../validation";
5
5
  import type { AuthContext } from "./auth-context";
6
6
  import type { Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
7
7
 
@@ -16,6 +16,7 @@ export interface PlanNodeInput {
16
16
  status?: unknown;
17
17
  statusSource?: unknown;
18
18
  refs?: unknown;
19
+ blackboardRefs?: unknown;
19
20
  }
20
21
 
21
22
  export interface PlanNodePatch {
@@ -26,6 +27,7 @@ export interface PlanNodePatch {
26
27
  status?: unknown;
27
28
  statusSource?: unknown;
28
29
  refs?: unknown;
30
+ blackboardRefs?: unknown;
29
31
  }
30
32
 
31
33
  export interface PlanCreateInput {
@@ -73,30 +75,47 @@ function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
73
75
  throw new ValidationError("refs are populated by live binding in slice c");
74
76
  }
75
77
 
78
+ function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | undefined {
79
+ const refs = cleanStringArray(value, "node.blackboardRefs", { itemMax: 120, maxItems: 100 });
80
+ if (!refs?.length) return refs;
81
+ if (!planTeamId) throw new ValidationError("plan has no team; cannot reference blackboard entries");
82
+ for (const ref of refs) {
83
+ const entry = getEntry(ref);
84
+ if (!entry || entry.teamId !== planTeamId) throw new ValidationError(`blackboard entry not found for plan team: ${ref}`);
85
+ }
86
+ return refs;
87
+ }
88
+
76
89
  function cleanStatusSource(value: unknown): "manual" {
77
90
  const statusSource = optionalEnum(value, "statusSource", ["manual", "bound"] as const, "manual");
78
91
  if (statusSource !== "manual") throw new ValidationError("statusSource must be manual in this slice");
79
92
  return "manual";
80
93
  }
81
94
 
82
- function cleanNode(input: PlanNodeInput): PlanNode {
95
+ function cleanNode(input: PlanNodeInput, planTeamId?: string): PlanNode {
83
96
  if (!isRecord(input)) throw new ValidationError("node must be an object");
97
+ const scopeBlurb = cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 });
98
+ const ownerRef = cleanOwnerRef(input.ownerRef);
99
+ const lane = cleanString(input.lane, "node.lane", { max: 120 });
100
+ const refs = cleanRefs(input.refs);
101
+ const blackboardRefs = cleanBlackboardRefs(input.blackboardRefs, planTeamId);
84
102
  return {
85
103
  id: cleanString(input.id, "node.id", { required: true, max: 120 })!,
86
104
  label: cleanString(input.label, "node.label", { required: true, max: 240 })!,
87
- ...(cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 }) ? { scopeBlurb: cleanString(input.scopeBlurb, "node.scopeBlurb", { max: 2000 }) } : {}),
88
- ...(cleanOwnerRef(input.ownerRef) ? { ownerRef: cleanOwnerRef(input.ownerRef) } : {}),
89
- ...(cleanString(input.lane, "node.lane", { max: 120 }) ? { lane: cleanString(input.lane, "node.lane", { max: 120 }) } : {}),
105
+ ...(scopeBlurb ? { scopeBlurb } : {}),
106
+ ...(ownerRef ? { ownerRef } : {}),
107
+ ...(lane ? { lane } : {}),
90
108
  status: optionalEnum(input.status, "node.status", PLAN_STATUSES, "pending") as PlanStatus,
91
109
  statusSource: cleanStatusSource(input.statusSource),
92
- ...(cleanRefs(input.refs) ? { refs: cleanRefs(input.refs) } : {}),
110
+ ...(refs ? { refs } : {}),
111
+ ...(blackboardRefs ? { blackboardRefs } : {}),
93
112
  };
94
113
  }
95
114
 
96
- function cleanNodes(value: unknown): PlanNode[] {
115
+ function cleanNodes(value: unknown, planTeamId?: string): PlanNode[] {
97
116
  if (value === undefined || value === null) return [];
98
117
  if (!Array.isArray(value)) throw new ValidationError("nodes must be an array");
99
- const nodes = value.map((item) => cleanNode(item as PlanNodeInput));
118
+ const nodes = value.map((item) => cleanNode(item as PlanNodeInput, planTeamId));
100
119
  assertUnique(nodes.map((node) => node.id), "node id");
101
120
  return nodes;
102
121
  }
@@ -181,12 +200,12 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
181
200
  const threadId = cleanThreadId(input.threadId);
182
201
  if (!planThreadExists(threadId)) throw new ValidationError(`thread ${threadId} not found`);
183
202
  const channel = cleanString(input.channel, "channel", { max: 120 });
184
- const nodes = cleanNodes(input.nodes);
185
- const edges = cleanEdges(input.edges);
186
- validateEdges(nodes, edges);
187
203
  const actor = actorId(ctx);
188
204
  const explicitTeamId = cleanString(input.teamId, "teamId", { max: 120 });
189
205
  const teamId = explicitTeamId ?? (input.teamId === undefined || input.teamId === null ? getAgent(actor)?.teamId : undefined);
206
+ const nodes = cleanNodes(input.nodes, teamId);
207
+ const edges = cleanEdges(input.edges);
208
+ validateEdges(nodes, edges);
190
209
  const plan = createPlanRow({
191
210
  threadId,
192
211
  teamId,
@@ -203,7 +222,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
203
222
 
204
223
  export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthContext): Plan {
205
224
  const plan = existingPlan(planId);
206
- const node = cleanNode(nodeInput);
225
+ const node = cleanNode(nodeInput, plan.teamId);
207
226
  if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
208
227
  const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
209
228
  emitPlan("plan.changed", updated, actorId(ctx));
@@ -223,6 +242,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
223
242
  ...(patch.lane !== undefined ? optionalProp("lane", cleanString(patch.lane, "lane", { max: 120 })) : {}),
224
243
  ...(patch.status !== undefined ? { status: optionalEnum(patch.status, "status", PLAN_STATUSES) as PlanStatus } : {}),
225
244
  ...(patch.refs !== undefined ? optionalProp("refs", cleanRefs(patch.refs)) : {}),
245
+ ...(patch.blackboardRefs !== undefined ? optionalProp("blackboardRefs", cleanBlackboardRefs(patch.blackboardRefs, plan.teamId)) : {}),
226
246
  statusSource: "manual",
227
247
  };
228
248
  const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
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,