pilotswarm-sdk 0.5.57 → 0.5.59

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 (150) hide show
  1. package/api/README.md +6 -0
  2. package/api/index.d.ts +25 -3
  3. package/api/index.js +2 -0
  4. package/api/src/admin-diagnostics.d.ts +4 -0
  5. package/api/src/admin-diagnostics.js +45 -0
  6. package/api/src/admin-scope.d.ts +12 -0
  7. package/api/src/admin-scope.js +39 -0
  8. package/api/src/api-client.js +214 -1
  9. package/api/src/http-api-transport.js +20 -1
  10. package/api/src/protocol.js +3 -2
  11. package/api/src/session-authz.d.ts +5 -3
  12. package/api/src/session-authz.js +10 -8
  13. package/dist/agent-loader.d.ts +12 -1
  14. package/dist/agent-loader.d.ts.map +1 -1
  15. package/dist/agent-loader.js +36 -5
  16. package/dist/agent-loader.js.map +1 -1
  17. package/dist/agent-manager-tools.d.ts +3 -0
  18. package/dist/agent-manager-tools.d.ts.map +1 -1
  19. package/dist/agent-manager-tools.js +19 -11
  20. package/dist/agent-manager-tools.js.map +1 -1
  21. package/dist/agent-package-format.d.ts +1 -0
  22. package/dist/agent-package-format.d.ts.map +1 -1
  23. package/dist/agent-package-format.js +8 -4
  24. package/dist/agent-package-format.js.map +1 -1
  25. package/dist/cms-migrations.d.ts.map +1 -1
  26. package/dist/cms-migrations.js +265 -0
  27. package/dist/cms-migrations.js.map +1 -1
  28. package/dist/cms.d.ts +58 -0
  29. package/dist/cms.d.ts.map +1 -1
  30. package/dist/cms.js +88 -0
  31. package/dist/cms.js.map +1 -1
  32. package/dist/copilot-client.d.ts +22 -0
  33. package/dist/copilot-client.d.ts.map +1 -0
  34. package/dist/copilot-client.js +52 -0
  35. package/dist/copilot-client.js.map +1 -0
  36. package/dist/inspect-tools.d.ts +4 -0
  37. package/dist/inspect-tools.d.ts.map +1 -1
  38. package/dist/inspect-tools.js +55 -8
  39. package/dist/inspect-tools.js.map +1 -1
  40. package/dist/live-turn.d.ts +60 -0
  41. package/dist/live-turn.d.ts.map +1 -0
  42. package/dist/live-turn.js +262 -0
  43. package/dist/live-turn.js.map +1 -0
  44. package/dist/managed-session.d.ts.map +1 -1
  45. package/dist/managed-session.js +137 -1
  46. package/dist/managed-session.js.map +1 -1
  47. package/dist/management-client.d.ts +10 -0
  48. package/dist/management-client.d.ts.map +1 -1
  49. package/dist/management-client.js +17 -3
  50. package/dist/management-client.js.map +1 -1
  51. package/dist/orchestration/agents.d.ts.map +1 -1
  52. package/dist/orchestration/agents.js +3 -1
  53. package/dist/orchestration/agents.js.map +1 -1
  54. package/dist/orchestration/index.d.ts +2 -2
  55. package/dist/orchestration/index.js +1 -1
  56. package/dist/orchestration/queue.d.ts.map +1 -1
  57. package/dist/orchestration/queue.js +5 -0
  58. package/dist/orchestration/queue.js.map +1 -1
  59. package/dist/orchestration/runtime.d.ts +3 -1
  60. package/dist/orchestration/runtime.d.ts.map +1 -1
  61. package/dist/orchestration/runtime.js +6 -1
  62. package/dist/orchestration/runtime.js.map +1 -1
  63. package/dist/orchestration/state.d.ts +2 -0
  64. package/dist/orchestration/state.d.ts.map +1 -1
  65. package/dist/orchestration/state.js.map +1 -1
  66. package/dist/orchestration/turn.d.ts +1 -1
  67. package/dist/orchestration/turn.d.ts.map +1 -1
  68. package/dist/orchestration/turn.js +49 -7
  69. package/dist/orchestration/turn.js.map +1 -1
  70. package/dist/orchestration-registry.d.ts.map +1 -1
  71. package/dist/orchestration-registry.js +4 -2
  72. package/dist/orchestration-registry.js.map +1 -1
  73. package/dist/orchestration-version.d.ts +1 -1
  74. package/dist/orchestration-version.js +1 -1
  75. package/dist/orchestration.d.ts +2 -2
  76. package/dist/orchestration.js +1 -1
  77. package/dist/orchestration_1_0_46.d.ts +1 -1
  78. package/dist/orchestration_1_0_47.d.ts +1 -1
  79. package/dist/orchestration_1_0_48.d.ts +1 -1
  80. package/dist/orchestration_1_0_49.d.ts +1 -1
  81. package/dist/orchestration_1_0_50.d.ts +1 -1
  82. package/dist/orchestration_1_0_71/agents.d.ts +61 -0
  83. package/dist/orchestration_1_0_71/agents.d.ts.map +1 -0
  84. package/dist/orchestration_1_0_71/agents.js +840 -0
  85. package/dist/orchestration_1_0_71/agents.js.map +1 -0
  86. package/dist/orchestration_1_0_71/index.d.ts +28 -0
  87. package/dist/orchestration_1_0_71/index.d.ts.map +1 -0
  88. package/dist/orchestration_1_0_71/index.js +13 -0
  89. package/dist/orchestration_1_0_71/index.js.map +1 -0
  90. package/dist/orchestration_1_0_71/lifecycle.d.ts +51 -0
  91. package/dist/orchestration_1_0_71/lifecycle.d.ts.map +1 -0
  92. package/dist/orchestration_1_0_71/lifecycle.js +973 -0
  93. package/dist/orchestration_1_0_71/lifecycle.js.map +1 -0
  94. package/dist/orchestration_1_0_71/queue.d.ts +20 -0
  95. package/dist/orchestration_1_0_71/queue.d.ts.map +1 -0
  96. package/dist/orchestration_1_0_71/queue.js +875 -0
  97. package/dist/orchestration_1_0_71/queue.js.map +1 -0
  98. package/dist/orchestration_1_0_71/runtime.d.ts +29 -0
  99. package/dist/orchestration_1_0_71/runtime.d.ts.map +1 -0
  100. package/dist/orchestration_1_0_71/runtime.js +225 -0
  101. package/dist/orchestration_1_0_71/runtime.js.map +1 -0
  102. package/dist/orchestration_1_0_71/state.d.ts +212 -0
  103. package/dist/orchestration_1_0_71/state.d.ts.map +1 -0
  104. package/dist/orchestration_1_0_71/state.js +178 -0
  105. package/dist/orchestration_1_0_71/state.js.map +1 -0
  106. package/dist/orchestration_1_0_71/turn.d.ts +34 -0
  107. package/dist/orchestration_1_0_71/turn.d.ts.map +1 -0
  108. package/dist/orchestration_1_0_71/turn.js +1392 -0
  109. package/dist/orchestration_1_0_71/turn.js.map +1 -0
  110. package/dist/orchestration_1_0_71/utils.d.ts +39 -0
  111. package/dist/orchestration_1_0_71/utils.d.ts.map +1 -0
  112. package/dist/orchestration_1_0_71/utils.js +322 -0
  113. package/dist/orchestration_1_0_71/utils.js.map +1 -0
  114. package/dist/provider-store.d.ts +3 -3
  115. package/dist/provider-store.d.ts.map +1 -1
  116. package/dist/provider-store.js +6 -6
  117. package/dist/provider-store.js.map +1 -1
  118. package/dist/provider-tools.d.ts +1 -0
  119. package/dist/provider-tools.d.ts.map +1 -1
  120. package/dist/provider-tools.js +4 -3
  121. package/dist/provider-tools.js.map +1 -1
  122. package/dist/regen-worker.d.ts.map +1 -1
  123. package/dist/regen-worker.js +3 -3
  124. package/dist/regen-worker.js.map +1 -1
  125. package/dist/session-manager.d.ts +7 -6
  126. package/dist/session-manager.d.ts.map +1 -1
  127. package/dist/session-manager.js +54 -32
  128. package/dist/session-manager.js.map +1 -1
  129. package/dist/session-proxy.d.ts +7 -1
  130. package/dist/session-proxy.d.ts.map +1 -1
  131. package/dist/session-proxy.js +44 -10
  132. package/dist/session-proxy.js.map +1 -1
  133. package/dist/system-agents.d.ts.map +1 -1
  134. package/dist/system-agents.js +9 -3
  135. package/dist/system-agents.js.map +1 -1
  136. package/dist/types.d.ts +5 -1
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/types.js.map +1 -1
  139. package/dist/web/generated-op-methods.d.ts +8 -0
  140. package/dist/web/generated-op-methods.d.ts.map +1 -1
  141. package/dist/web/generated-op-methods.js +2 -0
  142. package/dist/web/generated-op-methods.js.map +1 -1
  143. package/dist/web/web-management-client.d.ts +7 -0
  144. package/dist/web/web-management-client.d.ts.map +1 -1
  145. package/dist/web/web-management-client.js +3 -0
  146. package/dist/web/web-management-client.js.map +1 -1
  147. package/dist/worker.d.ts.map +1 -1
  148. package/dist/worker.js +7 -1
  149. package/dist/worker.js.map +1 -1
  150. package/package.json +5 -5
package/api/README.md CHANGED
@@ -21,8 +21,14 @@ const api = new ApiClient({
21
21
  const session = await api.call("createSession", { model: "anthropic:claude-sonnet-4-6" });
22
22
  await api.call("sendMessage", { sessionId: session.sessionId, prompt: "hello" });
23
23
  const unsubscribe = api.subscribeSession(session.sessionId, (event) => console.log(event));
24
+ const unsubscribeLive = api.subscribeLive(session.sessionId, "turn", (update) => console.log(update));
24
25
  ```
25
26
 
27
+ `subscribeLive` carries ephemeral last-value topics such as in-progress turn
28
+ text. It resubscribes automatically, ignores duplicate sequence values, and
29
+ refetches a topic snapshot with `getLive` after a sequence gap. Live values
30
+ are never part of the durable session event log.
31
+
26
32
  ## Modules
27
33
 
28
34
  - `src/protocol.js` — the **operations table**: every JSON operation's name,
package/api/index.d.ts CHANGED
@@ -62,6 +62,23 @@ export interface ApiClientOptions {
62
62
  WebSocketImpl?: unknown;
63
63
  }
64
64
 
65
+ /** Generic ephemeral topic delivery; never a durable replay event. */
66
+ export type LiveUpdate = {
67
+ sessionId: string;
68
+ topic: string;
69
+ updatedAt?: string;
70
+ } & ({ kind: "snapshot" | "patch"; seq: number; data: Record<string, unknown> }
71
+ | { kind: "signal"; seq?: number | null }
72
+ | { kind: "unavailable" });
73
+
74
+ export interface LiveStateRow {
75
+ topic: string;
76
+ seq: number;
77
+ payload: Record<string, unknown>;
78
+ updatedBy: string;
79
+ updatedAt: string;
80
+ }
81
+
65
82
  export declare class ApiClient {
66
83
  constructor(options: ApiClientOptions);
67
84
  apiUrl: string;
@@ -81,6 +98,7 @@ export declare class ApiClient {
81
98
  start(): Promise<void>;
82
99
  stop(): Promise<void>;
83
100
  subscribeSession(sessionId: string, handler: (event: unknown) => void, onResubscribe?: () => void): () => void;
101
+ subscribeLive(sessionId: string, topic: string, handler: (update: LiveUpdate) => void): () => void;
84
102
  subscribeLogs(handler: (entry: unknown) => void): () => void;
85
103
  }
86
104
 
@@ -97,12 +115,16 @@ export interface HttpApiTransportOptions extends ApiClientOptions {
97
115
  host?: HttpApiTransportHost;
98
116
  }
99
117
 
118
+ export { AdminScope, ADMIN_SCOPE_POLICY_VERSION, ADMIN_SCOPES, loadAdminScope, validateAdminScope, adminCanAccessResource, adminCapabilities } from "./src/admin-scope.js";
119
+ export * from "./src/admin-diagnostics.js";
120
+
100
121
  export declare class HttpApiTransport {
101
122
  constructor(options: HttpApiTransportOptions);
102
123
  api: ApiClient;
103
124
  bootstrap: any;
104
125
  start(): Promise<void>;
105
126
  stop(): Promise<void>;
127
+ getLive(sessionId: string, topics?: string[]): Promise<LiveStateRow[]>;
106
128
  [method: string]: any;
107
129
  }
108
130
 
@@ -134,14 +156,14 @@ export type SessionAccessClass =
134
156
  export declare const SESSION_VISIBILITY_VALUES: readonly string[];
135
157
  export declare function normalizeVisibility(value: unknown, fallback: string): string;
136
158
  export declare function systemSessionsReadable(env?: Record<string, string | undefined>): boolean;
137
- export declare function relationFor(snapshot: SessionAccessSnapshot | null, opts?: { isAdmin?: boolean }): "owner" | "admin" | "collaborator";
159
+ export declare function relationFor(snapshot: SessionAccessSnapshot | null, opts?: { isAdmin?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope }): "owner" | "admin" | "collaborator";
138
160
  export declare function evaluateSessionAccess(
139
161
  accessClass: SessionAccessClass,
140
162
  snapshot: SessionAccessSnapshot | null,
141
- opts?: { isAdmin?: boolean; systemReadable?: boolean },
163
+ opts?: { isAdmin?: boolean; systemReadable?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope },
142
164
  ): SessionAccessDecision;
143
165
  /** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
144
166
  export declare function evaluateArchiveAccess(
145
167
  snapshot: SessionAccessSnapshot | null,
146
- opts?: { isAdmin?: boolean },
168
+ opts?: { isAdmin?: boolean; adminScope?: import("./src/admin-scope.js").AdminScope },
147
169
  ): SessionAccessDecision;
package/api/index.js CHANGED
@@ -21,6 +21,8 @@ export {
21
21
  evaluateArchiveAccess,
22
22
  } from "./src/session-authz.js";
23
23
  export { ApiClient } from "./src/api-client.js";
24
+ export { ADMIN_SCOPE_POLICY_VERSION, ADMIN_SCOPES, loadAdminScope, validateAdminScope, adminCanAccessResource, adminCapabilities } from "./src/admin-scope.js";
25
+ export * from "./src/admin-diagnostics.js";
24
26
  export { createCanvasLiveMirror, jsonMergePatch } from "./src/canvas-live-mirror.js";
25
27
  export { HttpApiTransport } from "./src/http-api-transport.js";
26
28
  export {
@@ -0,0 +1,4 @@
1
+ export function projectUserAccounting<T>(stats: T): T;
2
+ export function projectFleetAccounting<T>(stats: T): T;
3
+ export function projectAgentWorkerState<T>(row: T): T;
4
+ export function projectWorker<T>(row: T): T;
@@ -0,0 +1,45 @@
1
+ /** Content-free operational projections. Use only for a restricted viewer. */
2
+ export function projectUserAccounting(stats) {
3
+ return { ...stats, users: (stats.users || []).map((user) => ({
4
+ ...user, sessionIds: [],
5
+ byModel: (user.byModel || []).map((model) => ({ ...model, sessionIds: [] })),
6
+ })) };
7
+ }
8
+
9
+ export function projectFleetAccounting(stats) {
10
+ const groups = new Map();
11
+ for (const row of stats.byAgent || []) {
12
+ const key = row.model ?? null;
13
+ const group = groups.get(key) || { agentId: null, model: key };
14
+ for (const [field, value] of Object.entries(row)) {
15
+ if (typeof value === "number" && field !== "cacheHitRatio") group[field] = (group[field] || 0) + value;
16
+ }
17
+ group.cacheHitRatio = group.totalTokensInput > 0 ? group.totalTokensCacheRead / group.totalTokensInput : null;
18
+ groups.set(key, group);
19
+ }
20
+ return { ...stats, byAgent: [...groups.values()], contentRedacted: true };
21
+ }
22
+
23
+ export function projectAgentWorkerState(row) {
24
+ const installed = Object.values(row.installed || {});
25
+ return { workerNodeId: row.workerNodeId, epoch: row.epoch, updatedAt: row.updatedAt,
26
+ installed: {}, installedCount: installed.length,
27
+ errorCount: installed.filter((entry) => entry?.status === "error").length,
28
+ contentRedacted: true };
29
+ }
30
+
31
+ export function projectWorker(row) {
32
+ const health = {};
33
+ for (const field of ["uptimeS", "rssBytes", "heapUsedBytes", "eventLoopDelayP99Ms", "activeSessions"]) {
34
+ if (typeof row.health?.[field] === "number") health[field] = row.health[field];
35
+ }
36
+ for (const field of ["orchestrationSlots", "workerSlots"]) {
37
+ if (typeof row.health?.[field]?.total === "number") health[field] = { total: row.health[field].total };
38
+ }
39
+ const packages = row.state?.["agent-packages"] || {};
40
+ return { workerNodeId: row.workerNodeId, pool: row.pool, phase: row.phase,
41
+ registeredAt: row.registeredAt, updatedAt: row.updatedAt,
42
+ info: { sdkVersion: row.info?.sdkVersion, authz: row.info?.authz }, health,
43
+ state: { "agent-packages": projectAgentWorkerState({ ...packages, workerNodeId: row.workerNodeId }) },
44
+ contentRedacted: true };
45
+ }
@@ -0,0 +1,12 @@
1
+ export type AdminScope = "unrestricted" | "cluster";
2
+ export declare const ADMIN_SCOPE_POLICY_VERSION: number;
3
+ export declare const ADMIN_SCOPES: readonly AdminScope[];
4
+ export declare function loadAdminScope(env?: Record<string, string | undefined>): AdminScope;
5
+ export declare function validateAdminScope(env: Record<string, string | undefined>, options?: { authenticationEnabled?: boolean }): AdminScope;
6
+ export declare function adminCanAccessResource(isAdmin: boolean | undefined, adminScope?: AdminScope, isSystem?: boolean): boolean;
7
+ export declare function adminCapabilities(isAdmin: boolean, adminScope?: AdminScope): {
8
+ clusterManagement: boolean;
9
+ fleetAccounting: boolean;
10
+ userResourceBypass: boolean;
11
+ systemSessionAdmin: boolean;
12
+ };
@@ -0,0 +1,39 @@
1
+ /** Deployment policy, independent of the authenticated admin role. */
2
+ export const ADMIN_SCOPE_POLICY_VERSION = 1;
3
+ export const ADMIN_SCOPES = Object.freeze(["unrestricted", "cluster"]);
4
+
5
+ export function loadAdminScope(env = (typeof process !== "undefined" ? process.env : {})) {
6
+ const scope = String(env.AUTHZ_ADMIN_SCOPE ?? "unrestricted").trim().toLowerCase();
7
+ if (!ADMIN_SCOPES.includes(scope)) {
8
+ throw new Error("AUTHZ_ADMIN_SCOPE must be cluster or unrestricted.");
9
+ }
10
+ return scope;
11
+ }
12
+
13
+ /** Workers validate enforcement; the portal additionally validates its actual auth provider. */
14
+ export function validateAdminScope(env, { authenticationEnabled } = {}) {
15
+ const scope = loadAdminScope(env);
16
+ if (scope === "cluster") {
17
+ if (!["1", "true", "yes", "on"].includes(String(env.AUTHZ_ENFORCE_OWNERSHIP ?? "").trim().toLowerCase())) {
18
+ throw new Error("AUTHZ_ADMIN_SCOPE=cluster requires AUTHZ_ENFORCE_OWNERSHIP=true.");
19
+ }
20
+ if (authenticationEnabled === false) {
21
+ throw new Error("AUTHZ_ADMIN_SCOPE=cluster requires authentication.");
22
+ }
23
+ }
24
+ return scope;
25
+ }
26
+
27
+ /** System-target access is deliberately unchanged in phase 1. */
28
+ export function adminCanAccessResource(isAdmin, adminScope = "unrestricted", isSystem = false) {
29
+ return Boolean(isAdmin && (adminScope === "unrestricted" || isSystem));
30
+ }
31
+
32
+ export function adminCapabilities(isAdmin, adminScope = "unrestricted") {
33
+ return {
34
+ clusterManagement: Boolean(isAdmin),
35
+ fleetAccounting: Boolean(isAdmin),
36
+ userResourceBypass: adminCanAccessResource(isAdmin, adminScope),
37
+ systemSessionAdmin: Boolean(isAdmin),
38
+ };
39
+ }
@@ -1,4 +1,5 @@
1
1
  import { API_PREFIX, ApiError, WS_PATH, artifactDownloadPath, buildOperationRequest } from "./protocol.js";
2
+ import { jsonMergePatch } from "./canvas-live-mirror.js";
2
3
 
3
4
  const RECONNECT_DELAY_MS = 1500;
4
5
 
@@ -66,6 +67,11 @@ export class ApiClient {
66
67
  this.sessionResubscribeHandlers = new Map();
67
68
  this.logSubscribers = new Set();
68
69
  this.canvasSubscribers = new Map();
70
+ this.liveSubscribers = new Map();
71
+ this.liveSequences = new Map();
72
+ this.liveRefetches = new Map();
73
+ this.liveValues = new Map();
74
+ this.liveRefetchTargets = new Map();
69
75
  }
70
76
 
71
77
  // ── HTTP ────────────────────────────────────────────────────────────
@@ -212,13 +218,22 @@ export class ApiClient {
212
218
  this.socket = null;
213
219
  this.socketOpenPromise = null;
214
220
  this.sessionSubscribers.clear();
221
+ this.sessionResubscribeHandlers.clear();
215
222
  this.logSubscribers.clear();
216
223
  this.canvasSubscribers.clear();
224
+ this.liveSubscribers.clear();
225
+ this.liveSequences.clear();
226
+ this.liveRefetches.clear();
227
+ this.liveValues.clear();
228
+ this.liveRefetchTargets.clear();
217
229
  }
218
230
 
219
231
  scheduleReconnect() {
220
232
  if (this.stopped || this.reconnectTimer) return;
221
- if (this.sessionSubscribers.size === 0 && this.logSubscribers.size === 0) return;
233
+ if (this.sessionSubscribers.size === 0
234
+ && this.logSubscribers.size === 0
235
+ && this.canvasSubscribers.size === 0
236
+ && this.liveSubscribers.size === 0) return;
222
237
  this.reconnectTimer = setTimeout(() => {
223
238
  this.reconnectTimer = null;
224
239
  this.ensureSocket().catch(() => {});
@@ -262,6 +277,24 @@ export class ApiClient {
262
277
  socket.addEventListener("message", (event) => {
263
278
  try {
264
279
  const message = JSON.parse(String(event.data || ""));
280
+ if (message.type === "error" && message.code === "ACCESS_REVOKED" && message.sessionId) {
281
+ for (const key of this.liveValues.keys()) {
282
+ if (key.startsWith(`${message.sessionId}\u0000`)) this.liveValues.delete(key);
283
+ }
284
+ for (const key of this.liveSubscribers.keys()) {
285
+ const [sessionId, topic] = key.split("\u0000");
286
+ if (sessionId !== message.sessionId) continue;
287
+ // Use the normal invalidation path: besides the
288
+ // retained value it invalidates an in-flight gap
289
+ // fetch, so its older authorized reply cannot
290
+ // repopulate a revoked live-only subscription.
291
+ void this.handleLiveMessage({ kind: "unavailable", sessionId, topic, accessRevoked: true });
292
+ }
293
+ for (const handler of this.sessionSubscribers.get(message.sessionId) || []) {
294
+ handler({ eventType: "session.access_revoked", sessionId: message.sessionId });
295
+ }
296
+ return;
297
+ }
265
298
  if (message.type === "sessionEvent") {
266
299
  const handlers = this.sessionSubscribers.get(message.sessionId);
267
300
  if (handlers) {
@@ -280,6 +313,10 @@ export class ApiClient {
280
313
  }
281
314
  return;
282
315
  }
316
+ if (message.type === "live") {
317
+ void this.handleLiveMessage(message);
318
+ return;
319
+ }
283
320
  // A canvas-scope error (plane unavailable, authz change,
284
321
  // server rollback) releases the mirror's takeover of that
285
322
  // session — otherwise legacy events would stay suppressed
@@ -291,12 +328,25 @@ export class ApiClient {
291
328
  for (const handler of handlers) handler({ kind: "unavailable", sessionId: message.sessionId });
292
329
  }
293
330
  }
331
+ if (message.type === "error" && message.scope === "live" && message.sessionId) {
332
+ for (const key of this.liveSubscribers.keys()) {
333
+ if (!key.startsWith(`${message.sessionId}\u0000`)) continue;
334
+ void this.handleLiveMessage({ kind: "unavailable", sessionId: message.sessionId, topic: key.split("\u0000")[1] });
335
+ }
336
+ }
294
337
  } catch {}
295
338
  });
296
339
 
297
340
  socket.addEventListener("close", (event) => {
298
341
  this.socket = null;
299
342
  this.socketOpenPromise = null;
343
+ for (const [key, handlers] of this.liveSubscribers) {
344
+ const [sessionId, topic] = key.split("\u0000");
345
+ for (const handler of handlers) {
346
+ try { handler({ kind: "unavailable", sessionId, topic }); } catch {}
347
+ }
348
+ }
349
+ this.liveValues.clear();
300
350
  if (event.code === 4401) {
301
351
  this.onUnauthorized();
302
352
  return;
@@ -342,7 +392,114 @@ export class ApiClient {
342
392
  }
343
393
  }
344
394
 
395
+ async handleLiveMessage(message) {
396
+ const key = `${message.sessionId}\u0000${message.topic}`;
397
+ const handlers = this.liveSubscribers.get(key);
398
+ if (!handlers || handlers.size === 0) return;
399
+ if (message.kind === "unavailable") {
400
+ this.liveSequences.delete(key);
401
+ this.liveValues.delete(key);
402
+ this.liveRefetches.delete(key);
403
+ this.liveRefetchTargets.delete(key);
404
+ for (const handler of handlers) { try { handler(message); } catch {} }
405
+ return;
406
+ }
407
+ if (message.kind === "signal") {
408
+ for (const handler of handlers) {
409
+ try { handler(message); } catch {}
410
+ }
411
+ return;
412
+ }
413
+
414
+ const seq = Number(message.seq);
415
+ if (!Number.isFinite(seq) || seq < 1) return;
416
+ const previous = this.liveSequences.get(key);
417
+ if (previous != null && seq === previous) return;
418
+ if (message.kind === "snapshot" && previous != null && seq < previous) return;
419
+
420
+ // A patch is meaningful only against its immediate predecessor. The
421
+ // subscribe burst is a snapshot, so no predecessor means a missed
422
+ // burst too. Collapse concurrent gaps into one authoritative read.
423
+ if (message.kind === "patch" && (previous == null || seq !== previous + 1)) {
424
+ if (seq > Number(this.liveRefetchTargets.get(key)?.seq || 0)) {
425
+ this.liveRefetchTargets.set(key, message);
426
+ }
427
+ if (!this.liveRefetches.has(key)) {
428
+ const allowSequenceReset = previous != null && seq < previous;
429
+ const refetch = this.call("getLive", {
430
+ sessionId: message.sessionId,
431
+ topics: [message.topic],
432
+ }).then((rows) => {
433
+ const row = (rows || []).find((candidate) => candidate?.topic === message.topic);
434
+ const currentHandlers = this.liveSubscribers.get(key);
435
+ // The last subscriber may have left and a new subscription
436
+ // for the same key may already exist. Do not let the old
437
+ // request inject its snapshot into that new subscription.
438
+ if (!row || currentHandlers !== handlers || currentHandlers.size === 0
439
+ || this.liveRefetches.get(key) !== refetch) return;
440
+ const snapshotSeq = Number(row.seq);
441
+ if (!Number.isFinite(snapshotSeq) || snapshotSeq < 1) return;
442
+ const seen = this.liveSequences.get(key);
443
+ if (seen != null && (
444
+ (!allowSequenceReset && snapshotSeq <= seen)
445
+ || (allowSequenceReset && (snapshotSeq === seen || (snapshotSeq < seen && seen !== previous)))
446
+ )) return;
447
+ this.liveSequences.set(key, snapshotSeq);
448
+ const snapshot = {
449
+ type: "live",
450
+ sessionId: message.sessionId,
451
+ topic: message.topic,
452
+ seq: snapshotSeq,
453
+ kind: "snapshot",
454
+ data: row.payload || {},
455
+ ...(row.updatedAt ? { updatedAt: row.updatedAt } : {}),
456
+ };
457
+ this.liveValues.set(key, snapshot);
458
+ for (const handler of currentHandlers) {
459
+ try { handler(snapshot); } catch {}
460
+ }
461
+ }).catch(() => {
462
+ // A later notification retries the authoritative read;
463
+ // durable session events remain the product fallback.
464
+ }).finally(() => {
465
+ // A replacement subscription can already have started a
466
+ // newer refetch for this key; do not erase its guard.
467
+ if (this.liveRefetches.get(key) === refetch) {
468
+ this.liveRefetches.delete(key);
469
+ const latest = this.liveRefetchTargets.get(key);
470
+ this.liveRefetchTargets.delete(key);
471
+ // A notification that arrived DURING the read may be
472
+ // newer than its snapshot. Do not lose that last tick.
473
+ if (latest && latest !== message && this.liveSubscribers.get(key) === handlers) {
474
+ void this.handleLiveMessage(latest);
475
+ }
476
+ }
477
+ });
478
+ this.liveRefetches.set(key, refetch);
479
+ }
480
+ await this.liveRefetches.get(key);
481
+ return;
482
+ }
483
+
484
+ this.liveSequences.set(key, seq);
485
+ this.liveValues.set(key, {
486
+ ...message, kind: "snapshot",
487
+ data: message.kind === "patch"
488
+ ? jsonMergePatch(this.liveValues.get(key)?.data || {}, message.data || {})
489
+ : message.data,
490
+ });
491
+ for (const handler of handlers) {
492
+ try { handler(message); } catch {}
493
+ }
494
+ }
495
+
345
496
  resubscribeAll(isReconnect = false) {
497
+ if (isReconnect) {
498
+ this.liveSequences.clear();
499
+ this.liveValues.clear();
500
+ this.liveRefetches.clear();
501
+ this.liveRefetchTargets.clear();
502
+ }
346
503
  for (const sessionId of this.sessionSubscribers.keys()) {
347
504
  this.socketSend({ type: "subscribeSession", sessionId });
348
505
  // On a RECONNECT, live delivery resumes but events emitted during
@@ -363,6 +520,15 @@ export class ApiClient {
363
520
  for (const sessionId of this.canvasSubscribers.keys()) {
364
521
  this.socketSend({ type: "subscribeCanvas", sessionId });
365
522
  }
523
+ const liveBySession = new Map();
524
+ for (const key of this.liveSubscribers.keys()) {
525
+ const [sessionId, topic] = key.split("\u0000");
526
+ if (!liveBySession.has(sessionId)) liveBySession.set(sessionId, []);
527
+ liveBySession.get(sessionId).push(topic);
528
+ }
529
+ for (const [sessionId, topics] of liveBySession) {
530
+ this.socketSend({ type: "subscribeLive", sessionId, topics });
531
+ }
366
532
  }
367
533
 
368
534
  /**
@@ -432,6 +598,53 @@ export class ApiClient {
432
598
  };
433
599
  }
434
600
 
601
+ /** Subscribe to one generic live-plane topic for a session. */
602
+ subscribeLive(sessionId, topic, handler) {
603
+ const normalizedSessionId = String(sessionId || "").trim();
604
+ const normalizedTopic = String(topic || "").trim();
605
+ if (!normalizedSessionId || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(normalizedTopic)) {
606
+ throw new Error("subscribeLive requires a session id and valid topic");
607
+ }
608
+ const key = `${normalizedSessionId}\u0000${normalizedTopic}`;
609
+ const alreadySubscribed = this.liveSubscribers.has(key);
610
+ if (!alreadySubscribed && [...this.liveSubscribers.keys()].filter((candidate) => candidate.startsWith(`${normalizedSessionId}\u0000`)).length >= 16) {
611
+ throw new Error("At most 16 live topics per session are supported");
612
+ }
613
+ if (!this.liveSubscribers.has(key)) this.liveSubscribers.set(key, new Set());
614
+ const handlers = this.liveSubscribers.get(key);
615
+ handlers.add(handler);
616
+ const topics = [];
617
+ for (const candidate of this.liveSubscribers.keys()) {
618
+ const [sid, liveTopic] = candidate.split("\u0000");
619
+ if (sid === normalizedSessionId) topics.push(liveTopic);
620
+ }
621
+ if (!alreadySubscribed) this.announceSubscription({ type: "subscribeLive", sessionId: normalizedSessionId, topics });
622
+ else if (this.liveValues.has(key)) {
623
+ try { handler(this.liveValues.get(key)); } catch {}
624
+ }
625
+ let active = true;
626
+ return () => {
627
+ if (!active) return;
628
+ active = false;
629
+ handlers.delete(handler);
630
+ if (handlers.size > 0 || this.liveSubscribers.get(key) !== handlers) return;
631
+ this.liveSubscribers.delete(key);
632
+ this.liveSequences.delete(key);
633
+ this.liveRefetches.delete(key);
634
+ this.liveRefetchTargets.delete(key);
635
+ this.liveValues.delete(key);
636
+ const remaining = [];
637
+ for (const candidate of this.liveSubscribers.keys()) {
638
+ const [sid, liveTopic] = candidate.split("\u0000");
639
+ if (sid === normalizedSessionId) remaining.push(liveTopic);
640
+ }
641
+ this.socketSend({ type: "unsubscribeLive", sessionId: normalizedSessionId });
642
+ if (remaining.length > 0) {
643
+ this.socketSend({ type: "subscribeLive", sessionId: normalizedSessionId, topics: remaining });
644
+ }
645
+ };
646
+ }
647
+
435
648
  subscribeLogs(handler) {
436
649
  this.logSubscribers.add(handler);
437
650
  this.announceSubscription({ type: "subscribeLogs" });
@@ -105,7 +105,8 @@ export class HttpApiTransport {
105
105
  }
106
106
 
107
107
  getAuthContext() {
108
- return this.bootstrap?.auth || {
108
+ if (this.bootstrap?.auth) return { ...this.bootstrap.auth, adminScope: this.bootstrap.authz?.adminScope ?? "unrestricted" };
109
+ return {
109
110
  principal: null,
110
111
  authorization: { allowed: false, role: null, reason: "Auth context unavailable", matchedGroups: [] },
111
112
  };
@@ -496,6 +497,10 @@ export class HttpApiTransport {
496
497
  return this.api.call("getCanvasLive", { sessionId });
497
498
  }
498
499
 
500
+ async getLive(sessionId, topics) {
501
+ return this.api.call("getLive", { sessionId, topics });
502
+ }
503
+
499
504
  // The canvas KV store (door 1 — signed-in browser).
500
505
  async readCanvasKv(sessionId, slot, query = {}) {
501
506
  return this.api.call("readCanvasKv", {
@@ -667,9 +672,23 @@ export class HttpApiTransport {
667
672
  }
668
673
  }
669
674
  });
675
+ const unsubscribeLive = this.api.subscribeLive(sessionId, "turn", (message) => {
676
+ if (message?.kind === "signal") return;
677
+ const data = message?.kind === "unavailable" ? { phase: "idle" } : message?.data;
678
+ if (!data || typeof data !== "object") return;
679
+ handler({
680
+ eventType: "assistant.live_tick",
681
+ sessionId,
682
+ transient: true,
683
+ liveSeq: Number(message.seq) || 0,
684
+ liveUpdatedAt: message.updatedAt,
685
+ data,
686
+ });
687
+ });
670
688
  return () => {
671
689
  unsubscribeEvents();
672
690
  unsubscribeCanvas();
691
+ unsubscribeLive();
673
692
  const handlers = this._canvasEmitHandlers.get(sessionId);
674
693
  if (handlers) {
675
694
  handlers.delete(handler);
@@ -41,8 +41,8 @@ export const API_VERSION = 1;
41
41
  export const WS_PATH = "/api/v1/ws";
42
42
 
43
43
  /** WebSocket message vocabulary (same as the legacy /portal-ws, minus theme). */
44
- export const WS_CLIENT_MESSAGES = ["subscribeSession", "unsubscribeSession", "subscribeLogs", "unsubscribeLogs"];
45
- export const WS_SERVER_MESSAGES = ["ready", "subscribedSession", "sessionEvent", "subscribedLogs", "logEntry", "error"];
44
+ export const WS_CLIENT_MESSAGES = ["subscribeSession", "unsubscribeSession", "subscribeLive", "unsubscribeLive", "subscribeLogs", "unsubscribeLogs"];
45
+ export const WS_SERVER_MESSAGES = ["ready", "subscribedSession", "sessionEvent", "subscribedLive", "live", "subscribedLogs", "logEntry", "error"];
46
46
 
47
47
  /** Error code used when an SDK web-mode method has no API equivalent. */
48
48
  export const WEB_MODE_UNSUPPORTED = "WEB_MODE_UNSUPPORTED";
@@ -114,6 +114,7 @@ export const OPERATIONS = [
114
114
  { name: "getSessionEvents", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/events", params: { sessionId: path("sessionId"), afterSeq: query("number"), limit: query("number"), eventTypes: query("json") }, summary: "Session events after a sequence number (reconnect catch-up). Optional eventTypes (JSON string array) narrows to those event types server-side." },
115
115
  { name: "getSessionEventsBefore", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/events-before", params: { sessionId: path("sessionId"), beforeSeq: query("number"), limit: query("number"), eventTypes: query("json") }, summary: "Older session events for history paging. Optional eventTypes (JSON string array) narrows to those event types server-side (chat transcript paging)." },
116
116
  { name: "getCanvasLive", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/canvas-live", params: { sessionId: path("sessionId") }, summary: "The canvas data plane's last-value rows: current doc pointer + latest merged tick per slot. Snapshot source for live canvas subscriptions; empty when the deployment predates the plane." },
117
+ { name: "getLive", access: "session:read", method: "GET", path: "/management/sessions/:sessionId/live", params: { sessionId: path("sessionId"), topics: query("json") }, summary: "Current retained values from the ephemeral live plane, optionally filtered by topic." },
117
118
  // The canvas KV store (interactive-canvas-apps Part C). canvas:read and
118
119
  // canvas:write are their OWN access classes: both gate on session read
119
120
  // (a canvas reader may hold no session write), and whether a read-only
@@ -8,6 +8,8 @@
8
8
  * gate that always passes.
9
9
  */
10
10
 
11
+ import type { AdminScope } from "./admin-scope.js";
12
+
11
13
  export interface SessionAccessSnapshot {
12
14
  rootSessionId?: string;
13
15
  isSystem: boolean;
@@ -42,19 +44,19 @@ export declare function systemSessionsReadable(env?: Record<string, string | und
42
44
 
43
45
  export declare function relationFor(
44
46
  snapshot: SessionAccessSnapshot | null,
45
- opts?: { isAdmin?: boolean },
47
+ opts?: { isAdmin?: boolean; adminScope?: AdminScope },
46
48
  ): "owner" | "admin" | "collaborator";
47
49
 
48
50
  export declare function evaluateSessionAccess(
49
51
  accessClass: SessionAccessClass,
50
52
  snapshot: SessionAccessSnapshot | null,
51
- opts?: { isAdmin?: boolean; systemReadable?: boolean },
53
+ opts?: { isAdmin?: boolean; systemReadable?: boolean; adminScope?: AdminScope },
52
54
  ): SessionAccessDecision;
53
55
 
54
56
  /** Archive reads are owner-or-admin ONLY — never a share. See proposal §15 A3. */
55
57
  export declare function evaluateArchiveAccess(
56
58
  snapshot: SessionAccessSnapshot | null,
57
- opts?: { isAdmin?: boolean },
59
+ opts?: { isAdmin?: boolean; adminScope?: AdminScope },
58
60
  ): SessionAccessDecision;
59
61
 
60
62
  /** A sign-in role observation read from the users table. */
@@ -1,3 +1,5 @@
1
+ import { adminCanAccessResource } from "./admin-scope.js";
2
+
1
3
  /**
2
4
  * Session-tree access predicate — the ONE implementation, shared by every
3
5
  * surface that has to answer "may this principal touch this session?".
@@ -54,9 +56,9 @@ function ownerLabel(snapshot) {
54
56
  * The caller's relation to a session tree, recorded on message payloads and
55
57
  * shown to the agent in multi-writer sessions.
56
58
  */
57
- export function relationFor(snapshot, { isAdmin } = {}) {
59
+ export function relationFor(snapshot, { isAdmin, adminScope = "unrestricted" } = {}) {
58
60
  if (snapshot?.viewerIsOwner) return "owner";
59
- if (isAdmin) return "admin";
61
+ if (adminCanAccessResource(isAdmin, adminScope, snapshot?.isSystem)) return "admin";
60
62
  return "collaborator";
61
63
  }
62
64
 
@@ -68,14 +70,14 @@ export function relationFor(snapshot, { isAdmin } = {}) {
68
70
  * @param {{isAdmin?: boolean, systemReadable?: boolean}} [opts]
69
71
  * @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
70
72
  */
71
- export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true } = {}) {
73
+ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false, systemReadable = true, adminScope = "unrestricted" } = {}) {
72
74
  if (!snapshot) {
73
75
  // Missing/deleted session: let the underlying operation produce its
74
76
  // own not-found; nothing to protect.
75
77
  return { allowed: true };
76
78
  }
77
79
 
78
- if (isAdmin) {
80
+ if (adminCanAccessResource(isAdmin, adminScope, snapshot.isSystem)) {
79
81
  // Admins pass everything; flag break-glass when this would have been
80
82
  // invisible to a plain user in the same position.
81
83
  const wouldBeInvisible = !snapshot.viewerIsOwner
@@ -120,7 +122,7 @@ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false,
120
122
  // manage / destroy / share: owner only (admin handled above).
121
123
  return snapshot.viewerIsOwner
122
124
  ? { allowed: true }
123
- : { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)}) or an admin can do this.` };
125
+ : { allowed: false, reason: `Only the session owner (${ownerLabel(snapshot)})${adminScope === "unrestricted" ? " or an admin" : ""} can do this.` };
124
126
  }
125
127
 
126
128
  /**
@@ -137,9 +139,9 @@ export function evaluateSessionAccess(accessClass, snapshot, { isAdmin = false,
137
139
  * @param {{isAdmin?: boolean}} [opts]
138
140
  * @returns {{allowed: boolean, notFound?: boolean, reason?: string, breakGlass?: boolean}}
139
141
  */
140
- export function evaluateArchiveAccess(snapshot, { isAdmin = false } = {}) {
142
+ export function evaluateArchiveAccess(snapshot, { isAdmin = false, adminScope = "unrestricted" } = {}) {
141
143
  if (!snapshot) return { allowed: true };
142
- if (isAdmin) {
144
+ if (adminCanAccessResource(isAdmin, adminScope, snapshot.isSystem)) {
143
145
  // Reading someone else's raw session state is exactly what the
144
146
  // authz_audit table calls a break-glass read.
145
147
  return { allowed: true, breakGlass: !snapshot.viewerIsOwner };
@@ -149,7 +151,7 @@ export function evaluateArchiveAccess(snapshot, { isAdmin = false } = {}) {
149
151
  return {
150
152
  allowed: false,
151
153
  reason:
152
- "Session archives are available to the session owner and administrators only. "
154
+ (adminScope === "cluster" ? "Session archives are available to the session owner only. " : "Session archives are available to the session owner and administrators only. ")
153
155
  + "A share grants the conversation, not the raw session state behind it.",
154
156
  };
155
157
  }