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,407 @@
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.createPiExtension = createPiExtension;
37
+ exports.detachAllPiRuntimesForExit = detachAllPiRuntimesForExit;
38
+ exports.setRuntimeSession = setRuntimeSession;
39
+ exports.__setPiClientFactoryForTests = __setPiClientFactoryForTests;
40
+ exports.__resetPiRuntimesForTests = __resetPiRuntimesForTests;
41
+ /**
42
+ * agent-tempo Pi extension — interactive (Phase 2) + headless (Phase 3a) runtime.
43
+ *
44
+ * createPiExtension({ mode, toolAccess }) → (pi: ExtensionAPI) => void
45
+ * export default = createPiExtension() (interactive)
46
+ *
47
+ * Registers the FULL agent-tempo tool surface natively on Pi (shared
48
+ * transport-neutral descriptors + `renderToPi`), drives the attachment phase
49
+ * from Pi lifecycle events, holds an attachment lease + heartbeat, and pumps
50
+ * cues into the live session. The SAME extension runs interactive (behind a
51
+ * human `pi` CLI) and headless (injected into `createAgentSession` by the daemon
52
+ * — Phase 3a); `mode` is the only behavioural discriminator (it gates the MD-C
53
+ * tool_call enforcement, which applies to unsupervised headless players only).
54
+ *
55
+ * ── Module-scope singleton (CRITICAL — researcher finding) ──
56
+ * Pi REBUILDS the extension instance on every SessionManager switch, so
57
+ * per-INSTANCE state does NOT survive. Everything that must survive — the
58
+ * Temporal `Client`, the fixed `workflowId`, the pinned handle, the heartbeat
59
+ * timer, the cue pump, the current-session pointer — lives in a MODULE-SCOPE
60
+ * singleton (`runtimes`, keyed by workflowId; one entry interactive, N for the
61
+ * headless daemon — D12a). The rebuilt instance RE-BINDS; it never recreates.
62
+ *
63
+ * ── Teardown (Option C — reason-discriminated) ──
64
+ * `session_shutdown` carries `reason` {quit|reload|new|resume|fork}. We detach
65
+ * ONLY on a clean `quit`; switch/unknown reasons → rebind (no detach). The
66
+ * `quit` detach is best-effort; the MD-A lease reaper is the permanent floor.
67
+ * Headless owns its exit sequence, so it uses {@link detachAllPiRuntimesForExit}
68
+ * for RELIABLE detach (await adapterExited) before disposing the SDK session.
69
+ *
70
+ * Determinism boundary: this module (and all of src/pi/) is CLIENT-SIDE only.
71
+ */
72
+ const os = __importStar(require("os"));
73
+ const crypto = __importStar(require("crypto"));
74
+ const config_1 = require("../config");
75
+ const server_tools_1 = require("../server-tools");
76
+ const phase_driver_1 = require("./phase-driver");
77
+ const workflow_client_1 = require("./workflow-client");
78
+ const cue_pump_1 = require("./cue-pump");
79
+ const reset_pump_1 = require("./reset-pump");
80
+ const render_tools_1 = require("./render-tools");
81
+ const lazy_proxy_1 = require("./lazy-proxy");
82
+ const probe_1 = require("./probe");
83
+ const inner_loop_publisher_1 = require("./inner-loop-publisher");
84
+ const inner_loop_client_1 = require("./inner-loop-client");
85
+ const gate_client_1 = require("./gate-client");
86
+ const tool_capability_1 = require("./tool-capability");
87
+ const gate_registry_1 = require("../http/gate-registry");
88
+ const log = (...args) => {
89
+ // eslint-disable-next-line no-console
90
+ console.error('[agent-tempo:pi]', ...args);
91
+ };
92
+ const nowIso = () => new Date().toISOString();
93
+ const PI_AGENT_TYPE = 'claude'; // Pi is not yet a first-class AgentType.
94
+ // MD-C shell/exec tool-class membership is owned by `tool-capability.ts`
95
+ // (`classify(name) === 'exec'`, content signed off by tempo-security). F1
96
+ // import-refactor (3d): this REPLACES the former local `SHELL_TOOL_NAMES` set —
97
+ // the canonical EXEC_TOOLS set is a SUPERSET that also blocks
98
+ // powershell/pwsh/cmd/run, closing the gap the local list left open. Single
99
+ // source of truth: never re-declare a shell denylist here.
100
+ // ── Module-scope Temporal Client singleton (D12a: one Client per OS process) ──
101
+ let sharedClientPromise = null;
102
+ let connectedClient = null;
103
+ function getSharedClient(config) {
104
+ if (!sharedClientPromise) {
105
+ sharedClientPromise = workflow_client_1.PiWorkflowClient.connect(config)
106
+ .then((c) => { connectedClient = c; return c; })
107
+ .catch((err) => { sharedClientPromise = null; log('Temporal connect failed:', err); throw err; });
108
+ }
109
+ return sharedClientPromise;
110
+ }
111
+ /** Connection factory; overridable via `__setPiClientFactoryForTests`. */
112
+ let clientFactory = getSharedClient;
113
+ /** One runtime per player, keyed by fixed workflowId. Survives instance rebuilds. */
114
+ const runtimes = new Map();
115
+ /**
116
+ * Build the Pi extension factory. `mode='headless'` installs the MD-C tool_call
117
+ * gate; `mode='interactive'` (default) does not (the human owns their machine).
118
+ */
119
+ function createPiExtension(options = {}) {
120
+ const mode = options.mode ?? 'interactive';
121
+ const toolAccess = options.toolAccess ?? 'restricted';
122
+ return function piExtension(pi) {
123
+ const probe = (0, probe_1.probePi)();
124
+ if (!probe.available)
125
+ log('WARNING:', probe.reason);
126
+ const config = (0, config_1.getConfig)();
127
+ const isConductor = process.env[config_1.ENV.CONDUCTOR] === '1' || process.env[config_1.ENV.CONDUCTOR] === 'true';
128
+ // Identity — FIXED workflowId for the process lifetime. `currentPlayerId` is
129
+ // the mutable DISPLAY id (set_name updates it); the workflowId never repoints.
130
+ let currentPlayerId = process.env[config_1.ENV.PLAYER_NAME] || `pi-${process.pid}`;
131
+ const workflowId = (0, config_1.sessionWorkflowId)(config.ensemble, currentPlayerId);
132
+ // 3c — the inner-loop URL + ingest token are keyed to the player's FIXED
133
+ // identity (the daemon minted the token for sessionWorkflowId(ensemble,
134
+ // <recruit name>)). `currentPlayerId` is mutable (set_name), so capture the
135
+ // original here — the publisher's HTTP client URL must match the workflowId.
136
+ const fixedPlayerId = currentPlayerId;
137
+ // Kick off (or reuse) the module-scope shared connection.
138
+ void clientFactory(config);
139
+ // ── D11 lazy proxies: resolve MODULE-SCOPE state per call (instance-independent) ──
140
+ const clientProxy = (0, lazy_proxy_1.createLazyProxy)(() => connectedClient, 'Temporal client');
141
+ const handleProxy = (0, lazy_proxy_1.createLazyProxy)(() => runtimes.get(workflowId)?.wf.handle ?? null, 'workflow handle');
142
+ // ── Register the FULL tool surface on THIS instance's `pi` ──
143
+ const toolOpts = {
144
+ client: clientProxy,
145
+ config,
146
+ getPlayerId: () => currentPlayerId,
147
+ setPlayerId: (id) => { currentPlayerId = id; },
148
+ handle: handleProxy,
149
+ workflowId,
150
+ ownAgentType: PI_AGENT_TYPE,
151
+ isConductor,
152
+ };
153
+ (0, render_tools_1.renderToPi)(pi, (0, server_tools_1.buildAllTempoTools)(toolOpts));
154
+ log(`registered tools (player=${currentPlayerId}, conductor=${isConductor}, mode=${mode})`);
155
+ // ── MD-C tool-access gate (HEADLESS ONLY) ──
156
+ // Interactive Pi = a human owns their machine → no gate. Headless = recruited
157
+ // unsupervised → MD-C governs tool access. TOOL-CLASS CHECK FIRST: shell/exec
158
+ // tools are HARD-BLOCKED at toolAccess='restricted' (the safe unsupervised
159
+ // default) regardless of any later gate logic. The supervised gate (3d) slots
160
+ // in AFTER this MD-C floor — for now, anything MD-C permits is allowed.
161
+ if (mode === 'headless') {
162
+ // 3d MD-G — the operator gate's two loopback clients, keyed to the FIXED
163
+ // player identity (matches the ingest token + workflowId). `gateInner`
164
+ // emits the gate_pending frame (the daemon's ingest side-effect registers
165
+ // the pending) and reports presence {subscribers, gateArmed}; `gateClient`
166
+ // polls the daemon for the operator's decision. Both no-op without the
167
+ // ingest token (so this is inert for a manually-launched headless Pi).
168
+ const gateInner = new inner_loop_client_1.InnerLoopHttpClient({ ensemble: config.ensemble, playerId: fixedPlayerId });
169
+ const gateClient = new gate_client_1.GateClient({ ensemble: config.ensemble, playerId: fixedPlayerId });
170
+ pi.on('tool_call', async (event, ctx) => {
171
+ const cls = (0, tool_capability_1.classify)(event.toolName);
172
+ // 1) MD-C tool-class FLOOR (fires FIRST). classify()==='exec' is the
173
+ // canonical EXEC set (F1) — a SUPERSET that hard-blocks
174
+ // powershell/pwsh/cmd/run at restricted. HARD-block, never gated.
175
+ if (cls === 'exec' && toolAccess === 'restricted') {
176
+ log(`MD-C: blocked '${event.toolName}' (toolAccess=restricted)`);
177
+ return {
178
+ block: true,
179
+ reason: `toolAccess=restricted: shell/exec tools are disabled for this headless Pi player`,
180
+ };
181
+ }
182
+ // 2) MD-G OPERATOR GATE — engage for any non-low-risk tool WHEN an
183
+ // operator is armed AND present (both read from the short-poll
184
+ // cached presence). low-risk bypasses; unknown→high-blast is gated-
185
+ // when-armed (R2). The await resolves on the operator's decision,
186
+ // the daemon's 45s auto-allow, ctx.signal cancel, or the bounded
187
+ // poll deadline — all FAIL-OPEN except an explicit deny.
188
+ if (cls !== 'low-risk' && gateInner.gateArmed(workflowId) && gateInner.subscriberCount(workflowId) > 0) {
189
+ const requestId = crypto.randomUUID();
190
+ gateInner.publish(workflowId, {
191
+ type: 'inner.gate_pending',
192
+ requestId,
193
+ tool: event.toolName,
194
+ argsSummary: (0, inner_loop_publisher_1.truncateSummary)(event.input, 2048),
195
+ classification: cls, // low-risk already returned above
196
+ timeoutMs: gate_registry_1.GATE_AUTO_ALLOW_MS,
197
+ ts: Date.now(),
198
+ });
199
+ const effect = await gateClient.awaitDecision(requestId, { signal: ctx?.signal });
200
+ if (effect === 'deny') {
201
+ log(`MD-G: operator DENIED '${event.toolName}' (req ${requestId})`);
202
+ return { block: true, reason: `operator denied ${event.toolName}` };
203
+ }
204
+ log(`MD-G: '${event.toolName}' permitted (req ${requestId})`);
205
+ return {};
206
+ }
207
+ // 3) not gated → permit.
208
+ return {};
209
+ });
210
+ log(`MD-C+MD-G tool gate active (mode=headless, toolAccess=${toolAccess})`);
211
+ }
212
+ /** Build session metadata from the (current) identity + host. */
213
+ function buildMetadata() {
214
+ return {
215
+ playerId: currentPlayerId,
216
+ ensemble: config.ensemble,
217
+ hostname: os.hostname(),
218
+ workDir: process.cwd(),
219
+ isConductor,
220
+ agentType: PI_AGENT_TYPE,
221
+ adapterId: 'pi',
222
+ };
223
+ }
224
+ /** Persist the active Pi conversation id to metadata.sessionId IF it changed (P2-5). */
225
+ async function refreshSessionId(rt, sessionId) {
226
+ if (!sessionId || sessionId === rt.lastSessionId)
227
+ return;
228
+ await rt.wf.updateSessionId(sessionId);
229
+ rt.lastSessionId = sessionId;
230
+ }
231
+ /**
232
+ * Get-or-create the runtime for this player. FIRST attach claims the lease +
233
+ * starts the heartbeat + cue pump. A subsequent `session_start` (instance
234
+ * rebuild) RE-BINDS the surviving runtime — session pointer only, no re-claim.
235
+ */
236
+ async function attachOrRebind(payload) {
237
+ const existing = runtimes.get(workflowId);
238
+ if (existing) {
239
+ existing.session = payload.session ?? existing.session;
240
+ log(`re-bound ${currentPlayerId} (Pi instance rebuilt; lease intact)`);
241
+ return existing;
242
+ }
243
+ const client = await clientFactory(config);
244
+ const wf = new workflow_client_1.PiWorkflowClient({
245
+ client,
246
+ config,
247
+ metadata: buildMetadata(),
248
+ expectedAttachmentId: process.env[config_1.ENV.ATTACHMENT_ID] || undefined,
249
+ });
250
+ const driver = new phase_driver_1.PhaseDriver();
251
+ const pump = new cue_pump_1.CuePump({
252
+ source: wf,
253
+ resolveSession: () => runtimes.get(workflowId)?.session ?? null,
254
+ });
255
+ // 3c — inner-loop publisher + its loopback-HTTP sink. The client no-ops
256
+ // unless AGENT_TEMPO_INGEST_TOKEN is present (daemon-spawned headless
257
+ // players only), so interactive Pi gets Tier-1 coarse for free and zero
258
+ // Tier-2 forwarding. URL keyed to the FIXED playerId (matches workflowId).
259
+ const registry = new inner_loop_client_1.InnerLoopHttpClient({ ensemble: config.ensemble, playerId: fixedPlayerId });
260
+ const pub = new inner_loop_publisher_1.InnerLoopPublisher({ workflowId, registry });
261
+ // 3d D14 — reset poll-tick (sibling to the cue pump): polls pendingReset →
262
+ // session.newSession() clean-wipe + ack. resolveSession re-acquired each
263
+ // tick so a session switch never wipes a stale session.
264
+ const reset = new reset_pump_1.ResetPump({
265
+ source: wf,
266
+ resolveSession: () => runtimes.get(workflowId)?.session ?? null,
267
+ });
268
+ const rt = { workflowId, wf, driver, pump, pub, reset, session: payload.session ?? null };
269
+ runtimes.set(workflowId, rt);
270
+ await wf.ensureSessionWorkflow();
271
+ const result = driver.handle('session_start', payload, nowIso());
272
+ await wf.performAction(result.action); // claim → attached, starts heartbeat
273
+ pump.start();
274
+ // Start the publisher AFTER the claim (heartbeat is live → coarse samples
275
+ // have a delivery path) and wire its coarse state into the heartbeat. The
276
+ // bound method is wrapped so `this` survives the provider call.
277
+ pub.start(pi);
278
+ wf.setCoarseProvider(() => pub.getCoarseState());
279
+ reset.start(); // 3d D14 — begin polling for pending resets
280
+ log(`attached ${currentPlayerId} (wf ${workflowId})`);
281
+ return rt;
282
+ }
283
+ // ── Lifecycle: session_start → first attach OR re-bind ──
284
+ pi.on('session_start', async (payload) => {
285
+ try {
286
+ const rt = await attachOrRebind(payload);
287
+ await refreshSessionId(rt, rt.session?.id);
288
+ }
289
+ catch (err) {
290
+ log('session_start wiring failed:', err);
291
+ }
292
+ });
293
+ // ── Lifecycle: phase-affecting events ──
294
+ for (const event of ['agent_start', 'agent_end']) {
295
+ pi.on(event, async (payload) => {
296
+ const rt = runtimes.get(workflowId);
297
+ if (!rt)
298
+ return;
299
+ if (payload.session)
300
+ rt.session = payload.session;
301
+ const result = rt.driver.handle(event, payload, nowIso());
302
+ try {
303
+ await rt.wf.performAction(result.action);
304
+ if (event === 'agent_start')
305
+ await refreshSessionId(rt, rt.session?.id);
306
+ }
307
+ catch (err) {
308
+ log(`${event} → ${result.action.kind} failed:`, err);
309
+ }
310
+ });
311
+ }
312
+ // ── Lifecycle: activity-only events (NEVER drive phase) ──
313
+ for (const event of [
314
+ 'turn_start', 'turn_end', 'tool_execution_start', 'tool_execution_end',
315
+ ]) {
316
+ pi.on(event, (payload) => {
317
+ const rt = runtimes.get(workflowId);
318
+ if (!rt)
319
+ return;
320
+ if (payload.session)
321
+ rt.session = payload.session;
322
+ rt.driver.handle(event, payload, nowIso());
323
+ });
324
+ }
325
+ // ── Lifecycle: session_shutdown → Option C (reason-discriminated teardown) ──
326
+ pi.on('session_shutdown', async (payload) => {
327
+ const rt = runtimes.get(workflowId);
328
+ if (!rt)
329
+ return;
330
+ rt.session = null; // switch gap: cue pump stops injecting (dodges Pi #2860)
331
+ if (payload.reason === 'quit') {
332
+ rt.pub.stop(); // 3c — stop observing + flush the trailing coalesce buffer
333
+ rt.reset.stop(); // 3d — stop the reset poll
334
+ try {
335
+ await rt.wf.detach('agent-exited'); // requestDetach + adapterExited + stopHeartbeat
336
+ runtimes.delete(workflowId);
337
+ }
338
+ catch (err) {
339
+ log('quit detach (best-effort) failed — reaper will backstop:', err);
340
+ }
341
+ }
342
+ });
343
+ };
344
+ }
345
+ /**
346
+ * RELIABLE detach for the headless exit sequence (Phase 3a). Headless owns its
347
+ * exit loop, so — unlike interactive's best-effort `quit` path — it can AWAIT a
348
+ * clean detach before disposing the SDK session. Ordering (architect ruling):
349
+ * stopHeartbeat → requestDetach → adapterExited (all inside `wf.detach`) → unmap.
350
+ * The caller then calls `session.dispose()`; the dispose-fired `session_shutdown`
351
+ * finds no mapped runtime → no-op (avoids double-detach). Detaches every runtime
352
+ * in the process (headless = one player per process).
353
+ */
354
+ async function detachAllPiRuntimesForExit() {
355
+ for (const rt of runtimes.values()) {
356
+ rt.pub.stop(); // 3c — stop the inner-loop publisher before detaching
357
+ rt.reset.stop(); // 3d — stop the reset poll before detaching
358
+ try {
359
+ await rt.wf.detach('agent-exited');
360
+ }
361
+ catch (err) {
362
+ log('headless detach failed (reaper will backstop):', err);
363
+ }
364
+ }
365
+ runtimes.clear();
366
+ }
367
+ /**
368
+ * Headless-only: wire the live Pi SDK session onto a runtime so the cue pump can
369
+ * inject into it. The interactive CLI's `session_start` payload carries
370
+ * `session`, but the headless SDK's DEFAULT session_start payload does NOT (it's
371
+ * `{ type, reason }`) — so `attachOrRebind` sets `rt.session = null` and the cue
372
+ * pump's `resolveSession` returns null (every cue is dropped). The headless entry
373
+ * HOLDS the session from `createAgentSession`, so it calls this after
374
+ * `bindExtensions` (by which point the runtime exists + has claimed) to set it.
375
+ * (3a live smoke — devops.)
376
+ */
377
+ function setRuntimeSession(workflowId, session) {
378
+ const rt = runtimes.get(workflowId);
379
+ if (rt) {
380
+ rt.session = session;
381
+ log(`headless session wired to runtime (wf ${workflowId})`);
382
+ }
383
+ else {
384
+ log(`setRuntimeSession: no runtime for ${workflowId} yet (session_start may not have fired)`);
385
+ }
386
+ }
387
+ // ── Test-only hooks (ADR 0006 `__<verb><Noun>ForTests` convention) ──
388
+ /** Override the Temporal connection factory (inject a fake Client). */
389
+ function __setPiClientFactoryForTests(factory) {
390
+ clientFactory = factory;
391
+ }
392
+ /** Stop timers, clear the per-player runtime map + shared-client singletons + factory. */
393
+ function __resetPiRuntimesForTests() {
394
+ for (const rt of runtimes.values()) {
395
+ rt.pub.stop();
396
+ rt.reset.stop();
397
+ rt.pump.stop();
398
+ rt.wf.stopHeartbeat();
399
+ }
400
+ runtimes.clear();
401
+ sharedClientPromise = null;
402
+ connectedClient = null;
403
+ clientFactory = getSharedClient;
404
+ }
405
+ /** Default export — interactive-mode extension (the human `pi` CLI entry). */
406
+ const piExtension = createPiExtension();
407
+ exports.default = piExtension;
@@ -0,0 +1,54 @@
1
+ /** Env var carrying the per-player ingest token (threaded in at spawn, shared w/ inner-loop). */
2
+ export declare const INGEST_TOKEN_ENV = "AGENT_TEMPO_INGEST_TOKEN";
3
+ /** What the handler does with the result. */
4
+ export type GateEffect = 'allow' | 'deny';
5
+ /** Minimal `fetch` shape (injectable for tests) — same contract as the inner-loop client. */
6
+ export type GateFetch = (url: string, init: {
7
+ method: string;
8
+ headers: Record<string, string>;
9
+ }) => Promise<{
10
+ status: number;
11
+ json(): Promise<unknown>;
12
+ }>;
13
+ export interface GateClientOptions {
14
+ ensemble: string;
15
+ playerId: string;
16
+ ingestToken?: string;
17
+ readPort?: () => number | null;
18
+ fetchFn?: GateFetch;
19
+ pollIntervalMs?: number;
20
+ timeoutMs?: number;
21
+ now?: () => number;
22
+ /** Cancellable wait (tests inject a synchronous/controllable one). */
23
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
24
+ }
25
+ /**
26
+ * Loopback poll-bridge to the daemon gate. Construct one per headless player.
27
+ * The `awaitDecision` poll is the only public surface used by the engagement path.
28
+ */
29
+ export declare class GateClient {
30
+ private readonly ensemble;
31
+ private readonly playerId;
32
+ private readonly ingestToken;
33
+ private readonly readPort;
34
+ private readonly fetchFn;
35
+ private readonly pollIntervalMs;
36
+ private readonly timeoutMs;
37
+ private readonly now;
38
+ private readonly sleep;
39
+ constructor(opts: GateClientOptions);
40
+ private get enabled();
41
+ private resolutionUrl;
42
+ /** One poll. Returns the effect on a resolved answer, or null to keep polling. */
43
+ private pollOnce;
44
+ /**
45
+ * Poll the daemon for the operator's decision on `requestId`, blocking until
46
+ * resolved / timeout / abort. FAIL-OPEN: `allow` unless the operator explicitly
47
+ * denied. Without a token/transport (e.g. interactive Pi, daemon HTTP off) →
48
+ * immediate `allow` (the gate is a daemon-mediated feature).
49
+ */
50
+ awaitDecision(requestId: string, opts?: {
51
+ signal?: AbortSignal;
52
+ timeoutMs?: number;
53
+ }): Promise<GateEffect>;
54
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GateClient = exports.INGEST_TOKEN_ENV = void 0;
4
+ /**
5
+ * Gate poll-bridge client (3d / MD-G) — the SUBPROCESS side of the operator gate.
6
+ *
7
+ * When the Pi `tool_call` handler engages the gate, it awaits {@link
8
+ * GateClient.awaitDecision}, which polls the daemon's resolution route
9
+ * (`GET /v1/players/:e/:p/gate/:requestId/resolution`, ingest-token auth, same
10
+ * loopback boundary as the 3c inner-loop) until the operator decides OR the
11
+ * daemon's authoritative 45s auto-allow lands. R1 (resolved): Pi awaits the
12
+ * returned `tool_call` Promise, so this inline poll-await is safe.
13
+ *
14
+ * TWO bounds, BOTH required (architect/conductor):
15
+ * - `signal` (Pi's `ctx.signal`): if the turn is cancelled (Esc/abort), the
16
+ * poll stops immediately and resolves `allow` (the tool won't run anyway —
17
+ * never leave the loop hung; Pi #2381).
18
+ * - `timeoutMs` (default just beyond the daemon's 45s): a safety net for an
19
+ * UNREACHABLE daemon — autonomous-first, so a timeout resolves `allow`.
20
+ * In the normal case the daemon answers first (operator decision, or its lazy
21
+ * 45s auto-allow), so this subprocess deadline is only the daemon-down backstop.
22
+ *
23
+ * Effect mapping: operator `deny` → `deny` (block the tool); `allow` /
24
+ * `auto-allow` / timeout / abort → `allow` (permit). Network/transport errors on
25
+ * a single poll are swallowed (retry next tick); only the bounds end the loop.
26
+ *
27
+ * Client-side ONLY (src/pi). Not imported by workflows.
28
+ */
29
+ const port_file_1 = require("../http/port-file");
30
+ /** Env var carrying the per-player ingest token (threaded in at spawn, shared w/ inner-loop). */
31
+ exports.INGEST_TOKEN_ENV = 'AGENT_TEMPO_INGEST_TOKEN';
32
+ /** Default poll cadence — reuse the inner-loop short-poll so a fresh decision lands within ~1s. */
33
+ const DEFAULT_POLL_MS = 1_000;
34
+ /**
35
+ * Subprocess deadline — slightly beyond the daemon's 45s auto-allow so, when the
36
+ * daemon is reachable, its authoritative answer always wins; this only fires when
37
+ * the daemon is unreachable.
38
+ */
39
+ const DEFAULT_TIMEOUT_MS = 50_000;
40
+ const log = (...args) => {
41
+ // eslint-disable-next-line no-console
42
+ console.error('[agent-tempo:pi]', ...args);
43
+ };
44
+ function resolveFetch() {
45
+ const g = globalThis.fetch;
46
+ return typeof g === 'function' ? g : null;
47
+ }
48
+ /** Default cancellable sleep — resolves after `ms`, or early if `signal` aborts. */
49
+ function defaultSleep(ms, signal) {
50
+ return new Promise((resolve) => {
51
+ if (signal?.aborted) {
52
+ resolve();
53
+ return;
54
+ }
55
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
56
+ const onAbort = () => { clearTimeout(timer); resolve(); };
57
+ signal?.addEventListener('abort', onAbort, { once: true });
58
+ });
59
+ }
60
+ /**
61
+ * Loopback poll-bridge to the daemon gate. Construct one per headless player.
62
+ * The `awaitDecision` poll is the only public surface used by the engagement path.
63
+ */
64
+ class GateClient {
65
+ ensemble;
66
+ playerId;
67
+ ingestToken;
68
+ readPort;
69
+ fetchFn;
70
+ pollIntervalMs;
71
+ timeoutMs;
72
+ now;
73
+ sleep;
74
+ constructor(opts) {
75
+ this.ensemble = opts.ensemble;
76
+ this.playerId = opts.playerId;
77
+ this.ingestToken = opts.ingestToken ?? process.env[exports.INGEST_TOKEN_ENV];
78
+ this.readPort = opts.readPort ?? (() => (0, port_file_1.readPortFile)());
79
+ this.fetchFn = opts.fetchFn ?? resolveFetch();
80
+ this.pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
81
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
82
+ this.now = opts.now ?? Date.now;
83
+ this.sleep = opts.sleep ?? defaultSleep;
84
+ }
85
+ get enabled() {
86
+ return Boolean(this.ingestToken) && this.fetchFn !== null;
87
+ }
88
+ resolutionUrl(port, requestId) {
89
+ return `http://127.0.0.1:${port}/v1/players/` +
90
+ `${encodeURIComponent(this.ensemble)}/${encodeURIComponent(this.playerId)}/gate/` +
91
+ `${encodeURIComponent(requestId)}/resolution`;
92
+ }
93
+ /** One poll. Returns the effect on a resolved answer, or null to keep polling. */
94
+ async pollOnce(requestId) {
95
+ const port = this.readPort();
96
+ if (port == null)
97
+ return null; // daemon HTTP down → keep trying until the bound
98
+ try {
99
+ const res = await this.fetchFn(this.resolutionUrl(port, requestId), {
100
+ method: 'GET',
101
+ headers: { 'X-Ingest-Token': this.ingestToken },
102
+ });
103
+ if (res.status !== 200)
104
+ return null; // 404 (not-yet-registered race) / 403 → retry
105
+ const data = (await res.json());
106
+ if (data.status !== 'resolved')
107
+ return null;
108
+ return data.decision === 'deny' ? 'deny' : 'allow'; // allow + auto-allow → allow
109
+ }
110
+ catch {
111
+ return null; // transport error → retry next tick
112
+ }
113
+ }
114
+ /**
115
+ * Poll the daemon for the operator's decision on `requestId`, blocking until
116
+ * resolved / timeout / abort. FAIL-OPEN: `allow` unless the operator explicitly
117
+ * denied. Without a token/transport (e.g. interactive Pi, daemon HTTP off) →
118
+ * immediate `allow` (the gate is a daemon-mediated feature).
119
+ */
120
+ async awaitDecision(requestId, opts = {}) {
121
+ if (!this.enabled)
122
+ return 'allow';
123
+ const deadline = this.now() + (opts.timeoutMs ?? this.timeoutMs);
124
+ while (this.now() < deadline) {
125
+ if (opts.signal?.aborted)
126
+ return 'allow'; // turn cancelled — don't block a dying turn
127
+ const effect = await this.pollOnce(requestId);
128
+ if (effect !== null)
129
+ return effect;
130
+ await this.sleep(this.pollIntervalMs, opts.signal);
131
+ }
132
+ log(`gate decision timed out for ${requestId} (daemon unreachable?) — auto-allow`);
133
+ return 'allow'; // autonomous-first backstop (daemon-down)
134
+ }
135
+ }
136
+ exports.GateClient = GateClient;