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
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IngestTokenRegistry = void 0;
37
+ /**
38
+ * Ingest-token registry (3c Tier-2 ingest-auth) — per security's design.
39
+ *
40
+ * The `/inner/ingest` + `/inner/presence` endpoints are the SOURCE-side
41
+ * (publisher → daemon) half of the off-wire fine-tail channel. Loopback-only is
42
+ * necessary but NOT sufficient: any local process could POST fake frames or
43
+ * inject into ANOTHER player's tail. So each headless Pi player is minted a
44
+ * per-player ingest token at spawn, scoped to its session `workflowId`; the
45
+ * ingest/presence handlers validate the presented token against the token minted
46
+ * for the URL-derived workflowId, binding every frame to its owning player.
47
+ *
48
+ * Lifecycle (driven from the daemon-only outbox.ts — NOT spawn.ts, which runs
49
+ * outside the daemon and must not import this):
50
+ * - MINT before `spawnPiHeadless` (token injected into the subprocess env).
51
+ * - REVOKE on detach + destroy.
52
+ * - REVOKE-ALL on daemon shutdown.
53
+ *
54
+ * Phase-4+ deferrals (carry-items, NOT 3c): token rotation, per-token
55
+ * rate-limiting, durable ingest audit.
56
+ */
57
+ const crypto = __importStar(require("crypto"));
58
+ const auth_1 = require("./auth");
59
+ /** Bytes of entropy per ingest token (base64url → 43 chars, same as the HTTP bearer). */
60
+ const INGEST_TOKEN_BYTES = 32;
61
+ /**
62
+ * In-memory map of `workflowId → ingest token`, owned by the daemon (one
63
+ * instance shared between the outbox minter and the HTTP ingest/presence
64
+ * validators). Tokens never persist — they live only for the player's lifetime.
65
+ */
66
+ class IngestTokenRegistry {
67
+ tokens = new Map();
68
+ /**
69
+ * Mint a fresh ingest token for a player, replacing any prior one (a restart
70
+ * re-mints). Returns the token to inject into the subprocess env.
71
+ */
72
+ mint(workflowId) {
73
+ const token = crypto.randomBytes(INGEST_TOKEN_BYTES).toString('base64url');
74
+ this.tokens.set(workflowId, token);
75
+ return token;
76
+ }
77
+ /**
78
+ * Validate a presented token against the token minted for `workflowId`
79
+ * (timing-safe, via {@link tokensMatch}). Returns `false` for an unknown
80
+ * player or a mismatch — so a compromised player presenting its OWN token for
81
+ * another player's URL is rejected (cross-player-spoof guard). The workflowId
82
+ * is public (it's in the URL); the token is the secret, and only its
83
+ * comparison is constant-time.
84
+ */
85
+ validate(workflowId, presented) {
86
+ const expected = this.tokens.get(workflowId);
87
+ if (expected === undefined)
88
+ return false;
89
+ return (0, auth_1.tokensMatch)(presented, expected);
90
+ }
91
+ /** Revoke a player's ingest token (detach / destroy). Idempotent. */
92
+ revoke(workflowId) {
93
+ this.tokens.delete(workflowId);
94
+ }
95
+ /** Revoke every token (daemon shutdown / clear-all). */
96
+ revokeAll() {
97
+ this.tokens.clear();
98
+ }
99
+ /** Whether a player currently holds a minted token. */
100
+ has(workflowId) {
101
+ return this.tokens.has(workflowId);
102
+ }
103
+ /** Active token count (diagnostics / tests). */
104
+ size() {
105
+ return this.tokens.size;
106
+ }
107
+ }
108
+ exports.IngestTokenRegistry = IngestTokenRegistry;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * HTTP route handlers for the 3c Tier-2 inner-loop side-channel (MD-F).
3
+ * server.ts dispatches to these; the logic lives here so it stays testable.
4
+ *
5
+ * THREE routes, TWO auth planes (security's ingest-auth design):
6
+ *
7
+ * INGRESS (source → daemon; publisher-only). Mounted in server.ts BEFORE the
8
+ * outer bearer gate so it works regardless of the daemon's bind address —
9
+ * authenticated by its OWN gates, not the operator bearer:
10
+ * - POST /v1/players/:ensemble/:playerId/inner/ingest → publish a frame
11
+ * - GET /v1/players/:ensemble/:playerId/inner/presence → { subscribers }
12
+ * Both gate on: (1) loopback `req.socket.remoteAddress`; (2) `X-Ingest-Token`
13
+ * validated against the URL-derived workflowId (cross-player-spoof guard).
14
+ * Every failure → 403 with no detail (no info leak).
15
+ *
16
+ * EGRESS (daemon → operator/widget). Mounted AFTER the outer bearer gate with
17
+ * an explicit `requireTier(3)`:
18
+ * - GET /v1/players/:ensemble/:playerId/inner → SSE fine-tail stream
19
+ *
20
+ * MD-F invariants preserved: off-Temporal, off the coordination bus,
21
+ * daemon-LOCAL (loopback ingest = same host), ephemeral (no ring/replay).
22
+ */
23
+ import type { IncomingMessage, ServerResponse } from 'http';
24
+ import type { InnerLoopRegistry } from './inner-loop';
25
+ import type { IngestTokenRegistry } from './ingest-registry';
26
+ import type { GateRegistry } from './gate-registry';
27
+ /** Header carrying the per-player ingest token (the source-plane credential). */
28
+ export declare const INGEST_TOKEN_HEADER = "x-ingest-token";
29
+ export interface InnerLoopDeps {
30
+ innerLoop: InnerLoopRegistry;
31
+ ingestTokens: IngestTokenRegistry;
32
+ /**
33
+ * 3d MD-G — the operator-gate registry, when wired. Two narrow couplings:
34
+ * - ingest: an `inner.gate_pending` frame ALSO registers the pending request
35
+ * in the gate (`open()`) — atomic register-and-surface from one POST (the
36
+ * "engagement IS registration" path; no separate open-route).
37
+ * - presence: the response carries `gateArmed` so the polling subprocess
38
+ * evaluates engagement (armed + present) from one fetch.
39
+ * The coupling is one-directional (inner-routes → GateRegistry); the gate's
40
+ * `gate_resolved` emission flows back via its injected publishToInner.
41
+ */
42
+ gate?: GateRegistry;
43
+ }
44
+ /** True when the request originates from the same host (loopback). */
45
+ export declare function isLoopbackRemote(req: IncomingMessage): boolean;
46
+ /**
47
+ * POST /v1/players/:e/:p/inner/ingest — the publisher forwards ONE InnerFrame.
48
+ * 204 on success; uniform 403 on any gate/shape/oversize failure (no-leak). The
49
+ * daemon TRUSTS the authenticated publisher's summaries (already ~2KB-truncated
50
+ * at source) — the 32KB cap is purely the DOS backstop; no re-truncation.
51
+ */
52
+ export declare function handleInnerIngest(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): Promise<void>;
53
+ /**
54
+ * GET /v1/players/:e/:p/inner/presence — publisher-only presence probe.
55
+ * Same gates as ingest (presence is publisher-only; leaking "is someone
56
+ * watching X" would be a covert channel). 200 `{ subscribers }` or 403.
57
+ */
58
+ export declare function handleInnerPresence(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): void;
59
+ /**
60
+ * GET /v1/players/:e/:p/inner — operator/widget SSE fine-tail stream (EGRESS).
61
+ * server.ts has already applied the outer bearer + `requireTier(3)` gate. Plain
62
+ * `event:`/`data:` framing (fetch-consumable, no EventSource-specific framing);
63
+ * `:ka` keepalive; `:closed` when the player goes away. No ring/seq/replay —
64
+ * ephemeral best-effort tail (a disconnect loses in-flight deltas, by design).
65
+ */
66
+ export declare function handleInnerSse(req: IncomingMessage, res: ServerResponse, deps: InnerLoopDeps, ensemble: string, playerId: string): Promise<void>;
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INGEST_TOKEN_HEADER = void 0;
4
+ exports.isLoopbackRemote = isLoopbackRemote;
5
+ exports.handleInnerIngest = handleInnerIngest;
6
+ exports.handleInnerPresence = handleInnerPresence;
7
+ exports.handleInnerSse = handleInnerSse;
8
+ const config_1 = require("../config");
9
+ const responses_1 = require("./responses");
10
+ const body_1 = require("./body");
11
+ /** Loopback remote addresses Node may report for a same-host connection. */
12
+ const LOOPBACK_REMOTES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
13
+ /** Header carrying the per-player ingest token (the source-plane credential). */
14
+ exports.INGEST_TOKEN_HEADER = 'x-ingest-token';
15
+ /** Max bytes buffered to a slow `/inner` SSE socket before we drop the connection. */
16
+ const MAX_SSE_WRITE_BUFFER = 1024 * 1024;
17
+ /** Keepalive comment cadence on an idle `/inner` stream. */
18
+ const INNER_KEEPALIVE_MS = 15_000;
19
+ /** True when the request originates from the same host (loopback). */
20
+ function isLoopbackRemote(req) {
21
+ const addr = req.socket?.remoteAddress;
22
+ return addr != null && LOOPBACK_REMOTES.has(addr);
23
+ }
24
+ function headerValue(v) {
25
+ if (v === undefined)
26
+ return undefined;
27
+ return Array.isArray(v) ? v[0] : v;
28
+ }
29
+ /**
30
+ * Run the shared INGRESS gate (loopback + ingest-token vs URL workflowId).
31
+ * Returns the resolved workflowId on success, or `null` after having written a
32
+ * uniform `403` (no info leak — callers just `return` on null).
33
+ */
34
+ function gateIngress(req, res, deps, ensemble, playerId) {
35
+ // Uniform 403 on EVERY failure — never reveal which gate tripped.
36
+ const deny = () => {
37
+ (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
38
+ return null;
39
+ };
40
+ if (!isLoopbackRemote(req))
41
+ return deny();
42
+ const token = headerValue(req.headers[exports.INGEST_TOKEN_HEADER]);
43
+ if (!token)
44
+ return deny();
45
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
46
+ if (!deps.ingestTokens.validate(workflowId, token))
47
+ return deny();
48
+ return workflowId;
49
+ }
50
+ /**
51
+ * POST /v1/players/:e/:p/inner/ingest — the publisher forwards ONE InnerFrame.
52
+ * 204 on success; uniform 403 on any gate/shape/oversize failure (no-leak). The
53
+ * daemon TRUSTS the authenticated publisher's summaries (already ~2KB-truncated
54
+ * at source) — the 32KB cap is purely the DOS backstop; no re-truncation.
55
+ */
56
+ async function handleInnerIngest(req, res, deps, ensemble, playerId) {
57
+ const workflowId = gateIngress(req, res, deps, ensemble, playerId);
58
+ if (workflowId === null)
59
+ return;
60
+ const body = await (0, body_1.readJsonBody)(req, body_1.INGEST_BODY_MAX);
61
+ // Oversize / malformed / non-frame → uniform 403 (no-leak; the publisher just
62
+ // drops the frame on any non-204).
63
+ if (body === body_1.BODY_TOO_LARGE || body === body_1.BODY_INVALID_JSON) {
64
+ return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
65
+ }
66
+ const type = body.type;
67
+ if (typeof type !== 'string' || !type.startsWith('inner.')) {
68
+ return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
69
+ }
70
+ // S1 (security): `type` is interpolated RAW into the operator SSE `event:`
71
+ // line (handleInnerSse) — a CR/LF here would let an authenticated source
72
+ // inject/garble frames in the operator's stream. Reject at the INGRESS
73
+ // boundary so a malformed type never enters the registry. (Every other frame
74
+ // field is JSON.stringify'd into `data:`, which escapes control chars.)
75
+ if (/[\r\n]/.test(type)) {
76
+ return (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' });
77
+ }
78
+ // Trust the authenticated publisher's frame shape (it owns the schema +
79
+ // truncation). Publish to local subscribers and ack with no body.
80
+ deps.innerLoop.publish(workflowId, body);
81
+ // 3d MD-G — a gate_pending frame ALSO registers the pending request in the
82
+ // gate (atomic register-and-surface from one POST; the "engagement IS
83
+ // registration" path, no separate open-route). Narrow, one-directional
84
+ // coupling: inner-routes → GateRegistry.open(). Guarded on the type so it
85
+ // never fires for ordinary inner frames. `open` is idempotent on requestId.
86
+ if (type === 'inner.gate_pending' && deps.gate) {
87
+ const f = body;
88
+ if (typeof f.requestId === 'string' && typeof f.tool === 'string') {
89
+ deps.gate.open(workflowId, f.requestId, {
90
+ tool: f.tool,
91
+ argsSummary: typeof f.argsSummary === 'string' ? f.argsSummary : '',
92
+ ensemble,
93
+ });
94
+ }
95
+ }
96
+ res.writeHead(204);
97
+ res.end();
98
+ }
99
+ /**
100
+ * GET /v1/players/:e/:p/inner/presence — publisher-only presence probe.
101
+ * Same gates as ingest (presence is publisher-only; leaking "is someone
102
+ * watching X" would be a covert channel). 200 `{ subscribers }` or 403.
103
+ */
104
+ function handleInnerPresence(req, res, deps, ensemble, playerId) {
105
+ const workflowId = gateIngress(req, res, deps, ensemble, playerId);
106
+ if (workflowId === null)
107
+ return;
108
+ // 3d MD-G — fold `gateArmed` into the presence response so the polling
109
+ // subprocess reads BOTH engagement inputs (operator-present + armed) from one
110
+ // fetch (avoids a stale-armed / fresh-present mismatch). `false` when the gate
111
+ // is unwired or unarmed.
112
+ (0, responses_1.jsonResponse)(res, 200, {
113
+ subscribers: deps.innerLoop.subscriberCount(workflowId),
114
+ gateArmed: deps.gate?.isArmed(workflowId) ?? false,
115
+ });
116
+ }
117
+ /**
118
+ * GET /v1/players/:e/:p/inner — operator/widget SSE fine-tail stream (EGRESS).
119
+ * server.ts has already applied the outer bearer + `requireTier(3)` gate. Plain
120
+ * `event:`/`data:` framing (fetch-consumable, no EventSource-specific framing);
121
+ * `:ka` keepalive; `:closed` when the player goes away. No ring/seq/replay —
122
+ * ephemeral best-effort tail (a disconnect loses in-flight deltas, by design).
123
+ */
124
+ async function handleInnerSse(req, res, deps, ensemble, playerId) {
125
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
126
+ res.writeHead(200, {
127
+ 'Content-Type': 'text/event-stream; charset=utf-8',
128
+ 'Cache-Control': 'no-cache, no-transform',
129
+ Connection: 'keep-alive',
130
+ 'X-Accel-Buffering': 'no',
131
+ });
132
+ // Flush headers immediately so the operator's stream OPENS now, not on the
133
+ // first frame/keepalive — otherwise Node buffers the head until the first
134
+ // body write and a fetch/EventSource client blocks up to INNER_KEEPALIVE_MS.
135
+ // (Mirrors the main SSE handler in sse-handler.ts.)
136
+ res.flushHeaders?.();
137
+ const sub = deps.innerLoop.subscribe(workflowId);
138
+ let cleanedUp = false;
139
+ const keepalive = setInterval(() => {
140
+ try {
141
+ res.write(':ka\n\n');
142
+ }
143
+ catch { /* socket gone — close handler cleans up */ }
144
+ }, INNER_KEEPALIVE_MS);
145
+ if (typeof keepalive.unref === 'function')
146
+ keepalive.unref();
147
+ const cleanup = () => {
148
+ if (cleanedUp)
149
+ return;
150
+ cleanedUp = true;
151
+ clearInterval(keepalive);
152
+ deps.innerLoop.unsubscribe(workflowId, sub);
153
+ };
154
+ req.on('close', cleanup);
155
+ res.on('close', cleanup);
156
+ try {
157
+ for await (const frame of sub) {
158
+ res.write(`event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`);
159
+ // Bound the per-connection write buffer: a slow operator socket must not
160
+ // grow the daemon's memory unboundedly. Drop the connection if it backs up
161
+ // (ephemeral tail — reconnect re-tails live).
162
+ const buffered = res.socket?.writableLength ?? 0;
163
+ if (buffered > MAX_SSE_WRITE_BUFFER)
164
+ break;
165
+ }
166
+ // Subscription ended (player gone / unsubscribe) — signal a clean close.
167
+ try {
168
+ res.write(':closed\n\n');
169
+ }
170
+ catch { /* already closed */ }
171
+ }
172
+ catch {
173
+ /* write-after-close or iterator error — fall through to cleanup */
174
+ }
175
+ finally {
176
+ cleanup();
177
+ try {
178
+ res.end();
179
+ }
180
+ catch { /* already ended */ }
181
+ }
182
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Inner-loop registry + subscription (3c Tier-2, MD-F) — the DAEMON-LOCAL,
3
+ * off-wire fine-tail sink. NOT on the coordination EnsembleEventBus, NOT on
4
+ * Temporal, NOT replayable (no ring / Last-Event-ID / seq). A per-player live
5
+ * tail of a headless Pi player's inner loop (thinking deltas, tool calls/results,
6
+ * token pressure, turn markers), served by the daemon RUNNING that player.
7
+ *
8
+ * Two clients meet here:
9
+ * - The OPERATOR side: `GET /v1/players/:e/:p/inner` opens an SSE stream →
10
+ * {@link InnerLoopRegistry.subscribe} → drains an {@link InnerSubscription}.
11
+ * - The SOURCE side: eng's `InnerLoopPublisher` (in the detached Pi subprocess)
12
+ * forwards frames via the thin loopback-HTTP client → `POST /inner/ingest` →
13
+ * {@link InnerLoopRegistry.publish}. The publisher presence-gates on
14
+ * {@link InnerLoopRegistry.subscriberCount} (read via `GET /inner/presence`)
15
+ * so zero watchers ⇒ zero forwarding.
16
+ *
17
+ * Backpressure (registry-owned, per the split with the source-side coalescing):
18
+ * each subscriber has a bounded queue with DROP-OLDEST. When frames are dropped,
19
+ * a single `compacted{dropped,sinceTs}` marker is delivered ahead of the next
20
+ * real frame so the operator knows the tail has gaps. (Source-side coalescing of
21
+ * thinking deltas + ~2KB summary truncation already bound the inbound rate; this
22
+ * is the last-resort guard against a stalled SSE socket.)
23
+ *
24
+ * This module implements eng's `InnerLoopRegistry` DI interface
25
+ * (`publish` + `subscriberCount`) as a SUPERSET that also owns subscriber
26
+ * lifecycle (`subscribe` / `unsubscribe` / `closePlayer`).
27
+ */
28
+ import type { InnerFrame, InnerLoopRegistry as InnerLoopSink } from '../pi/inner-loop-publisher';
29
+ /** Per-subscriber bounded queue depth before drop-oldest engages. */
30
+ export declare const INNER_SUB_QUEUE_MAX = 256;
31
+ /**
32
+ * A frame as delivered on the `/inner` wire — eng's source {@link InnerFrame}
33
+ * plus the registry-injected `compacted` backpressure marker (never produced by
34
+ * the source; purely the sink's gap signal).
35
+ */
36
+ export type InnerWireFrame = InnerFrame | {
37
+ type: 'compacted';
38
+ dropped: number;
39
+ sinceTs: number;
40
+ };
41
+ /**
42
+ * One connected `/inner` SSE subscriber. Async-iterable: the SSE handler does
43
+ * `for await (const frame of sub) { write(frame) }`. Bounded queue with
44
+ * drop-oldest; a `compacted{dropped,sinceTs}` marker is injected before the next
45
+ * real frame whenever drops have occurred since the last delivery.
46
+ *
47
+ * No ring / replay / seq — this is an ephemeral best-effort tail (MD-F): a
48
+ * disconnect loses in-flight deltas, by design.
49
+ */
50
+ export declare class InnerSubscription implements AsyncIterableIterator<InnerWireFrame> {
51
+ private readonly now;
52
+ private readonly queue;
53
+ private dropped;
54
+ private droppedSinceTs;
55
+ private waiter;
56
+ private closed;
57
+ constructor(now?: () => number);
58
+ /** Enqueue a frame, dropping the oldest if the queue is full. Source → sub. */
59
+ push(frame: InnerFrame): void;
60
+ /** Pull the next deliverable, injecting a `compacted` marker first if drops are pending. */
61
+ private take;
62
+ next(): Promise<IteratorResult<InnerWireFrame>>;
63
+ /** Terminate the stream — wakes a parked consumer with `done: true`. Idempotent. */
64
+ close(): void;
65
+ return(): Promise<IteratorResult<InnerWireFrame>>;
66
+ [Symbol.asyncIterator](): AsyncIterableIterator<InnerWireFrame>;
67
+ }
68
+ /**
69
+ * Per-daemon registry of inner-loop subscribers, keyed by the player's fixed
70
+ * session `workflowId`. Implements eng's {@link InnerLoopSink} interface
71
+ * (`publish` + `subscriberCount`) so the publisher's thin client and this sink
72
+ * share one contract.
73
+ */
74
+ export declare class InnerLoopRegistry implements InnerLoopSink {
75
+ private readonly now;
76
+ private readonly subs;
77
+ constructor(now?: () => number);
78
+ /** Open a new SSE subscription for a player. Caller drains it, then `unsubscribe`. */
79
+ subscribe(workflowId: string): InnerSubscription;
80
+ /** Remove + close one subscription (on SSE disconnect). Prunes the empty player set. */
81
+ unsubscribe(workflowId: string, sub: InnerSubscription): void;
82
+ /** Fan a frame out to every live subscriber for the player. Source → subs. */
83
+ publish(workflowId: string, frame: InnerFrame): void;
84
+ /** Live subscriber count — the publisher's presence gate reads this (via `/inner/presence`). */
85
+ subscriberCount(workflowId: string): number;
86
+ /** Close every subscriber for a player (player gone → streams end with `:closed`). */
87
+ closePlayer(workflowId: string): void;
88
+ /** Total live inner-tail subscribers across all players (diagnostics). */
89
+ totalSubscriberCount(): number;
90
+ /** Close everything (daemon shutdown). */
91
+ close(): void;
92
+ }
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InnerLoopRegistry = exports.InnerSubscription = exports.INNER_SUB_QUEUE_MAX = void 0;
4
+ /** Per-subscriber bounded queue depth before drop-oldest engages. */
5
+ exports.INNER_SUB_QUEUE_MAX = 256;
6
+ /**
7
+ * One connected `/inner` SSE subscriber. Async-iterable: the SSE handler does
8
+ * `for await (const frame of sub) { write(frame) }`. Bounded queue with
9
+ * drop-oldest; a `compacted{dropped,sinceTs}` marker is injected before the next
10
+ * real frame whenever drops have occurred since the last delivery.
11
+ *
12
+ * No ring / replay / seq — this is an ephemeral best-effort tail (MD-F): a
13
+ * disconnect loses in-flight deltas, by design.
14
+ */
15
+ class InnerSubscription {
16
+ now;
17
+ queue = [];
18
+ dropped = 0;
19
+ droppedSinceTs = 0;
20
+ waiter = null;
21
+ closed = false;
22
+ constructor(now = Date.now) {
23
+ this.now = now;
24
+ }
25
+ /** Enqueue a frame, dropping the oldest if the queue is full. Source → sub. */
26
+ push(frame) {
27
+ if (this.closed)
28
+ return;
29
+ if (this.queue.length >= exports.INNER_SUB_QUEUE_MAX) {
30
+ this.queue.shift(); // drop oldest
31
+ if (this.dropped === 0)
32
+ this.droppedSinceTs = this.now();
33
+ this.dropped++;
34
+ }
35
+ this.queue.push(frame);
36
+ // A full queue means no waiter is parked (a waiter only parks on an empty
37
+ // queue), so a drop never races a pending take — drain just wakes a waiter
38
+ // when one exists (queue was empty, now has one item).
39
+ if (this.waiter) {
40
+ const next = this.take();
41
+ if (next) {
42
+ const w = this.waiter;
43
+ this.waiter = null;
44
+ w({ value: next, done: false });
45
+ }
46
+ }
47
+ }
48
+ /** Pull the next deliverable, injecting a `compacted` marker first if drops are pending. */
49
+ take() {
50
+ if (this.dropped > 0) {
51
+ const marker = { type: 'compacted', dropped: this.dropped, sinceTs: this.droppedSinceTs };
52
+ this.dropped = 0;
53
+ return marker;
54
+ }
55
+ return this.queue.shift() ?? null;
56
+ }
57
+ next() {
58
+ if (this.closed)
59
+ return Promise.resolve({ value: undefined, done: true });
60
+ const item = this.take();
61
+ if (item)
62
+ return Promise.resolve({ value: item, done: false });
63
+ return new Promise((resolve) => { this.waiter = resolve; });
64
+ }
65
+ /** Terminate the stream — wakes a parked consumer with `done: true`. Idempotent. */
66
+ close() {
67
+ if (this.closed)
68
+ return;
69
+ this.closed = true;
70
+ if (this.waiter) {
71
+ const w = this.waiter;
72
+ this.waiter = null;
73
+ w({ value: undefined, done: true });
74
+ }
75
+ }
76
+ return() {
77
+ this.close();
78
+ return Promise.resolve({ value: undefined, done: true });
79
+ }
80
+ [Symbol.asyncIterator]() {
81
+ return this;
82
+ }
83
+ }
84
+ exports.InnerSubscription = InnerSubscription;
85
+ /**
86
+ * Per-daemon registry of inner-loop subscribers, keyed by the player's fixed
87
+ * session `workflowId`. Implements eng's {@link InnerLoopSink} interface
88
+ * (`publish` + `subscriberCount`) so the publisher's thin client and this sink
89
+ * share one contract.
90
+ */
91
+ class InnerLoopRegistry {
92
+ now;
93
+ subs = new Map();
94
+ constructor(now = Date.now) {
95
+ this.now = now;
96
+ }
97
+ /** Open a new SSE subscription for a player. Caller drains it, then `unsubscribe`. */
98
+ subscribe(workflowId) {
99
+ const sub = new InnerSubscription(this.now);
100
+ let set = this.subs.get(workflowId);
101
+ if (!set) {
102
+ set = new Set();
103
+ this.subs.set(workflowId, set);
104
+ }
105
+ set.add(sub);
106
+ return sub;
107
+ }
108
+ /** Remove + close one subscription (on SSE disconnect). Prunes the empty player set. */
109
+ unsubscribe(workflowId, sub) {
110
+ const set = this.subs.get(workflowId);
111
+ if (!set)
112
+ return;
113
+ set.delete(sub);
114
+ sub.close();
115
+ if (set.size === 0)
116
+ this.subs.delete(workflowId);
117
+ }
118
+ /** Fan a frame out to every live subscriber for the player. Source → subs. */
119
+ publish(workflowId, frame) {
120
+ const set = this.subs.get(workflowId);
121
+ if (!set)
122
+ return;
123
+ for (const sub of set)
124
+ sub.push(frame);
125
+ }
126
+ /** Live subscriber count — the publisher's presence gate reads this (via `/inner/presence`). */
127
+ subscriberCount(workflowId) {
128
+ return this.subs.get(workflowId)?.size ?? 0;
129
+ }
130
+ /** Close every subscriber for a player (player gone → streams end with `:closed`). */
131
+ closePlayer(workflowId) {
132
+ const set = this.subs.get(workflowId);
133
+ if (!set)
134
+ return;
135
+ for (const sub of set)
136
+ sub.close();
137
+ this.subs.delete(workflowId);
138
+ }
139
+ /** Total live inner-tail subscribers across all players (diagnostics). */
140
+ totalSubscriberCount() {
141
+ let n = 0;
142
+ for (const set of this.subs.values())
143
+ n += set.size;
144
+ return n;
145
+ }
146
+ /** Close everything (daemon shutdown). */
147
+ close() {
148
+ for (const set of this.subs.values()) {
149
+ for (const sub of set)
150
+ sub.close();
151
+ }
152
+ this.subs.clear();
153
+ }
154
+ }
155
+ exports.InnerLoopRegistry = InnerLoopRegistry;
@@ -18,6 +18,9 @@
18
18
  import * as http from 'http';
19
19
  import type { TempoClient } from '../client/interface';
20
20
  import type { AggregateRunner } from './aggregate';
21
+ import type { InnerLoopRegistry } from './inner-loop';
22
+ import type { IngestTokenRegistry } from './ingest-registry';
23
+ import type { GateRegistry } from './gate-registry';
21
24
  import { type CorsConfig } from './cors';
22
25
  import { ConnectionCap } from './sse-handler';
23
26
  /** Default bind addr per SSE-PROTOCOL.md §1. */
@@ -59,10 +62,14 @@ export interface HttpServerOptions {
59
62
  */
60
63
  portFilePath?: string;
61
64
  /**
62
- * Inject the bearer token directly. Production callers pass `undefined`
63
- * so the server reads/auto-generates from `~/.agent-tempo/config.json`.
65
+ * @deprecated 3e back-compat alias for {@link readToken}. A single injected
66
+ * bearer is treated as the READ token (T1). Prefer `readToken`/`adminToken`.
64
67
  */
65
68
  httpToken?: string;
69
+ /** 3e — inject the read-tier (T1) token directly (tests). Overrides config/env. */
70
+ readToken?: string;
71
+ /** 3e — inject the admin (T1+T2+T3) token directly (tests). Overrides env. */
72
+ adminToken?: string;
66
73
  /**
67
74
  * Test seam — lets unit tests stub `process.uptime`-style readings.
68
75
  */
@@ -80,6 +87,25 @@ export interface HttpServerOptions {
80
87
  * low value in tests to exercise the 503 path.
81
88
  */
82
89
  maxSseConnections?: number;
90
+ /**
91
+ * 3c Tier-2 — the inner-loop fine-tail registry (off-wire SSE sink) and the
92
+ * ingest-token registry (source-plane auth). When both are provided the
93
+ * `/v1/players/:e/:p/inner` egress + `/inner/ingest` + `/inner/presence`
94
+ * ingress routes light up; absent → those routes 404/503. The daemon
95
+ * constructs + shares these (the outbox mints ingest tokens into the same
96
+ * `ingestTokens` instance the server validates against).
97
+ */
98
+ innerLoop?: InnerLoopRegistry;
99
+ ingestTokens?: IngestTokenRegistry;
100
+ /**
101
+ * 3d MD-G — the operator-gate registry. When provided (with `ingestTokens`)
102
+ * the `/v1/players/:e/:p/gate-arm` + `/gate-disarm` + `/gate/:requestId`
103
+ * operator routes (requireTier(3)) and the `/gate/:requestId/resolution`
104
+ * subprocess-poll route (ingest-token) light up; absent → those routes 404.
105
+ * The daemon constructs + shares this with the worker (auto-disarm on
106
+ * detach/destroy) — same singleton pattern as the inner-loop registries.
107
+ */
108
+ gate?: GateRegistry;
83
109
  }
84
110
  export interface HttpServerHandle {
85
111
  /** The actual port the server is listening on (after `.listen()` resolves). */
@@ -106,13 +132,22 @@ interface HandleContext {
106
132
  version: string;
107
133
  bindAddr: string;
108
134
  corsConfig: CorsConfig;
109
- httpToken: string | null;
135
+ /** 3e RBAC read-tier token (T1), or null. */
136
+ readToken: string | null;
137
+ /** 3e RBAC admin token (T1+T2+T3) — env-var-only, or null when unset. */
138
+ adminToken: string | null;
110
139
  startedAt: number;
111
140
  subscriberCount: () => number;
112
141
  /** Present when PR-2 streaming is wired; null on PR-1-only deployments. */
113
142
  aggregate: AggregateRunner | null;
114
143
  /** Process-wide SSE subscriber cap (§7.3). */
115
144
  sseConnectionCap: ConnectionCap;
145
+ /** 3c Tier-2 inner-loop fine-tail sink (off-wire) — null when unwired. */
146
+ innerLoop: InnerLoopRegistry | null;
147
+ /** 3c Tier-2 ingest-token registry (source-plane auth) — null when unwired. */
148
+ ingestTokens: IngestTokenRegistry | null;
149
+ /** 3d MD-G operator-gate registry — null when unwired. */
150
+ gate: GateRegistry | null;
116
151
  }
117
152
  /**
118
153
  * Top-level request dispatcher — exported for unit tests that want to