agent-tempo 1.3.1 → 1.4.1

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 (199) hide show
  1. package/CLAUDE.md +39 -5
  2. package/README.md +6 -2
  3. package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
  4. package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
  5. package/dashboard/dist/index.html +1 -1
  6. package/dashboard/package.json +1 -1
  7. package/dist/activities/outbox.d.ts +30 -1
  8. package/dist/activities/outbox.js +96 -3
  9. package/dist/adapters/base.js +5 -0
  10. package/dist/adapters/index.d.ts +1 -1
  11. package/dist/adapters/index.js +7 -0
  12. package/dist/adapters/pi/adapter.d.ts +2 -0
  13. package/dist/adapters/pi/adapter.js +43 -0
  14. package/dist/adapters/pi/index.d.ts +16 -0
  15. package/dist/adapters/pi/index.js +10 -0
  16. package/dist/client/core.js +9 -2
  17. package/dist/client/interface.d.ts +6 -0
  18. package/dist/config.d.ts +79 -0
  19. package/dist/config.js +74 -0
  20. package/dist/daemon.js +32 -1
  21. package/dist/http/aggregate.d.ts +22 -1
  22. package/dist/http/aggregate.js +41 -0
  23. package/dist/http/auth.d.ts +94 -8
  24. package/dist/http/auth.js +93 -9
  25. package/dist/http/body.d.ts +4 -1
  26. package/dist/http/body.js +6 -3
  27. package/dist/http/event-bus.js +1 -0
  28. package/dist/http/event-types.d.ts +34 -2
  29. package/dist/http/event-types.js +1 -0
  30. package/dist/http/gate-audit.d.ts +12 -0
  31. package/dist/http/gate-audit.js +95 -0
  32. package/dist/http/gate-registry.d.ts +167 -0
  33. package/dist/http/gate-registry.js +163 -0
  34. package/dist/http/gate-routes.d.ts +48 -0
  35. package/dist/http/gate-routes.js +102 -0
  36. package/dist/http/ingest-registry.d.ts +30 -0
  37. package/dist/http/ingest-registry.js +108 -0
  38. package/dist/http/inner-loop-routes.d.ts +66 -0
  39. package/dist/http/inner-loop-routes.js +182 -0
  40. package/dist/http/inner-loop.d.ts +92 -0
  41. package/dist/http/inner-loop.js +155 -0
  42. package/dist/http/server.d.ts +38 -3
  43. package/dist/http/server.js +211 -6
  44. package/dist/http/snapshot.d.ts +6 -0
  45. package/dist/http/snapshot.js +6 -0
  46. package/dist/pi/cue-pump.d.ts +61 -0
  47. package/dist/pi/cue-pump.js +95 -0
  48. package/dist/pi/extension.d.ts +45 -0
  49. package/dist/pi/extension.js +407 -0
  50. package/dist/pi/gate-client.d.ts +54 -0
  51. package/dist/pi/gate-client.js +136 -0
  52. package/dist/pi/headless.d.ts +85 -0
  53. package/dist/pi/headless.js +250 -0
  54. package/dist/pi/index.d.ts +28 -0
  55. package/dist/pi/index.js +43 -0
  56. package/dist/pi/inner-loop-client.d.ts +67 -0
  57. package/dist/pi/inner-loop-client.js +164 -0
  58. package/dist/pi/inner-loop-publisher.d.ts +187 -0
  59. package/dist/pi/inner-loop-publisher.js +236 -0
  60. package/dist/pi/lazy-proxy.d.ts +37 -0
  61. package/dist/pi/lazy-proxy.js +55 -0
  62. package/dist/pi/mission-control/actions.d.ts +48 -0
  63. package/dist/pi/mission-control/actions.js +98 -0
  64. package/dist/pi/mission-control/board.d.ts +88 -0
  65. package/dist/pi/mission-control/board.js +141 -0
  66. package/dist/pi/mission-control/extension.d.ts +51 -0
  67. package/dist/pi/mission-control/extension.js +330 -0
  68. package/dist/pi/mission-control/index.d.ts +15 -0
  69. package/dist/pi/mission-control/index.js +32 -0
  70. package/dist/pi/mission-control/inner-tail.d.ts +48 -0
  71. package/dist/pi/mission-control/inner-tail.js +76 -0
  72. package/dist/pi/mission-control/pi-ui.d.ts +43 -0
  73. package/dist/pi/mission-control/pi-ui.js +10 -0
  74. package/dist/pi/mission-control/render.d.ts +6 -0
  75. package/dist/pi/mission-control/render.js +98 -0
  76. package/dist/pi/phase-driver.d.ts +74 -0
  77. package/dist/pi/phase-driver.js +122 -0
  78. package/dist/pi/pi-types.d.ts +222 -0
  79. package/dist/pi/pi-types.js +21 -0
  80. package/dist/pi/probe.d.ts +99 -0
  81. package/dist/pi/probe.js +179 -0
  82. package/dist/pi/render-tools.d.ts +17 -0
  83. package/dist/pi/render-tools.js +56 -0
  84. package/dist/pi/reset-pump.d.ts +47 -0
  85. package/dist/pi/reset-pump.js +85 -0
  86. package/dist/pi/session-seed.d.ts +74 -0
  87. package/dist/pi/session-seed.js +103 -0
  88. package/dist/pi/tool-capability.d.ts +60 -0
  89. package/dist/pi/tool-capability.js +156 -0
  90. package/dist/pi/workflow-client.d.ts +158 -0
  91. package/dist/pi/workflow-client.js +289 -0
  92. package/dist/pi/zod-to-typebox.d.ts +74 -0
  93. package/dist/pi/zod-to-typebox.js +191 -0
  94. package/dist/server-tools.d.ts +2 -0
  95. package/dist/server-tools.js +50 -46
  96. package/dist/spawn.d.ts +55 -0
  97. package/dist/spawn.js +72 -0
  98. package/dist/tools/agent-types.d.ts +2 -2
  99. package/dist/tools/agent-types.js +22 -17
  100. package/dist/tools/attachment-info.d.ts +2 -2
  101. package/dist/tools/attachment-info.js +38 -33
  102. package/dist/tools/broadcast.d.ts +2 -2
  103. package/dist/tools/broadcast.js +69 -64
  104. package/dist/tools/cancel-stage.d.ts +2 -2
  105. package/dist/tools/cancel-stage.js +20 -15
  106. package/dist/tools/clear-state.d.ts +2 -2
  107. package/dist/tools/clear-state.js +25 -20
  108. package/dist/tools/coat-check-evict.d.ts +2 -2
  109. package/dist/tools/coat-check-evict.js +29 -24
  110. package/dist/tools/coat-check-get.d.ts +2 -2
  111. package/dist/tools/coat-check-get.js +38 -33
  112. package/dist/tools/coat-check-list.d.ts +2 -2
  113. package/dist/tools/coat-check-list.js +48 -43
  114. package/dist/tools/coat-check-put.d.ts +2 -2
  115. package/dist/tools/coat-check-put.js +38 -33
  116. package/dist/tools/cue.d.ts +2 -2
  117. package/dist/tools/cue.js +57 -52
  118. package/dist/tools/descriptor.d.ts +72 -0
  119. package/dist/tools/descriptor.js +39 -0
  120. package/dist/tools/destroy.d.ts +2 -2
  121. package/dist/tools/destroy.js +153 -148
  122. package/dist/tools/ensemble.d.ts +2 -2
  123. package/dist/tools/ensemble.js +71 -66
  124. package/dist/tools/evaluate-gate.d.ts +2 -2
  125. package/dist/tools/evaluate-gate.js +33 -27
  126. package/dist/tools/fetch-state.d.ts +2 -2
  127. package/dist/tools/fetch-state.js +42 -37
  128. package/dist/tools/gates.d.ts +2 -2
  129. package/dist/tools/gates.js +39 -34
  130. package/dist/tools/hosts.d.ts +2 -2
  131. package/dist/tools/hosts.js +25 -20
  132. package/dist/tools/listen.d.ts +2 -2
  133. package/dist/tools/listen.js +23 -18
  134. package/dist/tools/load-lineup.d.ts +2 -2
  135. package/dist/tools/load-lineup.js +324 -319
  136. package/dist/tools/migrate.d.ts +2 -2
  137. package/dist/tools/migrate.js +45 -40
  138. package/dist/tools/pause.d.ts +2 -2
  139. package/dist/tools/pause.js +34 -29
  140. package/dist/tools/play.d.ts +2 -2
  141. package/dist/tools/play.js +53 -48
  142. package/dist/tools/quality-gate.d.ts +2 -2
  143. package/dist/tools/quality-gate.js +26 -21
  144. package/dist/tools/recall.d.ts +2 -2
  145. package/dist/tools/recall.js +32 -27
  146. package/dist/tools/recruit.d.ts +2 -2
  147. package/dist/tools/recruit.js +340 -256
  148. package/dist/tools/release.d.ts +2 -2
  149. package/dist/tools/release.js +85 -80
  150. package/dist/tools/report.d.ts +2 -2
  151. package/dist/tools/report.js +28 -23
  152. package/dist/tools/reset.d.ts +3 -0
  153. package/dist/tools/reset.js +51 -0
  154. package/dist/tools/restart.d.ts +2 -2
  155. package/dist/tools/restart.js +51 -46
  156. package/dist/tools/restore.d.ts +2 -2
  157. package/dist/tools/restore.js +76 -71
  158. package/dist/tools/save-lineup.d.ts +2 -2
  159. package/dist/tools/save-lineup.js +32 -27
  160. package/dist/tools/save-state.d.ts +2 -2
  161. package/dist/tools/save-state.js +31 -26
  162. package/dist/tools/schedule.d.ts +2 -2
  163. package/dist/tools/schedule.js +133 -128
  164. package/dist/tools/schedules.d.ts +2 -2
  165. package/dist/tools/schedules.js +41 -36
  166. package/dist/tools/set-ensemble-description.d.ts +2 -2
  167. package/dist/tools/set-ensemble-description.js +26 -21
  168. package/dist/tools/set-name.d.ts +2 -2
  169. package/dist/tools/set-name.js +38 -33
  170. package/dist/tools/set-part.d.ts +2 -2
  171. package/dist/tools/set-part.js +20 -15
  172. package/dist/tools/shutdown.d.ts +2 -2
  173. package/dist/tools/shutdown.js +39 -34
  174. package/dist/tools/stage.d.ts +2 -2
  175. package/dist/tools/stage.js +28 -23
  176. package/dist/tools/stages.d.ts +2 -2
  177. package/dist/tools/stages.js +36 -31
  178. package/dist/tools/unschedule.d.ts +2 -2
  179. package/dist/tools/unschedule.js +30 -25
  180. package/dist/tools/who-am-i.d.ts +2 -2
  181. package/dist/tools/who-am-i.js +36 -31
  182. package/dist/tools/worktree.d.ts +2 -2
  183. package/dist/tools/worktree.js +134 -129
  184. package/dist/tui/index.js +6 -6
  185. package/dist/types.d.ts +47 -2
  186. package/dist/types.js +1 -1
  187. package/dist/utils/default-part.js +1 -0
  188. package/dist/utils/sdk-probe.d.ts +23 -0
  189. package/dist/utils/sdk-probe.js +46 -7
  190. package/dist/worker.d.ts +3 -1
  191. package/dist/worker.js +6 -2
  192. package/dist/workflows/session.js +70 -2
  193. package/dist/workflows/signals.d.ts +32 -2
  194. package/dist/workflows/signals.js +25 -2
  195. package/package.json +4 -1
  196. package/workflow-bundle.js +97 -6
  197. package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
  198. package/dist/tools/helpers.d.ts +0 -21
  199. package/dist/tools/helpers.js +0 -25
@@ -56,6 +56,8 @@ exports.handle = handle;
56
56
  const http = __importStar(require("http"));
57
57
  const config_1 = require("../config");
58
58
  const auth_1 = require("./auth");
59
+ const inner_loop_routes_1 = require("./inner-loop-routes");
60
+ const gate_routes_1 = require("./gate-routes");
59
61
  const cors_1 = require("./cors");
60
62
  const dashboard_1 = require("./dashboard");
61
63
  const dashboard_pair_1 = require("./dashboard-pair");
@@ -98,10 +100,46 @@ async function startHttpServer(opts) {
98
100
  // generation now so the daemon doesn't crash mid-request when the first
99
101
  // bearer-required call shows up.
100
102
  const bindIsLoopback = (0, auth_1.isLoopbackBindAddr)(bindAddr);
101
- const httpToken = opts.httpToken ?? (0, auth_1.loadOrGenerateHttpToken)({ bearerRequired: !bindIsLoopback });
102
- if (!bindIsLoopback && !httpToken) {
103
+ // 3e RBAC token resolution. Back-compat: a single `httpToken` option (or a
104
+ // legacy config.json `httpToken`) is adopted as the READ token (T1); the ADMIN
105
+ // token is env-var-only. Explicit `readToken`/`adminToken` options override
106
+ // (used by tests). loopback bind ⇒ no bearer required ⇒ tokens may be null.
107
+ let readToken;
108
+ let legacyMigrated = false;
109
+ if (opts.readToken !== undefined) {
110
+ readToken = opts.readToken;
111
+ }
112
+ else if (opts.httpToken !== undefined) {
113
+ // Back-compat: a single injected bearer is treated as the READ token (T1).
114
+ readToken = opts.httpToken;
115
+ }
116
+ else {
117
+ const loaded = (0, auth_1.loadReadToken)({ bearerRequired: !bindIsLoopback });
118
+ readToken = loaded.token;
119
+ legacyMigrated = loaded.legacy;
120
+ }
121
+ const adminToken = opts.adminToken ?? (0, auth_1.loadAdminToken)();
122
+ if (!bindIsLoopback && !readToken) {
103
123
  throw new Error('Bearer token required for non-loopback bind but none configured. ' +
104
- 'Set httpToken in ~/.agent-tempo/config.json or unset AGENT_TEMPO_HTTP_BIND.');
124
+ 'Set AGENT_TEMPO_HTTP_READ_TOKEN (or readToken in ~/.agent-tempo/config.json), ' +
125
+ 'or unset AGENT_TEMPO_HTTP_BIND.');
126
+ }
127
+ // 3e MD-E — one-time startup warnings (non-blocking).
128
+ //
129
+ // (1) Legacy migration: a pre-3e single `httpToken` was adopted as the READ
130
+ // token (T1) and no admin token is configured, so writes / operator gate /
131
+ // inner-tail (all Tier ≥ 2) will 503 until an admin token is set.
132
+ if (legacyMigrated && adminToken === null) {
133
+ log('NOTICE: adopted legacy config.json `httpToken` as the read-tier token. ' +
134
+ 'Writes, the operator gate, and the inner-tail are admin-only and will return ' +
135
+ '503 until you set AGENT_TEMPO_HTTP_ADMIN_TOKEN (env-var only).');
136
+ }
137
+ // (2) Plaintext-bearer exposure: binding to a non-loopback address serves the
138
+ // bearer token over cleartext HTTP. Suppressible, never blocking.
139
+ if (!bindIsLoopback && process.env[config_1.ENV.TLS_ACKNOWLEDGED] !== '1') {
140
+ log(`WARNING: binding to non-loopback ${bindAddr} serves the bearer token over ` +
141
+ 'plaintext HTTP. Terminate TLS at a reverse proxy, or tunnel via SSH/Tailscale. ' +
142
+ `Set ${config_1.ENV.TLS_ACKNOWLEDGED}=1 to acknowledge and suppress this warning.`);
105
143
  }
106
144
  const startedAt = opts.startedAtMs ?? Date.now();
107
145
  // §7.3 process-wide cap. Defaults to 100 per spec; env var override.
@@ -122,11 +160,15 @@ async function startHttpServer(opts) {
122
160
  version: opts.version,
123
161
  bindAddr,
124
162
  corsConfig,
125
- httpToken,
163
+ readToken,
164
+ adminToken,
126
165
  startedAt,
127
166
  subscriberCount,
128
167
  aggregate: opts.aggregate ?? null,
129
168
  sseConnectionCap,
169
+ innerLoop: opts.innerLoop ?? null,
170
+ ingestTokens: opts.ingestTokens ?? null,
171
+ gate: opts.gate ?? null,
130
172
  }).catch((err) => {
131
173
  log('unhandled handler error:', err instanceof Error ? err.message : err);
132
174
  if (!res.headersSent) {
@@ -233,12 +275,53 @@ async function handle(req, res, ctx) {
233
275
  if (method === 'GET' && pairConsumeMatch) {
234
276
  return (0, dashboard_pair_1.handlePairConsume)(req, res, pairConsumeMatch[1]);
235
277
  }
236
- // Authentication gate.
278
+ // 3c Tier-2 INGRESS (publisher → daemon). Matched BEFORE the outer bearer
279
+ // gate: these use their OWN source-plane auth (loopback `socket.remoteAddress`
280
+ // + `X-Ingest-Token` vs the URL workflowId), so a localhost Pi subprocess
281
+ // reaches them regardless of the daemon's bind address. Only live when the
282
+ // daemon wired the registries; else they fall through to the 404/405 path.
283
+ if (ctx.innerLoop && ctx.ingestTokens) {
284
+ const innerDeps = { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens, ...(ctx.gate ? { gate: ctx.gate } : {}) };
285
+ const ingestMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/inner\/ingest$/);
286
+ if (ingestMatch) {
287
+ if (method !== 'POST') {
288
+ return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'POST' });
289
+ }
290
+ return (0, inner_loop_routes_1.handleInnerIngest)(req, res, innerDeps, decodeURIComponent(ingestMatch[1]), decodeURIComponent(ingestMatch[2]));
291
+ }
292
+ const presenceMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/inner\/presence$/);
293
+ if (presenceMatch) {
294
+ if (method !== 'GET') {
295
+ return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'GET' });
296
+ }
297
+ return (0, inner_loop_routes_1.handleInnerPresence)(req, res, innerDeps, decodeURIComponent(presenceMatch[1]), decodeURIComponent(presenceMatch[2]));
298
+ }
299
+ }
300
+ // 3d MD-G INGRESS (Pi subprocess → daemon poll). Same source-plane auth as the
301
+ // inner-loop ingest (loopback `socket.remoteAddress` + `X-Ingest-Token` vs the
302
+ // URL workflowId), matched BEFORE the bearer gate. Live only when the daemon
303
+ // wired the gate + ingest registries.
304
+ if (ctx.gate && ctx.ingestTokens) {
305
+ const gateDeps = { gate: ctx.gate, ingestTokens: ctx.ingestTokens };
306
+ const resolutionMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/gate\/([^/]+)\/resolution$/);
307
+ if (resolutionMatch) {
308
+ if (method !== 'GET') {
309
+ return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'GET' });
310
+ }
311
+ return (0, gate_routes_1.handleGateResolution)(req, res, gateDeps, decodeURIComponent(resolutionMatch[1]), decodeURIComponent(resolutionMatch[2]), decodeURIComponent(resolutionMatch[3]));
312
+ }
313
+ }
314
+ // Layer 2 — shared AUTHENTICATION + Origin/DNS-rebind defense (architect's
315
+ // decomposition). bearerRequired() carries the Origin-rebind logic; a request
316
+ // in bearer mode must present a token granting at LEAST a tier (read or admin).
317
+ // This is the single shared upstream pass — the per-route TIER authorization
318
+ // (Layer 3, `gateTier(N)` / inline `requireTier(3)`) refines it below: reads → T1,
319
+ // writes/pair-mint → T2 (admin), gate/inner → T3 (admin).
237
320
  const originHeader = headerString(req.headers.origin);
238
321
  const reqBearer = (0, auth_1.bearerRequired)(ctx.bindAddr, originHeader);
239
322
  if (reqBearer) {
240
323
  const provided = (0, auth_1.extractBearerToken)(headerString(req.headers.authorization));
241
- if (!provided || !ctx.httpToken || !(0, auth_1.tokensMatch)(provided, ctx.httpToken)) {
324
+ if (!provided || (0, auth_1.tierForToken)(provided, ctx) === 0) {
242
325
  writeCorsHeaders(res, originHeader, ctx, reqBearer);
243
326
  return (0, responses_1.errorResponse)(res, 401, { error: 'unauthorized' });
244
327
  }
@@ -252,6 +335,46 @@ async function handle(req, res, ctx) {
252
335
  res.setHeader('Access-Control-Allow-Origin', cors.echo);
253
336
  res.setHeader('Vary', 'Origin');
254
337
  }
338
+ // Layer 3 — per-route AUTHORIZATION (3e MD-E). The tier-guard input is
339
+ // resolved ONCE here off the shared L2 pass (bindAddr + Origin + the two
340
+ // RBAC tokens) and reused by every `gateTier(N)` call below — reads require
341
+ // T1, the write/pair-mint surface requires T2 (admin). The grandfathered T3
342
+ // gate/inner sites (3c/3d) keep their own inline `requireTier(3)` by design.
343
+ //
344
+ // This is defense-in-depth ON TOP of the L2 token-validity floor above (which
345
+ // already rejects an unrecognized bearer with 401): the explicit per-route
346
+ // guard keeps each surface protected at its declared tier even if the L2 floor
347
+ // is later relaxed, and makes the required tier self-documenting + greppable.
348
+ const tierInput = {
349
+ bindAddr: ctx.bindAddr,
350
+ originHeader,
351
+ authHeader: headerString(req.headers.authorization),
352
+ readToken: ctx.readToken,
353
+ adminToken: ctx.adminToken,
354
+ };
355
+ /**
356
+ * Write a tier-denial response, surfacing requireTier's actionable `detail`
357
+ * hint on 403 (insufficient-tier) / 503 (admin-unset) when present (3e ruling
358
+ * #3). The hint is operator guidance — e.g. "set AGENT_TEMPO_HTTP_ADMIN_TOKEN"
359
+ * — NOT a sensitive leak (security-confirmed). Shared by `gateTier` and the
360
+ * inline T3 gate/inner sites so every tier denial carries the same body shape.
361
+ */
362
+ const denyTier = (r) => {
363
+ (0, responses_1.errorResponse)(res, r.status, 'detail' in r ? { error: r.error, detail: r.detail } : { error: r.error });
364
+ };
365
+ /**
366
+ * Apply a per-route tier guard against the L2-resolved input. On failure it
367
+ * writes the 401/403/503 response and returns `false` (caller returns); on
368
+ * success returns `true`. Loopback requests short-circuit to PASS inside
369
+ * {@link requireTier} (local-trust → full tier).
370
+ */
371
+ const gateTier = (n) => {
372
+ const g = (0, auth_1.requireTier)(n, tierInput);
373
+ if (g.ok)
374
+ return true;
375
+ denyTier(g);
376
+ return false;
377
+ };
255
378
  // Write surface (PR-7a of #340) — POST `/v1/ensembles/:ensemble/<action>`
256
379
  // Match BEFORE the GET-only method gate; everything else (POST to a
257
380
  // read endpoint, GET to a write endpoint) flows into the 405 fallback
@@ -261,6 +384,10 @@ async function handle(req, res, ctx) {
261
384
  const ensemble = decodeURIComponent(writeMatch[1]);
262
385
  const action = writeMatch[2];
263
386
  if ((0, writes_1.isWriteAction)(action)) {
387
+ // L3 — the mutate surface is admin-only (Tier 2). Gate before the method
388
+ // check so an under-privileged caller can't probe the write verbs via 405.
389
+ if (!gateTier(2))
390
+ return;
264
391
  if (method !== 'POST') {
265
392
  return (0, responses_1.errorResponse)(res, 405, { error: 'method-not-allowed' }, { Allow: 'POST, OPTIONS' });
266
393
  }
@@ -274,6 +401,8 @@ async function handle(req, res, ctx) {
274
401
  // alongside the writeMatch above so it's reached before the GET-only
275
402
  // gate; the GET on the same path (list ensembles) is handled below.
276
403
  if (pathname === '/v1/ensembles' && method === 'POST') {
404
+ if (!gateTier(2))
405
+ return; // L3 — create-ensemble is a write (Tier 2).
277
406
  return (0, catalog_1.handleCreateEnsemble)(req, res, ctx.client);
278
407
  }
279
408
  // POST `/dashboard/api/pair` — mint a pairing for cross-device QR (PR-8
@@ -281,9 +410,43 @@ async function handle(req, res, ctx) {
281
410
  // proves authority before issuing a token; the token's GET-side consume
282
411
  // is the carve-out above the auth gate.
283
412
  if (method === 'POST' && pathname === '/dashboard/api/pair') {
413
+ // L3 — minting a cross-device pairing token is an admin operation (Tier 2):
414
+ // it grants a bearer-equivalent capability, so it must require the admin token.
415
+ if (!gateTier(2))
416
+ return;
284
417
  const provided = (0, auth_1.extractBearerToken)(headerString(req.headers.authorization));
285
418
  return (0, dashboard_pair_1.handlePairCreate)(req, res, provided);
286
419
  }
420
+ // 3d MD-G OPERATOR plane (operator/dashboard → daemon) — POST routes, so they
421
+ // sit BEFORE the GET-only method gate below (alongside the other POST routes).
422
+ // `requireTier(3)` — only an admin-token holder may arm/disarm or decide
423
+ // (MD-E highest tier). Live only when the daemon wired the gate registries.
424
+ if (ctx.gate && ctx.ingestTokens && method === 'POST') {
425
+ const gateDeps = { gate: ctx.gate, ingestTokens: ctx.ingestTokens };
426
+ const armMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/gate-(arm|disarm)$/);
427
+ const decideMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/gate\/([^/]+)$/);
428
+ if (armMatch || decideMatch) {
429
+ const tier = (0, auth_1.requireTier)(3, {
430
+ bindAddr: ctx.bindAddr,
431
+ originHeader,
432
+ authHeader: headerString(req.headers.authorization),
433
+ readToken: ctx.readToken,
434
+ adminToken: ctx.adminToken,
435
+ });
436
+ if (!tier.ok) {
437
+ denyTier(tier);
438
+ return;
439
+ }
440
+ if (armMatch) {
441
+ const [, e, p, verb] = armMatch;
442
+ return verb === 'arm'
443
+ ? (0, gate_routes_1.handleGateArm)(req, res, gateDeps, decodeURIComponent(e), decodeURIComponent(p))
444
+ : (0, gate_routes_1.handleGateDisarm)(req, res, gateDeps, decodeURIComponent(e), decodeURIComponent(p));
445
+ }
446
+ // decideMatch — POST /gate/:requestId { decision }
447
+ return (0, gate_routes_1.handleGateDecide)(req, res, gateDeps, decodeURIComponent(decideMatch[1]), decodeURIComponent(decideMatch[2]), decodeURIComponent(decideMatch[3]));
448
+ }
449
+ }
287
450
  // Method gate — read endpoints are GET-only. Both POST paths above
288
451
  // (PR-7a writes, PR-8 pair-mint) handle their own method matching;
289
452
  // everything else falls through here.
@@ -295,18 +458,26 @@ async function handle(req, res, ctx) {
295
458
  // `/v1/*` API uses. The pre-auth pair-token carve-out above is the
296
459
  // single exception that bootstraps cross-device pairing.
297
460
  if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) {
461
+ if (!gateTier(1))
462
+ return; // L3 — the dashboard SPA is read-tier (Tier 1).
298
463
  return (0, dashboard_1.handleDashboardStatic)(req, res, pathname);
299
464
  }
300
465
  if (pathname === '/v1/ensembles') {
466
+ if (!gateTier(1))
467
+ return; // L3 — read (Tier 1).
301
468
  return handleListEnsembles(res, ctx);
302
469
  }
303
470
  if (pathname === '/v1/hosts') {
471
+ if (!gateTier(1))
472
+ return; // L3 — read (Tier 1).
304
473
  return handleHosts(res, ctx);
305
474
  }
306
475
  // #579 — cluster-wide cross-host orphan listing for the dashboard.
307
476
  // Same bearer + CORS gate as `/v1/hosts`; optional `?ensemble=<name>`
308
477
  // narrows to one ensemble.
309
478
  if (pathname === '/v1/orphans') {
479
+ if (!gateTier(1))
480
+ return; // L3 — read (Tier 1).
310
481
  const ensembleFilter = url.searchParams.get('ensemble') ?? undefined;
311
482
  return (0, orphans_1.handleOrphans)(res, {
312
483
  client: ctx.client,
@@ -318,14 +489,20 @@ async function handle(req, res, ctx) {
318
489
  // Catalog reads (issue #400) — `listAgentTypes` / `listLineups`
319
490
  // touch local fs only, no Temporal calls; cheap to serve per-request.
320
491
  if (pathname === '/v1/agent-types') {
492
+ if (!gateTier(1))
493
+ return; // L3 — read (Tier 1).
321
494
  return (0, catalog_1.handleListAgentTypes)(res);
322
495
  }
323
496
  if (pathname === '/v1/lineups') {
497
+ if (!gateTier(1))
498
+ return; // L3 — read (Tier 1).
324
499
  return (0, catalog_1.handleListLineups)(res);
325
500
  }
326
501
  // /v1/state/:ensemble — single capture group.
327
502
  const stateMatch = pathname.match(/^\/v1\/state\/([^/]+)$/);
328
503
  if (stateMatch) {
504
+ if (!gateTier(1))
505
+ return; // L3 — read (Tier 1); covers the fixture path too.
329
506
  const ensemble = decodeURIComponent(stateMatch[1]);
330
507
  // Fixture mode (PR-3 of #340) — `?fixture=<name>` short-circuits the
331
508
  // live snapshot with canned data. Sits behind the bearer-auth gate.
@@ -340,6 +517,8 @@ async function handle(req, res, ctx) {
340
517
  // signals "feature exists, not yet wired" rather than "ensemble
341
518
  // doesn't exist".
342
519
  if (pathname === '/v1/events') {
520
+ if (!gateTier(1))
521
+ return; // L3 — read/observe stream (Tier 1).
343
522
  if (!ctx.aggregate) {
344
523
  return (0, responses_1.errorResponse)(res, 503, { error: 'streaming-not-implemented' }, { 'Retry-After': '60' });
345
524
  }
@@ -352,6 +531,8 @@ async function handle(req, res, ctx) {
352
531
  }
353
532
  const evtMatch = pathname.match(/^\/v1\/events\/([^/]+)$/);
354
533
  if (evtMatch) {
534
+ if (!gateTier(1))
535
+ return; // L3 — read/observe stream (Tier 1); covers fixture too.
355
536
  const ensemble = decodeURIComponent(evtMatch[1]);
356
537
  // Fixture mode (PR-3 of #340) — `?fixture=<name>` short-circuits both
357
538
  // the existence check and the aggregate poll loop with a canned event
@@ -378,6 +559,30 @@ async function handle(req, res, ctx) {
378
559
  cap: ctx.sseConnectionCap,
379
560
  });
380
561
  }
562
+ // 3c Tier-2 EGRESS — operator/widget inner-loop SSE fine tail. After the outer
563
+ // bearer gate (so it's already authenticated); the explicit `requireTier(3)`
564
+ // marks the tier for 3e (today the outer bearer already satisfied it — 3e
565
+ // relaxes the outer gate to a read token and this guard demands the admin
566
+ // token, no call-site change). View-agnostic: bearer-keyed, NO Origin
567
+ // requirement, plain `event:`/`data:` framing (fetch + Node-client consumable).
568
+ const innerSseMatch = pathname.match(/^\/v1\/players\/([^/]+)\/([^/]+)\/inner$/);
569
+ if (innerSseMatch) {
570
+ if (!ctx.innerLoop || !ctx.ingestTokens) {
571
+ return (0, responses_1.errorResponse)(res, 503, { error: 'streaming-not-implemented' }, { 'Retry-After': '60' });
572
+ }
573
+ const tier = (0, auth_1.requireTier)(3, {
574
+ bindAddr: ctx.bindAddr,
575
+ originHeader,
576
+ authHeader: headerString(req.headers.authorization),
577
+ readToken: ctx.readToken,
578
+ adminToken: ctx.adminToken,
579
+ });
580
+ if (!tier.ok) {
581
+ denyTier(tier);
582
+ return;
583
+ }
584
+ return (0, inner_loop_routes_1.handleInnerSse)(req, res, { innerLoop: ctx.innerLoop, ingestTokens: ctx.ingestTokens }, decodeURIComponent(innerSseMatch[1]), decodeURIComponent(innerSseMatch[2]));
585
+ }
381
586
  return (0, responses_1.errorResponse)(res, 404, { error: 'not-found' });
382
587
  }
383
588
  /** Pull a single string from a possibly-array header. */
@@ -51,6 +51,12 @@ export interface PlayerWireMeta {
51
51
  expiresAt: number | null;
52
52
  leaseMs: number | null;
53
53
  };
54
+ /** 3c Tier-1 — coarse activity (currentTool + context usage), merged onto the summary. */
55
+ coarse?: {
56
+ currentTool: string | null;
57
+ contextTokens?: number;
58
+ contextPercent?: number;
59
+ };
54
60
  }
55
61
  /**
56
62
  * Project a `MaestroPlayerInfo` into the wire-stable `PlayerSummaryV1`.
@@ -99,6 +99,12 @@ function toPlayerSummaryV1(p, wireMeta = null) {
99
99
  ...(wireMeta?.runId !== undefined ? { runId: wireMeta.runId } : {}),
100
100
  ...(wireMeta?.messaging !== undefined ? { messaging: wireMeta.messaging } : {}),
101
101
  ...(wireMeta?.lease !== undefined ? { lease: wireMeta.lease } : {}),
102
+ // 3c Tier-1 — coarse activity merged onto the summary so the aggregate
103
+ // poll/diff can emit player.activity. currentTool is always present on the
104
+ // coarse object (null = idle); context fields are conditionally included.
105
+ ...(wireMeta?.coarse?.currentTool !== undefined ? { currentTool: wireMeta.coarse.currentTool } : {}),
106
+ ...(wireMeta?.coarse?.contextTokens !== undefined ? { contextTokens: wireMeta.coarse.contextTokens } : {}),
107
+ ...(wireMeta?.coarse?.contextPercent !== undefined ? { contextPercent: wireMeta.coarse.contextPercent } : {}),
102
108
  };
103
109
  }
104
110
  /**
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cue pump — pulls cues queued on the session workflow and injects them into
3
+ * the LIVE Pi session via `sendCustomMessage`, then acks them.
4
+ *
5
+ * Pi has no reverse-RPC into a running session from Temporal, so (like the
6
+ * existing adapters) we poll `pendingMessages` and ack via `markDelivered`.
7
+ *
8
+ * Injection follows D10 cue-delivery semantics:
9
+ * - **deliverAs** — operator cue (`msg.isMaestro`, a human steering from the
10
+ * Maestro dashboard) → `'steer'` (interrupt the in-flight turn so the
11
+ * override lands immediately); peer cue → `'followUp'` (queue behind the
12
+ * current turn rather than interrupting a peer's work).
13
+ * - **triggerTurn — always `true`.** Researcher-confirmed: Pi's `followUp`
14
+ * does NOT self-wake an idle agent, so an unconditional `triggerTurn` is
15
+ * REQUIRED to avoid #18-style silent cue loss when no human is driving. It
16
+ * is a no-op when a turn is already running (the message just queues), so we
17
+ * don't need to race-check the idle state — set it unconditionally.
18
+ *
19
+ * Adapted from Pi's `examples/extensions/file-trigger.ts`.
20
+ */
21
+ import type { Message } from '../types';
22
+ import type { PiAgentSession } from './pi-types';
23
+ /** Source of pending cues + ack — satisfied by `PiWorkflowClient`. */
24
+ export interface CueSource {
25
+ fetchPending(): Promise<Message[]>;
26
+ ackDelivered(messageIds: string[]): Promise<void>;
27
+ }
28
+ /**
29
+ * Resolves the CURRENT live Pi session at injection time. Re-acquired on every
30
+ * tick rather than captured once, so a session switch (D11) never injects into
31
+ * a stale session. Returns `null` when no session is attached.
32
+ */
33
+ export type SessionResolver = () => PiAgentSession | null;
34
+ export interface CuePumpOptions {
35
+ source: CueSource;
36
+ resolveSession: SessionResolver;
37
+ /** Poll interval (ms). */
38
+ intervalMs?: number;
39
+ }
40
+ export declare class CuePump {
41
+ private readonly source;
42
+ private readonly resolveSession;
43
+ private readonly intervalMs;
44
+ private timer;
45
+ private draining;
46
+ constructor(opts: CuePumpOptions);
47
+ start(): void;
48
+ stop(): void;
49
+ /**
50
+ * One poll cycle: fetch pending cues, inject each into the live session, ack
51
+ * the ones successfully injected. Re-entrancy guarded so a slow tick never
52
+ * overlaps the next interval.
53
+ */
54
+ tick(): Promise<void>;
55
+ /**
56
+ * Inject one cue into the live session (D10 — see file header). Operator cues
57
+ * `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
58
+ * always set: a no-op mid-turn, the required cold-idle wake otherwise.
59
+ */
60
+ private injectCue;
61
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CuePump = void 0;
4
+ const DEFAULT_POLL_MS = 1_000;
5
+ const log = (...args) => {
6
+ // eslint-disable-next-line no-console
7
+ console.error('[agent-tempo:pi]', ...args);
8
+ };
9
+ class CuePump {
10
+ source;
11
+ resolveSession;
12
+ intervalMs;
13
+ timer = null;
14
+ draining = false;
15
+ constructor(opts) {
16
+ this.source = opts.source;
17
+ this.resolveSession = opts.resolveSession;
18
+ this.intervalMs = opts.intervalMs ?? DEFAULT_POLL_MS;
19
+ }
20
+ start() {
21
+ if (this.timer)
22
+ return;
23
+ this.timer = setInterval(() => {
24
+ this.tick().catch((err) => log('cue-pump tick failed:', err));
25
+ }, this.intervalMs);
26
+ if (typeof this.timer.unref === 'function')
27
+ this.timer.unref();
28
+ }
29
+ stop() {
30
+ if (this.timer) {
31
+ clearInterval(this.timer);
32
+ this.timer = null;
33
+ }
34
+ }
35
+ /**
36
+ * One poll cycle: fetch pending cues, inject each into the live session, ack
37
+ * the ones successfully injected. Re-entrancy guarded so a slow tick never
38
+ * overlaps the next interval.
39
+ */
40
+ async tick() {
41
+ if (this.draining)
42
+ return;
43
+ this.draining = true;
44
+ try {
45
+ const pending = await this.source.fetchPending();
46
+ if (pending.length === 0)
47
+ return;
48
+ const session = this.resolveSession();
49
+ if (!session) {
50
+ // No live session yet — leave cues queued; next tick retries.
51
+ return;
52
+ }
53
+ const delivered = [];
54
+ for (const msg of pending) {
55
+ try {
56
+ await this.injectCue(session, msg);
57
+ delivered.push(msg.id);
58
+ }
59
+ catch (err) {
60
+ log(`failed to inject cue ${msg.id}:`, err);
61
+ // Stop on first failure — preserve ordering; retry next tick.
62
+ break;
63
+ }
64
+ }
65
+ await this.source.ackDelivered(delivered);
66
+ }
67
+ finally {
68
+ this.draining = false;
69
+ }
70
+ }
71
+ /**
72
+ * Inject one cue into the live session (D10 — see file header). Operator cues
73
+ * `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
74
+ * always set: a no-op mid-turn, the required cold-idle wake otherwise.
75
+ */
76
+ async injectCue(session, msg) {
77
+ const content = msg.from ? `[cue from ${msg.from}] ${msg.text}` : msg.text;
78
+ // LOAD-BEARING Pi-runtime invariant (D10) — confirmed sound through Pi 0.78.x
79
+ // (researcher-cited; a D6 "behaviors-to-revalidate-on-bump" item):
80
+ // peer cue = { deliverAs: 'followUp', triggerTurn: true } → QUEUES; drains
81
+ // when the agent goes idle, NEVER preempts a running turn. triggerTurn only
82
+ // wakes a cold-idle session (followUp alone won't start one); it is a no-op
83
+ // while a turn is in flight.
84
+ // operator cue = { deliverAs: 'steer', triggerTurn: true } → same-turn PRIORITY:
85
+ // injected after the current tool batch, before the next LLM call. NOT a hard
86
+ // mid-tool abort (only RPC abort / AbortSignal hard-interrupts a running tool).
87
+ // The guarantee this comment protects: a future Pi version MUST keep followUp
88
+ // non-interrupting AND triggerTurn a no-op-while-busy. If that regresses, peer
89
+ // cues silently become preemptions, defeating operator-vs-peer. Not unit-testable
90
+ // here (the session is mocked) — locked by researcher confirmation + the D6 Pi
91
+ // version floor (≥ #2860 + #5115) + a real-Pi mid-turn integration smoke.
92
+ await session.sendCustomMessage({ customType: 'cue', content, display: true }, { deliverAs: msg.isMaestro ? 'steer' : 'followUp', triggerTurn: true });
93
+ }
94
+ }
95
+ exports.CuePump = CuePump;
@@ -0,0 +1,45 @@
1
+ import type { Client } from '@temporalio/client';
2
+ import { type Config } from '../config';
3
+ import type { ExtensionAPI, PiAgentSession } from './pi-types';
4
+ /** Runtime mode. Headless = recruited unsupervised player (MD-C gate active). */
5
+ export type PiExtensionMode = 'interactive' | 'headless';
6
+ export type PiToolAccess = 'restricted' | 'standard' | 'full';
7
+ export interface PiExtensionOptions {
8
+ /** Default `'interactive'`. Headless installs the MD-C tool_call gate. */
9
+ mode?: PiExtensionMode;
10
+ /** MD-C tool-class policy (headless only). Default `'restricted'`. */
11
+ toolAccess?: PiToolAccess;
12
+ }
13
+ /**
14
+ * Build the Pi extension factory. `mode='headless'` installs the MD-C tool_call
15
+ * gate; `mode='interactive'` (default) does not (the human owns their machine).
16
+ */
17
+ export declare function createPiExtension(options?: PiExtensionOptions): (pi: ExtensionAPI) => void;
18
+ /**
19
+ * RELIABLE detach for the headless exit sequence (Phase 3a). Headless owns its
20
+ * exit loop, so — unlike interactive's best-effort `quit` path — it can AWAIT a
21
+ * clean detach before disposing the SDK session. Ordering (architect ruling):
22
+ * stopHeartbeat → requestDetach → adapterExited (all inside `wf.detach`) → unmap.
23
+ * The caller then calls `session.dispose()`; the dispose-fired `session_shutdown`
24
+ * finds no mapped runtime → no-op (avoids double-detach). Detaches every runtime
25
+ * in the process (headless = one player per process).
26
+ */
27
+ export declare function detachAllPiRuntimesForExit(): Promise<void>;
28
+ /**
29
+ * Headless-only: wire the live Pi SDK session onto a runtime so the cue pump can
30
+ * inject into it. The interactive CLI's `session_start` payload carries
31
+ * `session`, but the headless SDK's DEFAULT session_start payload does NOT (it's
32
+ * `{ type, reason }`) — so `attachOrRebind` sets `rt.session = null` and the cue
33
+ * pump's `resolveSession` returns null (every cue is dropped). The headless entry
34
+ * HOLDS the session from `createAgentSession`, so it calls this after
35
+ * `bindExtensions` (by which point the runtime exists + has claimed) to set it.
36
+ * (3a live smoke — devops.)
37
+ */
38
+ export declare function setRuntimeSession(workflowId: string, session: PiAgentSession): void;
39
+ /** Override the Temporal connection factory (inject a fake Client). */
40
+ export declare function __setPiClientFactoryForTests(factory: (config: Config) => Promise<Client>): void;
41
+ /** Stop timers, clear the per-player runtime map + shared-client singletons + factory. */
42
+ export declare function __resetPiRuntimesForTests(): void;
43
+ /** Default export — interactive-mode extension (the human `pi` CLI entry). */
44
+ declare const piExtension: (pi: ExtensionAPI) => void;
45
+ export default piExtension;