pilotswarm-sdk 0.5.37 → 0.5.39

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 (52) hide show
  1. package/api/src/http-api-transport.js +26 -7
  2. package/api/src/protocol.js +16 -7
  3. package/dist/agent-manager-tools.d.ts.map +1 -1
  4. package/dist/agent-manager-tools.js +12 -2
  5. package/dist/agent-manager-tools.js.map +1 -1
  6. package/dist/agent-package-installer.d.ts +2 -0
  7. package/dist/agent-package-installer.d.ts.map +1 -1
  8. package/dist/agent-package-installer.js +1 -0
  9. package/dist/agent-package-installer.js.map +1 -1
  10. package/dist/agent-package-service.d.ts +1 -1
  11. package/dist/agent-package-service.d.ts.map +1 -1
  12. package/dist/agent-package-service.js +79 -11
  13. package/dist/agent-package-service.js.map +1 -1
  14. package/dist/cms-migrations.d.ts.map +1 -1
  15. package/dist/cms-migrations.js +117 -0
  16. package/dist/cms-migrations.js.map +1 -1
  17. package/dist/cms.d.ts +50 -0
  18. package/dist/cms.d.ts.map +1 -1
  19. package/dist/cms.js +65 -0
  20. package/dist/cms.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/managed-session.d.ts +4 -7
  26. package/dist/managed-session.d.ts.map +1 -1
  27. package/dist/managed-session.js +99 -18
  28. package/dist/managed-session.js.map +1 -1
  29. package/dist/management-client.d.ts.map +1 -1
  30. package/dist/management-client.js +8 -1
  31. package/dist/management-client.js.map +1 -1
  32. package/dist/model-providers.d.ts +142 -6
  33. package/dist/model-providers.d.ts.map +1 -1
  34. package/dist/model-providers.js +117 -1
  35. package/dist/model-providers.js.map +1 -1
  36. package/dist/session-manager.d.ts +97 -7
  37. package/dist/session-manager.d.ts.map +1 -1
  38. package/dist/session-manager.js +292 -31
  39. package/dist/session-manager.js.map +1 -1
  40. package/dist/session-proxy.d.ts +43 -0
  41. package/dist/session-proxy.d.ts.map +1 -1
  42. package/dist/session-proxy.js +296 -184
  43. package/dist/session-proxy.js.map +1 -1
  44. package/dist/web/generated-op-methods.d.ts +34 -5
  45. package/dist/web/generated-op-methods.d.ts.map +1 -1
  46. package/dist/web/generated-op-methods.js +2 -0
  47. package/dist/web/generated-op-methods.js.map +1 -1
  48. package/dist/worker.d.ts +23 -0
  49. package/dist/worker.d.ts.map +1 -1
  50. package/dist/worker.js +189 -17
  51. package/dist/worker.js.map +1 -1
  52. package/package.json +3 -3
@@ -28,6 +28,140 @@ import { createHash, randomUUID } from "node:crypto";
28
28
  import { attemptStoreRecovery, runTurnCommit, runTurnPreamble } from "./session-lifecycle.js";
29
29
  import { supportsVersionedSnapshots, writeTurnSentinel } from "./snapshot-protocol.js";
30
30
  const SYSTEM_AGENT_IDS = new Set(["pilotswarm", "sweeper", "resourcemgr", "facts-manager"]);
31
+ /**
32
+ * THE agent-name resolver: FQN parsing, fuzzy matching, package privacy, and
33
+ * owner shadowing in one place.
34
+ *
35
+ * This used to exist twice — the resolveAgentConfig activity had the full
36
+ * rule set while the control bridge's inline copy had none of it, so
37
+ * `spawn_agent`/`create_agent_session` could bind another user's private
38
+ * agent, could not address `__shared:<name>`, and ignored the caller's own
39
+ * shadowing copy. A security rule with two implementations has one that is
40
+ * wrong; this is now the only one.
41
+ *
42
+ * `getCallerOwnerKey` is awaited lazily — only when a user-scope package
43
+ * agent is actually in play — and must resolve to the caller's owner key
44
+ * (`provider\u0001subject`) or null. Fail closed: null means no private
45
+ * agents match.
46
+ */
47
+ export async function resolveAgentDefinitionForCaller(opts) {
48
+ const agents = [
49
+ ...(opts.userAgents ?? []).map(a => ({ ...a, system: false, creatable: true })),
50
+ ...(opts.systemAgents ?? []).map(a => ({ ...a, creatable: false })),
51
+ ];
52
+ const normalize = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
53
+ // Parse the reference. `a:b` has meant `namespace:agent` since long
54
+ // before FQNs existed, so the parser reports that shape as AMBIGUOUS
55
+ // and we keep the namespace reading first — an existing binding must
56
+ // not change meaning because per-user namespaces shipped.
57
+ const fqn = parseAgentFqn(opts.agentName);
58
+ let lookupNamespace;
59
+ let rawName = opts.agentName;
60
+ /** `__shared:` explicitly asks past the caller's own copy. */
61
+ let sharedOnly = false;
62
+ if (fqn.kind === "shared") {
63
+ rawName = fqn.name;
64
+ sharedOnly = true;
65
+ }
66
+ else if (fqn.kind === "bare") {
67
+ rawName = fqn.name;
68
+ }
69
+ else if (fqn.kind === "ambiguous" || fqn.kind === "owner") {
70
+ rawName = fqn.name;
71
+ lookupNamespace = fqn.namespaceRef ?? fqn.ownerRef;
72
+ }
73
+ else {
74
+ // Invalid (reserved prefix, bad semver, malformed). Fall back to
75
+ // the raw string so a legitimately odd agent name still resolves
76
+ // the way it always did; a reserved name simply will not match.
77
+ rawName = opts.agentName;
78
+ }
79
+ const lookup = normalize(rawName);
80
+ // Also try without trailing "agent" suffix for fuzzy matching
81
+ // (LLM often says "Sweeper agent" which normalizes to "sweeperagent", but id is "sweeper")
82
+ const lookupBase = lookup.replace(/agent$/, "");
83
+ let callerKeyResolved = false;
84
+ let callerKey = null;
85
+ const callerOwnerKeyOnce = async () => {
86
+ if (!callerKeyResolved) {
87
+ callerKeyResolved = true;
88
+ try {
89
+ callerKey = await opts.getCallerOwnerKey();
90
+ }
91
+ catch {
92
+ // Fail closed: an unresolvable caller gets no private agents.
93
+ callerKey = null;
94
+ }
95
+ }
96
+ return callerKey;
97
+ };
98
+ const ownerKeyOf = (owner) => owner?.provider && owner?.subject ? `${owner.provider}\u0001${owner.subject}` : null;
99
+ const visibleToCaller = async (agent) => {
100
+ const agentOwnerKey = ownerKeyOf(agent?.packageOwner);
101
+ if (agent?.packageScope !== "user" || !agentOwnerKey)
102
+ return true;
103
+ const key = await callerOwnerKeyOnce();
104
+ return key !== null && key === agentOwnerKey;
105
+ };
106
+ const candidatesFor = (a) => [a.name, a.id, a.title].map(normalize).filter(Boolean);
107
+ const nameMatches = (a) => {
108
+ const candidates = candidatesFor(a);
109
+ return candidates.includes(lookup) || (lookupBase && candidates.includes(lookupBase));
110
+ };
111
+ let matches = agents.filter(a => {
112
+ if (lookupNamespace && normalize(a.namespace) !== normalize(lookupNamespace))
113
+ return false;
114
+ return nameMatches(a);
115
+ });
116
+ // An ambiguous `a:b` that matched nothing as `namespace:agent` gets its
117
+ // second reading — `owner:package` — rather than just failing. Order,
118
+ // not guesswork: the old meaning always wins when it resolves.
119
+ if (matches.length === 0 && fqn.kind === "ambiguous") {
120
+ matches = agents.filter(nameMatches);
121
+ }
122
+ if (sharedOnly) {
123
+ // `__shared:x` means the deployment's copy, explicitly. This is the
124
+ // one thing bare-name precedence otherwise takes away: without it,
125
+ // a user with their own copy could never reach the shared one.
126
+ matches = matches.filter(a => a.packageScope !== "user");
127
+ }
128
+ else {
129
+ // SHADOWING, at the agent level: the caller's own copy comes first,
130
+ // then everything public. Same rule the registry resolver applies,
131
+ // so "run X" and "show me X" cannot disagree about which X.
132
+ const key = matches.some(a => a.packageScope === "user") ? await callerOwnerKeyOnce() : null;
133
+ matches = [
134
+ ...matches.filter(a => a.packageScope === "user" && ownerKeyOf(a.packageOwner) === key && key !== null),
135
+ ...matches.filter(a => !(a.packageScope === "user" && ownerKeyOf(a.packageOwner) === key && key !== null)),
136
+ ];
137
+ }
138
+ let agent = undefined;
139
+ for (const candidate of matches) {
140
+ if (await visibleToCaller(candidate)) {
141
+ agent = candidate;
142
+ break;
143
+ }
144
+ }
145
+ if (!agent)
146
+ return null;
147
+ return {
148
+ name: agent.name,
149
+ prompt: agent.prompt,
150
+ tools: agent.tools ?? undefined,
151
+ initialPrompt: agent.initialPrompt ?? undefined,
152
+ title: agent.title ?? undefined,
153
+ system: agent.system ?? undefined,
154
+ id: agent.id ?? undefined,
155
+ parent: agent.parent ?? undefined,
156
+ splash: agent.splash ?? undefined,
157
+ splashMobile: agent.splashMobile ?? undefined,
158
+ namespace: agent.namespace ?? undefined,
159
+ promptLayerKind: agent.promptLayerKind ?? undefined,
160
+ creatable: agent.creatable ?? !agent.system,
161
+ packageId: agent.packageId ?? undefined,
162
+ packageScope: agent.packageScope ?? undefined,
163
+ };
164
+ }
31
165
  /**
32
166
  * The session canvas: one reserved artifact per ROOT session, drawn by the
33
167
  * agent with draw_canvas and rendered live in the portal. The revision is
@@ -35,6 +169,24 @@ const SYSTEM_AGENT_IDS = new Set(["pilotswarm", "sweeper", "resourcemgr", "facts
35
169
  * column — see docs/proposals/session-canvas.md.
36
170
  */
37
171
  export const CANVAS_ARTIFACT_FILENAME = "canvas.html";
172
+ /** Slot 1 keeps the historical name; 2-5 are canvas2.html .. canvas5.html. */
173
+ export function canvasArtifactFilename(slot) {
174
+ return slot <= 1 ? CANVAS_ARTIFACT_FILENAME : `canvas${slot}.html`;
175
+ }
176
+ /** 1-5, defaulting absent to 1. Returns null for anything else. */
177
+ function normalizeCanvasSlot(value) {
178
+ if (value === undefined || value === null || value === "")
179
+ return 1;
180
+ const n = Number(value);
181
+ if (!Number.isInteger(n) || n < 1 || n > 5)
182
+ return null;
183
+ return n;
184
+ }
185
+ /** Event rows predate slots; a missing slot means slot 1. */
186
+ function eventSlot(row) {
187
+ const n = Number(row?.data?.slot);
188
+ return Number.isInteger(n) && n >= 1 && n <= 5 ? n : 1;
189
+ }
38
190
  /**
39
191
  * The current canvas revision, from the durable event log — the single
40
192
  * authority. Reads a small window rather than exactly one row and takes the
@@ -45,14 +197,20 @@ export const CANVAS_ARTIFACT_FILENAME = "canvas.html";
45
197
  // Latest canvas draw's full data (rev + armed contract) — same bounded event
46
198
  // query as latestCanvasRev; used by read_canvas(manifestOnly) so an inheriting
47
199
  // agent learns what the browser is currently enforcing.
48
- async function latestCanvasEventData(catalog, sessionId) {
200
+ async function latestCanvasEventData(catalog, sessionId, slot = 1) {
49
201
  // No empty-coerce on failure: a transient read error that reads as "no
50
202
  // canvas" would mint rev 1 over a live rev-12 canvas (clients' monotonic
51
203
  // guards then discard the draw while the tool reports success). Callers
52
204
  // route the throw to a structured { error }.
53
- const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 5, ["session.canvas_updated"]);
205
+ //
206
+ // Window 30, not 5: five interleaved slots can push one slot's latest
207
+ // draw well past a five-event window. The session_canvases table is the
208
+ // fast path; this scan is the durable fallback.
209
+ const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_updated"]);
54
210
  let latest = { rev: 0 };
55
211
  for (const row of rows || []) {
212
+ if (eventSlot(row) !== slot)
213
+ continue;
56
214
  const rev = Number(row?.data?.rev);
57
215
  if (Number.isFinite(rev) && Number.isInteger(rev) && rev > latest.rev) {
58
216
  latest = { rev, responseContract: row?.data?.responseContract };
@@ -60,10 +218,21 @@ async function latestCanvasEventData(catalog, sessionId) {
60
218
  }
61
219
  return latest;
62
220
  }
63
- async function latestCanvasRev(catalog, sessionId) {
64
- const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 5, ["session.canvas_updated"]);
221
+ async function latestCanvasRev(catalog, sessionId, slot = 1) {
222
+ // Table first (migration 0045); event scan as the durable fallback for
223
+ // rows the table has not seen (legacy sessions, a missed upsert).
224
+ try {
225
+ const rows = await catalog.getSessionCanvases?.(sessionId);
226
+ const hit = (rows || []).find((r) => Number(r.slot) === slot);
227
+ if (hit && Number(hit.latestRev) > 0)
228
+ return Number(hit.latestRev);
229
+ }
230
+ catch { /* fall through to the event scan */ }
231
+ const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_updated"]);
65
232
  let latest = 0;
66
233
  for (const row of rows || []) {
234
+ if (eventSlot(row) !== slot)
235
+ continue;
67
236
  const rev = Number(row?.data?.rev);
68
237
  if (Number.isFinite(rev) && rev > latest && Number.isInteger(rev))
69
238
  latest = rev;
@@ -1021,28 +1190,24 @@ artifactStore) {
1021
1190
  return sanitized;
1022
1191
  };
1023
1192
  const isReadOnlyTuner = () => runConfig.agentIdentity === "agent-tuner";
1024
- const normalizeAgentLookup = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
1025
- const resolveAgentConfigInline = (agentName) => {
1026
- const agents = [
1027
- ...(userAgents ?? []).map(a => ({ ...a, system: false, creatable: true })),
1028
- ...(systemAgents ?? []).map(a => ({ ...a, creatable: false })),
1029
- ];
1030
- let lookupNamespace;
1031
- let rawName = agentName;
1032
- if (agentName.includes(":")) {
1033
- const parts = agentName.split(":");
1034
- lookupNamespace = parts[0];
1035
- rawName = parts.slice(1).join(":");
1036
- }
1037
- const lookup = normalizeAgentLookup(rawName);
1038
- const lookupBase = lookup.replace(/agent$/, "");
1039
- return agents.find(a => {
1040
- if (lookupNamespace && normalizeAgentLookup(a.namespace) !== normalizeAgentLookup(lookupNamespace))
1041
- return false;
1042
- const candidates = [a.name, a.id, a.title].map(normalizeAgentLookup).filter(Boolean);
1043
- return candidates.includes(lookup) || (lookupBase && candidates.includes(lookupBase));
1044
- }) ?? null;
1045
- };
1193
+ // The one shared resolver — FQN parsing (`__shared:` works here too),
1194
+ // package privacy, and the caller's own-copy shadowing included. The
1195
+ // previous inline copy had none of that: it could hand another user's
1196
+ // private agent to this session and could not address the shared copy
1197
+ // of a shadowed name.
1198
+ const resolveAgentConfigInline = (agentName) => resolveAgentDefinitionForCaller({
1199
+ agentName,
1200
+ userAgents,
1201
+ systemAgents,
1202
+ getCallerOwnerKey: async () => {
1203
+ const owner = catalog
1204
+ ? await resolveEffectiveSpawnOwner((id) => catalog.getSession(id), input.sessionId).catch(() => null)
1205
+ : null;
1206
+ return owner?.provider && owner?.subject
1207
+ ? `${owner.provider}\u0001${owner.subject}`
1208
+ : null;
1209
+ },
1210
+ });
1046
1211
  const loadDirectChildSessions = async () => {
1047
1212
  const sdkClient = await getInlineClient();
1048
1213
  const sessions = await sdkClient.listSessions();
@@ -1120,10 +1285,12 @@ artifactStore) {
1120
1285
  // Same-turn draw serialization: parallel draw_canvas calls in one
1121
1286
  // assistant message would otherwise race the derive-write-record
1122
1287
  // section and mint duplicate revisions.
1123
- async function latestCanvasDataRev(catalog, sessionId) {
1124
- const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 5, ["session.canvas_data"]);
1288
+ async function latestCanvasDataRev(catalog, sessionId, slot = 1) {
1289
+ const rows = await catalog.getSessionEventsBefore(sessionId, Number.MAX_SAFE_INTEGER, 30, ["session.canvas_data"]);
1125
1290
  let latest = 0;
1126
1291
  for (const row of rows || []) {
1292
+ if (eventSlot(row) !== slot)
1293
+ continue;
1127
1294
  const rev = Number(row?.data?.dataRev);
1128
1295
  if (Number.isFinite(rev) && rev > latest && Number.isInteger(rev))
1129
1296
  latest = rev;
@@ -1248,7 +1415,7 @@ artifactStore) {
1248
1415
  const agentName = String(args.agent_name || "").trim();
1249
1416
  if (!agentName)
1250
1417
  return "Error: agent_name is required.";
1251
- const agentDef = resolveAgentConfigInline(agentName);
1418
+ const agentDef = await resolveAgentConfigInline(agentName);
1252
1419
  if (!agentDef) {
1253
1420
  return `[SYSTEM: create_agent_session failed — agent "${agentName}" not found. Use list_agent_packages / ps_list_agents to see what is installed.]`;
1254
1421
  }
@@ -1373,7 +1540,7 @@ artifactStore) {
1373
1540
  : "app-agent");
1374
1541
  };
1375
1542
  if (resolvedAgentName) {
1376
- const agentDef = resolveAgentConfigInline(resolvedAgentName);
1543
+ const agentDef = await resolveAgentConfigInline(resolvedAgentName);
1377
1544
  if (!agentDef) {
1378
1545
  return `[SYSTEM: spawn_agent failed — agent "${resolvedAgentName}" not found. Use ps_list_agents to see available agents.]`;
1379
1546
  }
@@ -1894,18 +2061,25 @@ artifactStore) {
1894
2061
  // session-proxy's generic onEvent persister treats
1895
2062
  // canvas_updated as already-persisted (see EPHEMERAL_TYPES); the
1896
2063
  // handler's opts.onEvent emit is the live-push half only.
1897
- ...(input.parentSessionId ? {} : {
2064
+ // Every session gets the canvas bridge — root, sub-agent, and
2065
+ // sub-sub-agent alike. Each draws its OWN canvases; there is no
2066
+ // cross-session canvas access here, so a child cannot touch its
2067
+ // parent's surface any more than a stranger's.
2068
+ ...({
1898
2069
  drawCanvas: async (args) => {
1899
2070
  if (!artifactStore)
1900
2071
  return { error: "this worker has no artifact store" };
1901
2072
  if (!catalog)
1902
2073
  return { error: "canvas requires the CMS catalog" };
2074
+ const slot = normalizeCanvasSlot(args.slot);
2075
+ if (slot === null)
2076
+ return { error: "slot must be an integer 1-5" };
2077
+ const rawName = args.name === undefined ? undefined : String(args.name).trim();
2078
+ if (rawName !== undefined && rawName.length > 60) {
2079
+ return { error: "name must be 60 characters or fewer" };
2080
+ }
1903
2081
  const run = canvasDrawChain.then(async () => {
1904
- const row = await catalog.getSession(input.sessionId).catch(() => null);
1905
- if (row?.parentSessionId) {
1906
- return { error: "the canvas is only available on root sessions" };
1907
- }
1908
- const rev = (await latestCanvasRev(catalog, input.sessionId)) + 1;
2082
+ const rev = (await latestCanvasRev(catalog, input.sessionId, slot)) + 1;
1909
2083
  // Source resolution. fromArtifact pulls bytes store-side —
1910
2084
  // the same trust stance as read_artifact/write_artifact's
1911
2085
  // cross-session paths (the artifact layer is worker-trusted);
@@ -1969,7 +2143,7 @@ artifactStore) {
1969
2143
  }
1970
2144
  const app = canvasAppCard(extraction.manifest);
1971
2145
  const note = args.note ? String(args.note) : undefined;
1972
- await artifactStore.uploadArtifact(input.sessionId, CANVAS_ARTIFACT_FILENAME, html, "text/html",
2146
+ await artifactStore.uploadArtifact(input.sessionId, canvasArtifactFilename(slot), html, "text/html",
1973
2147
  // Pinned on EVERY draw, not just the first: uploads
1974
2148
  // replace artifact metadata wholesale, so a pin set
1975
2149
  // once at rev 1 was silently erased by rev 2.
@@ -1984,14 +2158,27 @@ artifactStore) {
1984
2158
  eventType: "session.canvas_updated",
1985
2159
  data: {
1986
2160
  rev,
2161
+ slot,
2162
+ ...(rawName !== undefined ? { name: rawName } : {}),
1987
2163
  sizeBytes,
1988
2164
  ...(note ? { note } : {}),
1989
2165
  ...(effectiveContract ? { responseContract: effectiveContract } : {}),
1990
2166
  ...(source ? { source } : {}),
1991
2167
  },
1992
2168
  }], workerNodeId);
2169
+ // The per-slot cache (migration 0045). Non-fatal on
2170
+ // purpose: the event above is durable, and the next
2171
+ // draw falls back to the event scan if this row is
2172
+ // missing — failing the draw over a cache write would
2173
+ // invert the dependency.
2174
+ try {
2175
+ await catalog.upsertSessionCanvas?.(input.sessionId, slot, rawName ?? null, rev, sizeBytes);
2176
+ }
2177
+ catch { /* self-heals on the next draw */ }
1993
2178
  return {
1994
2179
  rev,
2180
+ slot,
2181
+ ...(rawName !== undefined ? { name: rawName } : {}),
1995
2182
  sizeBytes,
1996
2183
  ...(source ? { source } : {}),
1997
2184
  ...(app ? { app } : {}),
@@ -2005,15 +2192,14 @@ artifactStore) {
2005
2192
  updateCanvas: async (args) => {
2006
2193
  if (!catalog)
2007
2194
  return { error: "canvas requires the CMS catalog" };
2195
+ const slot = normalizeCanvasSlot(args.slot);
2196
+ if (slot === null)
2197
+ return { error: "slot must be an integer 1-5" };
2008
2198
  const run = canvasDrawChain.then(async () => {
2009
- const row = await catalog.getSession(input.sessionId).catch(() => null);
2010
- if (row?.parentSessionId) {
2011
- return { error: "the canvas is only available on root sessions" };
2012
- }
2013
- if ((await latestCanvasRev(catalog, input.sessionId)) === 0) {
2014
- return { error: "no canvas has been drawn on this session — draw_canvas first; ticks patch an existing page" };
2199
+ if ((await latestCanvasRev(catalog, input.sessionId, slot)) === 0) {
2200
+ return { error: `no canvas has been drawn in slot ${slot} — draw_canvas first; ticks patch an existing page` };
2015
2201
  }
2016
- const dataRev = (await latestCanvasDataRev(catalog, input.sessionId)) + 1;
2202
+ const dataRev = (await latestCanvasDataRev(catalog, input.sessionId, slot)) + 1;
2017
2203
  const sizeBytes = Buffer.byteLength(JSON.stringify(args.data), "utf8");
2018
2204
  // The payload rides the durable event — it IS the
2019
2205
  // replay source for cold loads; no artifact write.
@@ -2021,21 +2207,45 @@ artifactStore) {
2021
2207
  eventType: "session.canvas_data",
2022
2208
  data: {
2023
2209
  dataRev,
2210
+ slot,
2024
2211
  sizeBytes,
2025
2212
  payload: args.data,
2026
2213
  ...(args.note ? { note: String(args.note) } : {}),
2027
2214
  },
2028
2215
  }], workerNodeId);
2029
- return { dataRev, sizeBytes };
2216
+ return { dataRev, slot, sizeBytes };
2030
2217
  }).catch((err) => ({ error: err?.message || String(err) }));
2031
2218
  canvasDrawChain = run.then(() => undefined, () => undefined);
2032
2219
  return run;
2033
2220
  },
2221
+ // Bring an ALREADY-DRAWN canvas to the user's screen without
2222
+ // redrawing it: no bytes, no new rev, nothing marked unseen.
2223
+ // The portal routes the event through the same flip guards a
2224
+ // draw uses (active session, freshness, per-slot opt-out).
2225
+ showCanvas: async (args) => {
2226
+ if (!catalog)
2227
+ return { error: "canvas requires the CMS catalog" };
2228
+ const slot = normalizeCanvasSlot(args.slot);
2229
+ if (slot === null)
2230
+ return { error: "slot must be an integer 1-5" };
2231
+ const rev = await latestCanvasRev(catalog, input.sessionId, slot);
2232
+ if (rev === 0) {
2233
+ return { error: `nothing has been drawn in slot ${slot} — draw_canvas first; show_canvas only presents an existing canvas` };
2234
+ }
2235
+ await catalog.recordEvents(input.sessionId, [{
2236
+ eventType: "session.canvas_presented",
2237
+ data: { slot, rev },
2238
+ }], workerNodeId);
2239
+ return { presented: true, slot, rev };
2240
+ },
2034
2241
  readCanvas: async (args) => {
2035
2242
  if (!artifactStore)
2036
2243
  return { error: "this worker has no artifact store" };
2037
2244
  if (!catalog)
2038
2245
  return { error: "canvas requires the CMS catalog" };
2246
+ const slot = normalizeCanvasSlot(args.slot);
2247
+ if (slot === null)
2248
+ return { error: "slot must be an integer 1-5" };
2039
2249
  try {
2040
2250
  // Log FIRST: the event log is the single authority on
2041
2251
  // whether a canvas exists. Orphan bytes from a crashed
@@ -2051,10 +2261,10 @@ artifactStore) {
2051
2261
  // ARMED contract from the latest draw event — the
2052
2262
  // pair an inheriting agent needs to interpret
2053
2263
  // canvas-action messages and author ticks.
2054
- const latest = await latestCanvasEventData(catalog, input.sessionId);
2264
+ const latest = await latestCanvasEventData(catalog, input.sessionId, slot);
2055
2265
  if (latest.rev === 0)
2056
2266
  return { exists: false };
2057
- const docText = await artifactStore.downloadArtifactText(input.sessionId, CANVAS_ARTIFACT_FILENAME);
2267
+ const docText = await artifactStore.downloadArtifactText(input.sessionId, canvasArtifactFilename(slot));
2058
2268
  const extraction = extractCanvasAppManifest(docText);
2059
2269
  const card = canvasAppCard(extraction.manifest);
2060
2270
  // Events are writable via send_session_event with no
@@ -2071,10 +2281,10 @@ artifactStore) {
2071
2281
  ...(extraction.error ? { manifestError: extraction.error } : {}),
2072
2282
  };
2073
2283
  }
2074
- const rev = await latestCanvasRev(catalog, input.sessionId);
2284
+ const rev = await latestCanvasRev(catalog, input.sessionId, slot);
2075
2285
  if (rev === 0)
2076
2286
  return { exists: false };
2077
- const text = await artifactStore.downloadArtifactText(input.sessionId, CANVAS_ARTIFACT_FILENAME);
2287
+ const text = await artifactStore.downloadArtifactText(input.sessionId, canvasArtifactFilename(slot));
2078
2288
  const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
2079
2289
  const maxChars = Math.min(262_144, Math.max(1, Math.floor(Number(args.maxBytes) || 65_536)));
2080
2290
  const content = text.slice(offset, offset + maxChars);
@@ -2134,6 +2344,9 @@ artifactStore) {
2134
2344
  // handler's emit is the live-push half only; recording it here
2135
2345
  // too would double-insert every revision.
2136
2346
  "session.canvas_updated",
2347
+ // Same contract as canvas_updated: the showCanvas bridge
2348
+ // records it durably itself; the emit is live-push only.
2349
+ "session.canvas_presented",
2137
2350
  // Same contract for data ticks: the updateCanvas bridge method
2138
2351
  // records the event (payload inline) inside the serialized
2139
2352
  // section — the generic path must not double-insert it.
@@ -3066,143 +3279,42 @@ artifactStore) {
3066
3279
  // Resolves a loaded agent definition by name. User-creatable agents return
3067
3280
  // creatable=true. Worker-managed system agents return creatable=false so
3068
3281
  // callers can surface a clear error instead of spawning them.
3282
+ //
3283
+ // Matching, package privacy, and owner shadowing all live in
3284
+ // resolveAgentDefinitionForCaller — shared with the control bridge so
3285
+ // spawn_agent/create_agent_session and this activity can never disagree
3286
+ // about which agent a name means.
3069
3287
  runtime.registerActivity("resolveAgentConfig", async (_activityCtx, input) => {
3070
- const agents = [
3071
- ...(userAgents ?? []).map(a => ({ ...a, system: false, creatable: true })),
3072
- ...(systemAgents ?? []).map(a => ({ ...a, creatable: false })),
3073
- ];
3074
- const normalize = (value) => (value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
3075
- // Parse the reference. `a:b` has meant `namespace:agent` since long
3076
- // before FQNs existed, so the parser reports that shape as AMBIGUOUS
3077
- // and we keep the namespace reading first — an existing binding must
3078
- // not change meaning because per-user namespaces shipped.
3079
- const fqn = parseAgentFqn(input.agentName);
3080
- let lookupNamespace;
3081
- let rawName = input.agentName;
3082
- /** `__shared:` explicitly asks past the caller's own copy. */
3083
- let sharedOnly = false;
3084
- if (fqn.kind === "shared") {
3085
- rawName = fqn.name;
3086
- sharedOnly = true;
3087
- }
3088
- else if (fqn.kind === "bare") {
3089
- rawName = fqn.name;
3090
- }
3091
- else if (fqn.kind === "ambiguous" || fqn.kind === "owner") {
3092
- rawName = fqn.name;
3093
- lookupNamespace = fqn.namespaceRef ?? fqn.ownerRef;
3094
- }
3095
- else {
3096
- // Invalid (reserved prefix, bad semver, malformed). Fall back to
3097
- // the raw string so a legitimately odd agent name still resolves
3098
- // the way it always did; a reserved name simply will not match.
3099
- rawName = input.agentName;
3100
- }
3101
- const lookup = normalize(rawName);
3102
- // Also try without trailing "agent" suffix for fuzzy matching
3103
- // (LLM often says "Sweeper agent" which normalizes to "sweeperagent", but id is "sweeper")
3104
- const lookupBase = lookup.replace(/agent$/, "");
3105
- /**
3106
- * PACKAGE PRIVACY, enforced at RESOLUTION rather than at creation.
3107
- *
3108
- * Workers install every enabled package, including other users'
3109
- * user-scope ones, so `userAgents` is a fleet-wide list with no
3110
- * tenancy in it. The Web API filters package agents when a session is
3111
- * created (`_authorizePackageAgentCreate`), but `spawn_agent` reaches
3112
- * this activity directly and never passes through that filter — so a
3113
- * name match alone would hand Alice's private agent, prompt and all,
3114
- * to Bob's session.
3115
- *
3116
- * It has to be enforced HERE and not at spawn time: the return value
3117
- * carries the agent's full prompt, and merely returning it writes that
3118
- * prompt into the caller's orchestration history.
3119
- *
3120
- * Deployment-owned plugin agents carry no package owner and stay
3121
- * public, exactly as before.
3122
- */
3123
- let callerOwnerKey = null;
3124
- let callerResolved = false;
3125
- const ownerKeyOf = (owner) => owner?.provider && owner?.subject ? `${owner.provider}\u0001${owner.subject}` : null;
3126
- /** Resolve the caller's owner once per invocation, memoized and fail-closed. */
3127
- const callerOwnerKeyOnce = async () => {
3128
- if (!callerResolved) {
3129
- callerResolved = true;
3130
- try {
3131
- const row = input.callerSessionId
3132
- ? await catalog?.getSession(input.callerSessionId)
3133
- : null;
3134
- callerOwnerKey = ownerKeyOf(row?.owner);
3135
- }
3136
- catch {
3137
- // Fail closed: an unresolvable caller gets no private agents.
3138
- callerOwnerKey = null;
3139
- }
3140
- }
3141
- return callerOwnerKey;
3142
- };
3143
- const visibleToCaller = async (agent) => {
3144
- const agentOwnerKey = ownerKeyOf(agent?.packageOwner);
3145
- if (agent?.packageScope !== "user" || !agentOwnerKey)
3146
- return true;
3147
- const key = await callerOwnerKeyOnce();
3148
- return key !== null && key === agentOwnerKey;
3149
- };
3150
- const candidatesFor = (a) => [a.name, a.id, a.title].map(normalize).filter(Boolean);
3151
- const nameMatches = (a) => {
3152
- const candidates = candidatesFor(a);
3153
- return candidates.includes(lookup) || (lookupBase && candidates.includes(lookupBase));
3154
- };
3155
- let matches = agents.filter(a => {
3156
- if (lookupNamespace && normalize(a.namespace) !== normalize(lookupNamespace))
3157
- return false;
3158
- return nameMatches(a);
3288
+ return resolveAgentDefinitionForCaller({
3289
+ agentName: input.agentName,
3290
+ userAgents,
3291
+ systemAgents,
3292
+ // PACKAGE PRIVACY, enforced at RESOLUTION rather than at creation.
3293
+ //
3294
+ // Workers install every enabled package, including other users'
3295
+ // user-scope ones, so `userAgents` is a fleet-wide list with no
3296
+ // tenancy in it. The Web API filters package agents when a session
3297
+ // is created (`_authorizePackageAgentCreate`), but `spawn_agent`
3298
+ // reaches this activity directly and never passes through that
3299
+ // filter — so a name match alone would hand Alice's private agent,
3300
+ // prompt and all, to Bob's session.
3301
+ //
3302
+ // It has to be enforced HERE and not at spawn time: the return
3303
+ // value carries the agent's full prompt, and merely returning it
3304
+ // writes that prompt into the caller's orchestration history.
3305
+ //
3306
+ // Deployment-owned plugin agents carry no package owner and stay
3307
+ // public, exactly as before.
3308
+ getCallerOwnerKey: async () => {
3309
+ const row = input.callerSessionId
3310
+ ? await catalog?.getSession(input.callerSessionId)
3311
+ : null;
3312
+ const owner = row?.owner;
3313
+ return owner?.provider && owner?.subject
3314
+ ? `${owner.provider}\u0001${owner.subject}`
3315
+ : null;
3316
+ },
3159
3317
  });
3160
- // An ambiguous `a:b` that matched nothing as `namespace:agent` gets its
3161
- // second reading — `owner:package` — rather than just failing. Order,
3162
- // not guesswork: the old meaning always wins when it resolves.
3163
- if (matches.length === 0 && fqn.kind === "ambiguous") {
3164
- matches = agents.filter(nameMatches);
3165
- }
3166
- if (sharedOnly) {
3167
- // `__shared:x` means the deployment's copy, explicitly. This is the
3168
- // one thing bare-name precedence otherwise takes away: without it,
3169
- // a user with their own copy could never reach the shared one.
3170
- matches = matches.filter(a => a.packageScope !== "user");
3171
- }
3172
- else {
3173
- // SHADOWING, at the agent level: the caller's own copy comes first,
3174
- // then everything public. Same rule the registry resolver applies,
3175
- // so "run X" and "show me X" cannot disagree about which X.
3176
- const callerKey = await callerOwnerKeyOnce();
3177
- matches = [
3178
- ...matches.filter(a => a.packageScope === "user" && ownerKeyOf(a.packageOwner) === callerKey && callerKey !== null),
3179
- ...matches.filter(a => !(a.packageScope === "user" && ownerKeyOf(a.packageOwner) === callerKey && callerKey !== null)),
3180
- ];
3181
- }
3182
- let agent = undefined;
3183
- for (const candidate of matches) {
3184
- if (await visibleToCaller(candidate)) {
3185
- agent = candidate;
3186
- break;
3187
- }
3188
- }
3189
- if (!agent)
3190
- return null;
3191
- return {
3192
- name: agent.name,
3193
- prompt: agent.prompt,
3194
- tools: agent.tools ?? undefined,
3195
- initialPrompt: agent.initialPrompt ?? undefined,
3196
- title: agent.title ?? undefined,
3197
- system: agent.system ?? undefined,
3198
- id: agent.id ?? undefined,
3199
- parent: agent.parent ?? undefined,
3200
- splash: agent.splash ?? undefined,
3201
- splashMobile: agent.splashMobile ?? undefined,
3202
- namespace: agent.namespace ?? undefined,
3203
- promptLayerKind: agent.promptLayerKind ?? undefined,
3204
- creatable: agent.creatable ?? !agent.system,
3205
- };
3206
3318
  });
3207
3319
  // ── spawnChildSession ─────────────────────────────────────
3208
3320
  // Creates a child session via the PilotSwarmClient SDK.