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,95 @@
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.gateAuditRoot = gateAuditRoot;
37
+ exports.gateAuditPath = gateAuditPath;
38
+ exports.createGateAuditSink = createGateAuditSink;
39
+ /**
40
+ * Operator-gate audit writer (3d / MD-G, R5 — security-locked) — the daemon's
41
+ * append-only JSONL sink for {@link GateRegistry} events. One file per player at
42
+ *
43
+ * <AGENT_TEMPO_HOME>/gate-audit/<ensemble>/<workflowId>.jsonl
44
+ *
45
+ * Each {@link GateAuditRecord} (arm | disarm | decision) is one JSON line,
46
+ * appended SYNCHRONOUSLY at the decision/posture-change point so the durable
47
+ * record lands before the daemon hands back control (no buffering window where a
48
+ * crash loses an allow/deny). The `ensemble` sidecar (not part of the locked
49
+ * record schema) only paths the file.
50
+ *
51
+ * Daemon-side ONLY. The writer is wired as the GateRegistry's audit sink in
52
+ * `daemon.ts`; failures are swallowed + logged (audit is best-effort durable —
53
+ * never let an append error break a live gate decision).
54
+ */
55
+ const fs = __importStar(require("fs"));
56
+ const path = __importStar(require("path"));
57
+ const config_1 = require("../config");
58
+ const log = (...args) => console.error('[agent-tempo:gate-audit]', ...args);
59
+ /** Root of the per-player gate-audit tree. */
60
+ function gateAuditRoot() {
61
+ return path.join(config_1.AGENT_TEMPO_HOME, 'gate-audit');
62
+ }
63
+ /**
64
+ * Sanitize a single path segment (ensemble / workflowId) so a crafted name can't
65
+ * traverse out of the audit root. Ensemble + workflowId are already validated
66
+ * upstream (ENSEMBLE_NAME_REGEX / the workflowId is daemon-built), but defend the
67
+ * filesystem boundary anyway: strip anything outside `[A-Za-z0-9._-]`, collapse
68
+ * to a non-empty token.
69
+ */
70
+ function safeSegment(seg) {
71
+ const cleaned = seg.replace(/[^A-Za-z0-9._-]/g, '_');
72
+ return cleaned.length > 0 ? cleaned : '_';
73
+ }
74
+ /** Absolute JSONL path for a (ensemble, workflowId) pair under `root`. */
75
+ function gateAuditPath(ensemble, workflowId, root = gateAuditRoot()) {
76
+ return path.join(root, safeSegment(ensemble || '_'), `${safeSegment(workflowId)}.jsonl`);
77
+ }
78
+ /**
79
+ * Build the daemon's audit sink. Returns a {@link GateAuditSink} that appends one
80
+ * JSON line per record. Append + mkdir are synchronous (durable-before-return);
81
+ * any I/O error is logged + swallowed so a disk problem never wedges a gate
82
+ * decision. `root` is injectable for tests (defaults to {@link gateAuditRoot}).
83
+ */
84
+ function createGateAuditSink(root = gateAuditRoot()) {
85
+ return (record, ensemble) => {
86
+ try {
87
+ const file = gateAuditPath(ensemble, record.workflowId, root);
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
90
+ }
91
+ catch (err) {
92
+ log('append failed (non-fatal):', err instanceof Error ? err.message : err);
93
+ }
94
+ };
95
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Operator-gate registry (3d / MD-G) — the DAEMON-LOCAL state for the live
3
+ * tool-call approve/deny gate on a headless Pi player. Per-player (keyed by the
4
+ * fixed session `workflowId`), it holds two things:
5
+ *
6
+ * 1. ARMED state — whether an operator has armed the gate for this player.
7
+ * When disarmed, the player's `tool_call` handler never engages the gate
8
+ * (MD-C decides immediately); when armed, a non-`low-risk` tool routes here
9
+ * for an operator decision.
10
+ * 2. PENDING requests — one entry per in-flight gated tool call, keyed by a
11
+ * caller-supplied `requestId`. The operator resolves it (POST /gate/:id);
12
+ * the Pi subprocess POLLS for the resolution (GET /gate/:id/resolution) and
13
+ * resolves its awaited `tool_call` Promise on a non-pending answer.
14
+ *
15
+ * ── 45s auto-allow (R3, autonomous-first, maintainer-LOCKED) ──
16
+ * A pending request with no operator decision after {@link GATE_AUTO_ALLOW_MS}
17
+ * resolves to `auto-allow` so an unsupervised run never stalls (and Pi #2381 — a
18
+ * `tool_call` handler that never resolves hangs the loop — can't bite). Expiry is
19
+ * LAZY-ON-POLL (computed from `createdAt` vs the injected clock at
20
+ * {@link GateRegistry.getResolution} time) — no daemon timer, fully
21
+ * deterministic under test. The Pi-side poll loop additionally bounds itself +
22
+ * honors `ctx.signal`, so the loop is bounded on BOTH sides.
23
+ *
24
+ * ── Audit (R5, security-locked) ──
25
+ * Every posture change (arm / disarm) and every decision (operator allow|deny,
26
+ * timeout auto-allow) is handed to the injected {@link GateAuditSink} — the
27
+ * daemon wires the append-only JSONL writer
28
+ * (`~/.agent-tempo/gate-audit/<ensemble>/<workflowId>.jsonl`). The registry owns
29
+ * the auto-allow decision point, so it MUST audit that one; arm/disarm/operator
30
+ * decisions are audited here too for a single sink.
31
+ *
32
+ * ── gate_resolved on the /inner stream (architect-ruled DI) ──
33
+ * When a decision lands, the registry emits an `inner.gate_resolved` frame so the
34
+ * operator sees the outcome. To avoid a circular import (GateRegistry ↔ the
35
+ * inner-loop module), the publish path is an INJECTED {@link PublishToInner}
36
+ * callback — the daemon wires it to `innerLoop.publish`. The registry imports
37
+ * only the InnerFrame TYPE (erased at compile), never the inner-loop runtime.
38
+ *
39
+ * This module is daemon-side ONLY (loopback HTTP boundary, off Temporal, off the
40
+ * coordination bus). Nothing here is imported by `src/workflows/`.
41
+ */
42
+ import type { InnerFrame } from '../pi/inner-loop-publisher';
43
+ /** Operator-gate timeout: a pending request auto-ALLOWS after this long (R3, locked). */
44
+ export declare const GATE_AUTO_ALLOW_MS = 45000;
45
+ /** Terminal decision on a gated tool call. `auto-allow` = the R3 timeout fired. */
46
+ export type GateDecision = 'allow' | 'deny' | 'auto-allow';
47
+ /** Who/what produced a decision. */
48
+ export type GateDecisionSource = 'operator' | 'timeout';
49
+ /** Metadata the source attaches when opening a gate request (for operator display + audit). */
50
+ export interface GateRequestMeta {
51
+ /** The tool being gated (already classified non-`low-risk`). */
52
+ tool: string;
53
+ /** A bounded summary of the tool args (source-truncated, ≤ a couple KB). */
54
+ argsSummary: string;
55
+ /** The player's Pi conversation id, if known (audit only). */
56
+ sessionId?: string;
57
+ /**
58
+ * The player's ensemble — stashed per-player so the audit sink can path the
59
+ * JSONL under `<ensemble>/<workflowId>.jsonl` (workflowId is not cleanly
60
+ * splittable since both ensemble + playerId may contain hyphens).
61
+ */
62
+ ensemble?: string;
63
+ }
64
+ /** The poll answer the Pi subprocess receives from GET /gate/:requestId/resolution. */
65
+ export type GateResolution = {
66
+ status: 'pending';
67
+ } | {
68
+ status: 'resolved';
69
+ decision: GateDecision;
70
+ source: GateDecisionSource;
71
+ };
72
+ /** One audited gate event (R5 schema, security-locked; `kind` discriminator per architect). */
73
+ export type GateAuditRecord = {
74
+ kind: 'decision';
75
+ ts: string;
76
+ workflowId: string;
77
+ sessionId?: string;
78
+ requestId: string;
79
+ tool: string;
80
+ argsSummary: string;
81
+ decision: GateDecision;
82
+ source: GateDecisionSource;
83
+ operatorTokenHint?: string;
84
+ } | {
85
+ kind: 'arm' | 'disarm';
86
+ ts: string;
87
+ workflowId: string;
88
+ source: 'operator';
89
+ operatorTokenHint?: string;
90
+ };
91
+ /**
92
+ * Append-only audit sink (daemon wires the JSONL writer; tests inject a spy).
93
+ * `ensemble` is a SIDECAR (not part of the locked record schema) the writer uses
94
+ * to path `<ensemble>/<workflowId>.jsonl`; `''` when unknown.
95
+ */
96
+ export type GateAuditSink = (record: GateAuditRecord, ensemble: string) => void;
97
+ /**
98
+ * Injected publish path for the `inner.gate_resolved` outcome frame (architect's
99
+ * DI to avoid a GateRegistry ↔ inner-loop circular import). Daemon wires it to
100
+ * `innerLoop.publish`; tests inject a spy; default is a no-op.
101
+ */
102
+ export type PublishToInner = (workflowId: string, frame: InnerFrame) => void;
103
+ /** Result of an operator decision attempt — drives the route's HTTP status. */
104
+ export type DecideResult = {
105
+ ok: true;
106
+ } | {
107
+ ok: false;
108
+ reason: 'not-found';
109
+ } | {
110
+ ok: false;
111
+ reason: 'already-decided';
112
+ };
113
+ /**
114
+ * Per-daemon operator-gate registry, keyed by the player's fixed session
115
+ * `workflowId`. One instance is constructed in the daemon and shared between the
116
+ * Temporal worker (auto-disarm on detach/destroy) and the HTTP gate routes —
117
+ * the same singleton-sharing pattern as the 3c InnerLoop/IngestToken registries.
118
+ */
119
+ export declare class GateRegistry {
120
+ private readonly audit;
121
+ private readonly now;
122
+ private readonly autoAllowMs;
123
+ private readonly publishToInner;
124
+ private readonly gates;
125
+ constructor(audit?: GateAuditSink, now?: () => number, autoAllowMs?: number, publishToInner?: PublishToInner);
126
+ /** Emit an `inner.gate_resolved` outcome frame on the player's /inner stream. */
127
+ private emitResolved;
128
+ private gate;
129
+ private nowIso;
130
+ /** Arm the gate for a player — subsequent non-`low-risk` tools route to the operator. */
131
+ arm(workflowId: string, ensemble: string, operatorTokenHint?: string): void;
132
+ /** Disarm the gate — new tools no longer engage it (in-flight pending still resolvable / auto-allow). */
133
+ disarm(workflowId: string, operatorTokenHint?: string): void;
134
+ /** Whether the gate is currently armed for a player (the engagement predicate reads this). */
135
+ isArmed(workflowId: string): boolean;
136
+ /**
137
+ * Open (or return the existing) pending request for a gated tool call.
138
+ * Idempotent on `requestId` — a retried open returns the existing entry so the
139
+ * source can safely re-register before polling. Does NOT audit (the request
140
+ * isn't a decision); the decision/auto-allow audit records carry tool+args.
141
+ */
142
+ open(workflowId: string, requestId: string, meta: GateRequestMeta): void;
143
+ /**
144
+ * Operator decision (allow|deny) on a pending request. Returns a result that
145
+ * the route maps to a status: unknown → 404, already-decided → 409 (idempotency
146
+ * guard so a double-POST or a post-timeout race can't flip a recorded answer).
147
+ */
148
+ decide(workflowId: string, requestId: string, decision: 'allow' | 'deny', operatorTokenHint?: string): DecideResult;
149
+ /**
150
+ * The Pi subprocess's poll answer. Returns `null` for an unknown request
151
+ * (route → 404). For a known request: an already-recorded decision, OR — if no
152
+ * decision and `now - createdAt >= autoAllowMs` — a freshly-recorded `auto-allow`
153
+ * (R3 timeout, source `timeout`, audited HERE since the registry owns the
154
+ * timeout clock), OR `pending`.
155
+ */
156
+ getResolution(workflowId: string, requestId: string): GateResolution | null;
157
+ /**
158
+ * Auto-disarm + drop all pending for a player (detach / destroy). The
159
+ * subprocess is going away, so abandoning its pending requests is correct — a
160
+ * still-polling client would just stop. Idempotent.
161
+ */
162
+ clearPlayer(workflowId: string): void;
163
+ /** Drop every player's gate state (daemon shutdown / clear-all). */
164
+ clear(): void;
165
+ /** Pending-request count for a player (diagnostics / tests). */
166
+ pendingCount(workflowId: string): number;
167
+ }
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GateRegistry = exports.GATE_AUTO_ALLOW_MS = void 0;
4
+ /** Operator-gate timeout: a pending request auto-ALLOWS after this long (R3, locked). */
5
+ exports.GATE_AUTO_ALLOW_MS = 45_000;
6
+ /**
7
+ * Per-daemon operator-gate registry, keyed by the player's fixed session
8
+ * `workflowId`. One instance is constructed in the daemon and shared between the
9
+ * Temporal worker (auto-disarm on detach/destroy) and the HTTP gate routes —
10
+ * the same singleton-sharing pattern as the 3c InnerLoop/IngestToken registries.
11
+ */
12
+ class GateRegistry {
13
+ audit;
14
+ now;
15
+ autoAllowMs;
16
+ publishToInner;
17
+ gates = new Map();
18
+ constructor(audit = () => { }, now = Date.now, autoAllowMs = exports.GATE_AUTO_ALLOW_MS, publishToInner = () => { }) {
19
+ this.audit = audit;
20
+ this.now = now;
21
+ this.autoAllowMs = autoAllowMs;
22
+ this.publishToInner = publishToInner;
23
+ }
24
+ /** Emit an `inner.gate_resolved` outcome frame on the player's /inner stream. */
25
+ emitResolved(workflowId, requestId, decision, source) {
26
+ this.publishToInner(workflowId, { type: 'inner.gate_resolved', requestId, decision, source, ts: this.now() });
27
+ }
28
+ gate(workflowId) {
29
+ let g = this.gates.get(workflowId);
30
+ if (!g) {
31
+ g = { armed: false, ensemble: '', pending: new Map() };
32
+ this.gates.set(workflowId, g);
33
+ }
34
+ return g;
35
+ }
36
+ nowIso() {
37
+ // `now` is injectable; format the same epoch ms the registry reasons about so
38
+ // audit timestamps line up with the lazy-expiry clock under test.
39
+ return new Date(this.now()).toISOString();
40
+ }
41
+ // ── Arm / disarm (operator posture; audited) ───────────────────────────────
42
+ /** Arm the gate for a player — subsequent non-`low-risk` tools route to the operator. */
43
+ arm(workflowId, ensemble, operatorTokenHint) {
44
+ const g = this.gate(workflowId);
45
+ g.armed = true;
46
+ if (ensemble)
47
+ g.ensemble = ensemble;
48
+ this.audit({ kind: 'arm', ts: this.nowIso(), workflowId, source: 'operator', ...(operatorTokenHint ? { operatorTokenHint } : {}) }, g.ensemble);
49
+ }
50
+ /** Disarm the gate — new tools no longer engage it (in-flight pending still resolvable / auto-allow). */
51
+ disarm(workflowId, operatorTokenHint) {
52
+ const g = this.gate(workflowId);
53
+ g.armed = false;
54
+ this.audit({ kind: 'disarm', ts: this.nowIso(), workflowId, source: 'operator', ...(operatorTokenHint ? { operatorTokenHint } : {}) }, g.ensemble);
55
+ }
56
+ /** Whether the gate is currently armed for a player (the engagement predicate reads this). */
57
+ isArmed(workflowId) {
58
+ return this.gates.get(workflowId)?.armed ?? false;
59
+ }
60
+ // ── Pending requests (source opens; operator decides; source polls) ─────────
61
+ /**
62
+ * Open (or return the existing) pending request for a gated tool call.
63
+ * Idempotent on `requestId` — a retried open returns the existing entry so the
64
+ * source can safely re-register before polling. Does NOT audit (the request
65
+ * isn't a decision); the decision/auto-allow audit records carry tool+args.
66
+ */
67
+ open(workflowId, requestId, meta) {
68
+ const g = this.gate(workflowId);
69
+ if (meta.ensemble)
70
+ g.ensemble = meta.ensemble;
71
+ if (g.pending.has(requestId))
72
+ return;
73
+ g.pending.set(requestId, {
74
+ tool: meta.tool,
75
+ argsSummary: meta.argsSummary,
76
+ sessionId: meta.sessionId,
77
+ createdAt: this.now(),
78
+ decision: null,
79
+ source: null,
80
+ });
81
+ }
82
+ /**
83
+ * Operator decision (allow|deny) on a pending request. Returns a result that
84
+ * the route maps to a status: unknown → 404, already-decided → 409 (idempotency
85
+ * guard so a double-POST or a post-timeout race can't flip a recorded answer).
86
+ */
87
+ decide(workflowId, requestId, decision, operatorTokenHint) {
88
+ const g = this.gates.get(workflowId);
89
+ const req = g?.pending.get(requestId);
90
+ if (!req)
91
+ return { ok: false, reason: 'not-found' };
92
+ if (req.decision !== null)
93
+ return { ok: false, reason: 'already-decided' };
94
+ req.decision = decision;
95
+ req.source = 'operator';
96
+ this.audit({
97
+ kind: 'decision',
98
+ ts: this.nowIso(),
99
+ workflowId,
100
+ ...(req.sessionId ? { sessionId: req.sessionId } : {}),
101
+ requestId,
102
+ tool: req.tool,
103
+ argsSummary: req.argsSummary,
104
+ decision,
105
+ source: 'operator',
106
+ ...(operatorTokenHint ? { operatorTokenHint } : {}),
107
+ }, g?.ensemble ?? '');
108
+ this.emitResolved(workflowId, requestId, decision, 'operator');
109
+ return { ok: true };
110
+ }
111
+ /**
112
+ * The Pi subprocess's poll answer. Returns `null` for an unknown request
113
+ * (route → 404). For a known request: an already-recorded decision, OR — if no
114
+ * decision and `now - createdAt >= autoAllowMs` — a freshly-recorded `auto-allow`
115
+ * (R3 timeout, source `timeout`, audited HERE since the registry owns the
116
+ * timeout clock), OR `pending`.
117
+ */
118
+ getResolution(workflowId, requestId) {
119
+ const g = this.gates.get(workflowId);
120
+ const req = g?.pending.get(requestId);
121
+ if (!req)
122
+ return null;
123
+ if (req.decision !== null) {
124
+ return { status: 'resolved', decision: req.decision, source: req.source ?? 'operator' };
125
+ }
126
+ if (this.now() - req.createdAt >= this.autoAllowMs) {
127
+ req.decision = 'auto-allow';
128
+ req.source = 'timeout';
129
+ this.audit({
130
+ kind: 'decision',
131
+ ts: this.nowIso(),
132
+ workflowId,
133
+ ...(req.sessionId ? { sessionId: req.sessionId } : {}),
134
+ requestId,
135
+ tool: req.tool,
136
+ argsSummary: req.argsSummary,
137
+ decision: 'auto-allow',
138
+ source: 'timeout',
139
+ }, g?.ensemble ?? '');
140
+ this.emitResolved(workflowId, requestId, 'auto-allow', 'timeout');
141
+ return { status: 'resolved', decision: 'auto-allow', source: 'timeout' };
142
+ }
143
+ return { status: 'pending' };
144
+ }
145
+ // ── Lifecycle (auto-disarm on detach/destroy; daemon shutdown) ──────────────
146
+ /**
147
+ * Auto-disarm + drop all pending for a player (detach / destroy). The
148
+ * subprocess is going away, so abandoning its pending requests is correct — a
149
+ * still-polling client would just stop. Idempotent.
150
+ */
151
+ clearPlayer(workflowId) {
152
+ this.gates.delete(workflowId);
153
+ }
154
+ /** Drop every player's gate state (daemon shutdown / clear-all). */
155
+ clear() {
156
+ this.gates.clear();
157
+ }
158
+ /** Pending-request count for a player (diagnostics / tests). */
159
+ pendingCount(workflowId) {
160
+ return this.gates.get(workflowId)?.pending.size ?? 0;
161
+ }
162
+ }
163
+ exports.GateRegistry = GateRegistry;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * HTTP route handlers for the 3d operator gate (MD-G). server.ts dispatches to
3
+ * these; logic lives here so it stays testable.
4
+ *
5
+ * FOUR routes, TWO auth planes (mirrors the 3c inner-loop split):
6
+ *
7
+ * OPERATOR plane (operator/dashboard → daemon). Mounted AFTER the outer bearer
8
+ * gate with an explicit `requireTier(3)` (the highest RBAC tier, MD-E) — only
9
+ * an admin-token holder may arm/disarm or decide:
10
+ * - POST /v1/players/:e/:p/gate-arm → arm the gate
11
+ * - POST /v1/players/:e/:p/gate-disarm → disarm the gate
12
+ * - POST /v1/players/:e/:p/gate/:requestId → decide { decision:'allow'|'deny' }
13
+ *
14
+ * SOURCE plane (Pi subprocess → daemon; publisher-only). Same INGRESS gate as
15
+ * the inner-loop ingest — loopback `remoteAddress` + `X-Ingest-Token` validated
16
+ * against the URL-derived workflowId (cross-player-spoof guard), uniform 403:
17
+ * - GET /v1/players/:e/:p/gate/:requestId/resolution → poll { status, … }
18
+ *
19
+ * The Pi subprocess's awaited `tool_call` handler polls the resolution route
20
+ * until it gets a non-`pending` answer (operator decision OR the registry's lazy
21
+ * 45s auto-allow) or its own bounded/ctx.signal-cancelled deadline.
22
+ *
23
+ * Daemon-side ONLY (loopback boundary, off Temporal). Not imported by workflows.
24
+ */
25
+ import type { IncomingMessage, ServerResponse } from 'http';
26
+ import type { GateRegistry } from './gate-registry';
27
+ import type { IngestTokenRegistry } from './ingest-registry';
28
+ export interface GateDeps {
29
+ gate: GateRegistry;
30
+ ingestTokens: IngestTokenRegistry;
31
+ }
32
+ /** POST /gate-arm — arm the operator gate for a player. 204. */
33
+ export declare function handleGateArm(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string): void;
34
+ /** POST /gate-disarm — disarm the operator gate for a player. 204. */
35
+ export declare function handleGateDisarm(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string): void;
36
+ /**
37
+ * POST /gate/:requestId — operator decision on a pending gated tool call.
38
+ * Body `{ decision: 'allow' | 'deny' }`. 204 on success; 404 unknown requestId;
39
+ * 409 already-decided (idempotency — a double-POST or post-timeout race can't
40
+ * flip a recorded answer); 400 malformed body / bad decision value.
41
+ */
42
+ export declare function handleGateDecide(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string, requestId: string): Promise<void>;
43
+ /**
44
+ * GET /gate/:requestId/resolution — the Pi subprocess polls for the decision.
45
+ * 200 `{ status:'pending' }` | `{ status:'resolved', decision, source }`; 404 for
46
+ * an unknown requestId; uniform 403 on any INGRESS gate failure (no leak).
47
+ */
48
+ export declare function handleGateResolution(req: IncomingMessage, res: ServerResponse, deps: GateDeps, ensemble: string, playerId: string, requestId: string): void;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleGateArm = handleGateArm;
4
+ exports.handleGateDisarm = handleGateDisarm;
5
+ exports.handleGateDecide = handleGateDecide;
6
+ exports.handleGateResolution = handleGateResolution;
7
+ const config_1 = require("../config");
8
+ const responses_1 = require("./responses");
9
+ const body_1 = require("./body");
10
+ const inner_loop_routes_1 = require("./inner-loop-routes");
11
+ /** Decision body cap — the payload is a tiny `{decision}`; this is the DOS backstop. */
12
+ const GATE_BODY_MAX = 4 * 1024;
13
+ function headerValue(v) {
14
+ if (v === undefined)
15
+ return undefined;
16
+ return Array.isArray(v) ? v[0] : v;
17
+ }
18
+ /**
19
+ * SOURCE-plane INGRESS gate (loopback + ingest-token vs URL workflowId), shared
20
+ * with the inner-loop ingest contract. Returns the workflowId on success, or
21
+ * `null` after writing a uniform 403 (no info leak — callers just `return`).
22
+ */
23
+ function gateIngress(req, res, deps, ensemble, playerId) {
24
+ const deny = () => { (0, responses_1.errorResponse)(res, 403, { error: 'forbidden' }); return null; };
25
+ if (!(0, inner_loop_routes_1.isLoopbackRemote)(req))
26
+ return deny();
27
+ const token = headerValue(req.headers[inner_loop_routes_1.INGEST_TOKEN_HEADER]);
28
+ if (!token)
29
+ return deny();
30
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
31
+ if (!deps.ingestTokens.validate(workflowId, token))
32
+ return deny();
33
+ return workflowId;
34
+ }
35
+ /**
36
+ * Best-effort short audit hint from the operator's bearer (last 6 chars) — never
37
+ * the full token. Absent on a loopback-trust request with no Authorization.
38
+ */
39
+ function operatorTokenHint(req) {
40
+ const auth = headerValue(req.headers.authorization);
41
+ if (!auth)
42
+ return undefined;
43
+ const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
44
+ const tok = m?.[1];
45
+ return tok && tok.length >= 6 ? `…${tok.slice(-6)}` : undefined;
46
+ }
47
+ // ── OPERATOR plane (server.ts applies requireTier(3) before dispatch) ──────────
48
+ /** POST /gate-arm — arm the operator gate for a player. 204. */
49
+ function handleGateArm(req, res, deps, ensemble, playerId) {
50
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
51
+ deps.gate.arm(workflowId, ensemble, operatorTokenHint(req));
52
+ res.writeHead(204);
53
+ res.end();
54
+ }
55
+ /** POST /gate-disarm — disarm the operator gate for a player. 204. */
56
+ function handleGateDisarm(req, res, deps, ensemble, playerId) {
57
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
58
+ deps.gate.disarm(workflowId, operatorTokenHint(req));
59
+ res.writeHead(204);
60
+ res.end();
61
+ }
62
+ /**
63
+ * POST /gate/:requestId — operator decision on a pending gated tool call.
64
+ * Body `{ decision: 'allow' | 'deny' }`. 204 on success; 404 unknown requestId;
65
+ * 409 already-decided (idempotency — a double-POST or post-timeout race can't
66
+ * flip a recorded answer); 400 malformed body / bad decision value.
67
+ */
68
+ async function handleGateDecide(req, res, deps, ensemble, playerId, requestId) {
69
+ const workflowId = (0, config_1.sessionWorkflowId)(ensemble, playerId);
70
+ const body = await (0, body_1.readJsonBody)(req, GATE_BODY_MAX);
71
+ if (body === body_1.BODY_TOO_LARGE || body === body_1.BODY_INVALID_JSON) {
72
+ return (0, responses_1.errorResponse)(res, 400, { error: 'bad-request' });
73
+ }
74
+ const decision = body.decision;
75
+ if (decision !== 'allow' && decision !== 'deny') {
76
+ return (0, responses_1.errorResponse)(res, 400, { error: 'bad-request', detail: "decision must be 'allow' or 'deny'" });
77
+ }
78
+ const result = deps.gate.decide(workflowId, requestId, decision, operatorTokenHint(req));
79
+ if (result.ok) {
80
+ res.writeHead(204);
81
+ res.end();
82
+ return;
83
+ }
84
+ if (result.reason === 'not-found')
85
+ return (0, responses_1.errorResponse)(res, 404, { error: 'not-found' });
86
+ return (0, responses_1.errorResponse)(res, 409, { error: 'already-decided' });
87
+ }
88
+ // ── SOURCE plane (ingest-token; Pi subprocess polls) ───────────────────────────
89
+ /**
90
+ * GET /gate/:requestId/resolution — the Pi subprocess polls for the decision.
91
+ * 200 `{ status:'pending' }` | `{ status:'resolved', decision, source }`; 404 for
92
+ * an unknown requestId; uniform 403 on any INGRESS gate failure (no leak).
93
+ */
94
+ function handleGateResolution(req, res, deps, ensemble, playerId, requestId) {
95
+ const workflowId = gateIngress(req, res, deps, ensemble, playerId);
96
+ if (workflowId === null)
97
+ return;
98
+ const resolution = deps.gate.getResolution(workflowId, requestId);
99
+ if (resolution === null)
100
+ return (0, responses_1.errorResponse)(res, 404, { error: 'not-found' });
101
+ return (0, responses_1.jsonResponse)(res, 200, resolution);
102
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * In-memory map of `workflowId → ingest token`, owned by the daemon (one
3
+ * instance shared between the outbox minter and the HTTP ingest/presence
4
+ * validators). Tokens never persist — they live only for the player's lifetime.
5
+ */
6
+ export declare class IngestTokenRegistry {
7
+ private readonly tokens;
8
+ /**
9
+ * Mint a fresh ingest token for a player, replacing any prior one (a restart
10
+ * re-mints). Returns the token to inject into the subprocess env.
11
+ */
12
+ mint(workflowId: string): string;
13
+ /**
14
+ * Validate a presented token against the token minted for `workflowId`
15
+ * (timing-safe, via {@link tokensMatch}). Returns `false` for an unknown
16
+ * player or a mismatch — so a compromised player presenting its OWN token for
17
+ * another player's URL is rejected (cross-player-spoof guard). The workflowId
18
+ * is public (it's in the URL); the token is the secret, and only its
19
+ * comparison is constant-time.
20
+ */
21
+ validate(workflowId: string, presented: string): boolean;
22
+ /** Revoke a player's ingest token (detach / destroy). Idempotent. */
23
+ revoke(workflowId: string): void;
24
+ /** Revoke every token (daemon shutdown / clear-all). */
25
+ revokeAll(): void;
26
+ /** Whether a player currently holds a minted token. */
27
+ has(workflowId: string): boolean;
28
+ /** Active token count (diagnostics / tests). */
29
+ size(): number;
30
+ }