agent-tempo 1.2.0 → 1.4.0

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 (281) hide show
  1. package/CLAUDE.md +253 -219
  2. package/LICENSE +21 -21
  3. package/README.md +293 -289
  4. package/assets/icon-dark.svg +9 -9
  5. package/assets/icon.svg +9 -9
  6. package/assets/logo-dark.svg +11 -11
  7. package/assets/logo-light.svg +11 -11
  8. package/dashboard/README.md +91 -91
  9. package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
  10. package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
  11. package/dashboard/dist/index.html +20 -20
  12. package/dashboard/package.json +47 -47
  13. package/dist/activities/outbox.d.ts +30 -1
  14. package/dist/activities/outbox.js +96 -3
  15. package/dist/adapters/base.js +5 -0
  16. package/dist/adapters/copilot/adapter.js +12 -1
  17. package/dist/adapters/index.d.ts +1 -1
  18. package/dist/adapters/index.js +7 -0
  19. package/dist/adapters/pi/adapter.d.ts +2 -0
  20. package/dist/adapters/pi/adapter.js +43 -0
  21. package/dist/adapters/pi/index.d.ts +16 -0
  22. package/dist/adapters/pi/index.js +10 -0
  23. package/dist/cli/global-wrapper.d.ts +19 -0
  24. package/dist/cli/global-wrapper.js +169 -0
  25. package/dist/cli/help-text.js +97 -97
  26. package/dist/cli/startup.js +11 -0
  27. package/dist/cli/upgrade-command.js +81 -81
  28. package/dist/cli.js +12 -0
  29. package/dist/client/core.js +9 -2
  30. package/dist/client/interface.d.ts +6 -0
  31. package/dist/config.d.ts +79 -0
  32. package/dist/config.js +74 -0
  33. package/dist/daemon.js +37 -1
  34. package/dist/http/aggregate.d.ts +22 -1
  35. package/dist/http/aggregate.js +41 -0
  36. package/dist/http/auth.d.ts +94 -8
  37. package/dist/http/auth.js +93 -9
  38. package/dist/http/body.d.ts +4 -1
  39. package/dist/http/body.js +6 -3
  40. package/dist/http/event-bus.js +1 -0
  41. package/dist/http/event-types.d.ts +34 -2
  42. package/dist/http/event-types.js +1 -0
  43. package/dist/http/gate-audit.d.ts +12 -0
  44. package/dist/http/gate-audit.js +95 -0
  45. package/dist/http/gate-registry.d.ts +167 -0
  46. package/dist/http/gate-registry.js +163 -0
  47. package/dist/http/gate-routes.d.ts +48 -0
  48. package/dist/http/gate-routes.js +102 -0
  49. package/dist/http/ingest-registry.d.ts +30 -0
  50. package/dist/http/ingest-registry.js +108 -0
  51. package/dist/http/inner-loop-routes.d.ts +66 -0
  52. package/dist/http/inner-loop-routes.js +182 -0
  53. package/dist/http/inner-loop.d.ts +92 -0
  54. package/dist/http/inner-loop.js +155 -0
  55. package/dist/http/server.d.ts +38 -3
  56. package/dist/http/server.js +211 -6
  57. package/dist/http/snapshot.d.ts +6 -0
  58. package/dist/http/snapshot.js +6 -0
  59. package/dist/pi/cue-pump.d.ts +61 -0
  60. package/dist/pi/cue-pump.js +95 -0
  61. package/dist/pi/extension.d.ts +45 -0
  62. package/dist/pi/extension.js +407 -0
  63. package/dist/pi/gate-client.d.ts +54 -0
  64. package/dist/pi/gate-client.js +136 -0
  65. package/dist/pi/headless.d.ts +85 -0
  66. package/dist/pi/headless.js +224 -0
  67. package/dist/pi/index.d.ts +28 -0
  68. package/dist/pi/index.js +43 -0
  69. package/dist/pi/inner-loop-client.d.ts +67 -0
  70. package/dist/pi/inner-loop-client.js +164 -0
  71. package/dist/pi/inner-loop-publisher.d.ts +187 -0
  72. package/dist/pi/inner-loop-publisher.js +236 -0
  73. package/dist/pi/lazy-proxy.d.ts +37 -0
  74. package/dist/pi/lazy-proxy.js +55 -0
  75. package/dist/pi/mission-control/actions.d.ts +48 -0
  76. package/dist/pi/mission-control/actions.js +98 -0
  77. package/dist/pi/mission-control/board.d.ts +53 -0
  78. package/dist/pi/mission-control/board.js +104 -0
  79. package/dist/pi/mission-control/extension.d.ts +44 -0
  80. package/dist/pi/mission-control/extension.js +251 -0
  81. package/dist/pi/mission-control/index.d.ts +15 -0
  82. package/dist/pi/mission-control/index.js +32 -0
  83. package/dist/pi/mission-control/inner-tail.d.ts +48 -0
  84. package/dist/pi/mission-control/inner-tail.js +76 -0
  85. package/dist/pi/mission-control/pi-ui.d.ts +43 -0
  86. package/dist/pi/mission-control/pi-ui.js +10 -0
  87. package/dist/pi/mission-control/render.d.ts +6 -0
  88. package/dist/pi/mission-control/render.js +95 -0
  89. package/dist/pi/phase-driver.d.ts +74 -0
  90. package/dist/pi/phase-driver.js +122 -0
  91. package/dist/pi/pi-types.d.ts +208 -0
  92. package/dist/pi/pi-types.js +21 -0
  93. package/dist/pi/probe.d.ts +80 -0
  94. package/dist/pi/probe.js +154 -0
  95. package/dist/pi/render-tools.d.ts +17 -0
  96. package/dist/pi/render-tools.js +51 -0
  97. package/dist/pi/reset-pump.d.ts +47 -0
  98. package/dist/pi/reset-pump.js +85 -0
  99. package/dist/pi/tool-capability.d.ts +60 -0
  100. package/dist/pi/tool-capability.js +156 -0
  101. package/dist/pi/workflow-client.d.ts +158 -0
  102. package/dist/pi/workflow-client.js +289 -0
  103. package/dist/pi/zod-to-typebox.d.ts +74 -0
  104. package/dist/pi/zod-to-typebox.js +191 -0
  105. package/dist/scripts/verify-daemon-isolation-guard.js +24 -24
  106. package/dist/server-tools.d.ts +2 -0
  107. package/dist/server-tools.js +50 -46
  108. package/dist/server.js +4 -0
  109. package/dist/spawn.d.ts +55 -0
  110. package/dist/spawn.js +84 -12
  111. package/dist/tools/agent-types.d.ts +2 -2
  112. package/dist/tools/agent-types.js +22 -17
  113. package/dist/tools/attachment-info.d.ts +2 -2
  114. package/dist/tools/attachment-info.js +38 -33
  115. package/dist/tools/broadcast.d.ts +2 -2
  116. package/dist/tools/broadcast.js +69 -64
  117. package/dist/tools/cancel-stage.d.ts +2 -2
  118. package/dist/tools/cancel-stage.js +20 -15
  119. package/dist/tools/clear-state.d.ts +2 -2
  120. package/dist/tools/clear-state.js +25 -20
  121. package/dist/tools/coat-check-evict.d.ts +2 -2
  122. package/dist/tools/coat-check-evict.js +30 -25
  123. package/dist/tools/coat-check-get.d.ts +2 -2
  124. package/dist/tools/coat-check-get.js +39 -34
  125. package/dist/tools/coat-check-list.d.ts +2 -2
  126. package/dist/tools/coat-check-list.js +48 -43
  127. package/dist/tools/coat-check-put.d.ts +2 -2
  128. package/dist/tools/coat-check-put.js +41 -36
  129. package/dist/tools/cue.d.ts +2 -2
  130. package/dist/tools/cue.js +57 -52
  131. package/dist/tools/descriptor.d.ts +72 -0
  132. package/dist/tools/descriptor.js +39 -0
  133. package/dist/tools/destroy.d.ts +2 -2
  134. package/dist/tools/destroy.js +153 -148
  135. package/dist/tools/ensemble.d.ts +2 -2
  136. package/dist/tools/ensemble.js +71 -66
  137. package/dist/tools/evaluate-gate.d.ts +2 -2
  138. package/dist/tools/evaluate-gate.js +33 -27
  139. package/dist/tools/fetch-state.d.ts +2 -2
  140. package/dist/tools/fetch-state.js +43 -38
  141. package/dist/tools/gates.d.ts +2 -2
  142. package/dist/tools/gates.js +39 -34
  143. package/dist/tools/hosts.d.ts +2 -2
  144. package/dist/tools/hosts.js +25 -20
  145. package/dist/tools/listen.d.ts +2 -2
  146. package/dist/tools/listen.js +23 -18
  147. package/dist/tools/load-lineup.d.ts +2 -2
  148. package/dist/tools/load-lineup.js +324 -319
  149. package/dist/tools/migrate.d.ts +2 -2
  150. package/dist/tools/migrate.js +45 -40
  151. package/dist/tools/pause.d.ts +2 -2
  152. package/dist/tools/pause.js +34 -29
  153. package/dist/tools/play.d.ts +2 -2
  154. package/dist/tools/play.js +53 -48
  155. package/dist/tools/quality-gate.d.ts +2 -2
  156. package/dist/tools/quality-gate.js +26 -21
  157. package/dist/tools/recall.d.ts +2 -2
  158. package/dist/tools/recall.js +32 -27
  159. package/dist/tools/recruit.d.ts +2 -2
  160. package/dist/tools/recruit.js +325 -256
  161. package/dist/tools/release.d.ts +2 -2
  162. package/dist/tools/release.js +85 -80
  163. package/dist/tools/report.d.ts +2 -2
  164. package/dist/tools/report.js +28 -23
  165. package/dist/tools/reset.d.ts +3 -0
  166. package/dist/tools/reset.js +51 -0
  167. package/dist/tools/restart.d.ts +2 -2
  168. package/dist/tools/restart.js +51 -46
  169. package/dist/tools/restore.d.ts +2 -2
  170. package/dist/tools/restore.js +76 -71
  171. package/dist/tools/save-lineup.d.ts +2 -2
  172. package/dist/tools/save-lineup.js +32 -27
  173. package/dist/tools/save-state.d.ts +2 -2
  174. package/dist/tools/save-state.js +43 -38
  175. package/dist/tools/schedule.d.ts +2 -2
  176. package/dist/tools/schedule.js +133 -128
  177. package/dist/tools/schedules.d.ts +2 -2
  178. package/dist/tools/schedules.js +41 -36
  179. package/dist/tools/set-ensemble-description.d.ts +2 -2
  180. package/dist/tools/set-ensemble-description.js +26 -21
  181. package/dist/tools/set-name.d.ts +2 -2
  182. package/dist/tools/set-name.js +38 -33
  183. package/dist/tools/set-part.d.ts +2 -2
  184. package/dist/tools/set-part.js +20 -15
  185. package/dist/tools/shutdown.d.ts +2 -2
  186. package/dist/tools/shutdown.js +39 -34
  187. package/dist/tools/stage.d.ts +2 -2
  188. package/dist/tools/stage.js +28 -23
  189. package/dist/tools/stages.d.ts +2 -2
  190. package/dist/tools/stages.js +36 -31
  191. package/dist/tools/unschedule.d.ts +2 -2
  192. package/dist/tools/unschedule.js +30 -25
  193. package/dist/tools/who-am-i.d.ts +2 -2
  194. package/dist/tools/who-am-i.js +36 -31
  195. package/dist/tools/worktree.d.ts +2 -2
  196. package/dist/tools/worktree.js +134 -129
  197. package/dist/tui/index.js +6 -6
  198. package/dist/types.d.ts +47 -2
  199. package/dist/types.js +1 -1
  200. package/dist/utils/default-part.js +1 -0
  201. package/dist/utils/grpc-shutdown-guard.d.ts +52 -0
  202. package/dist/utils/grpc-shutdown-guard.js +88 -0
  203. package/dist/utils/sdk-probe.d.ts +23 -0
  204. package/dist/utils/sdk-probe.js +46 -7
  205. package/dist/worker.d.ts +3 -1
  206. package/dist/worker.js +6 -2
  207. package/dist/workflows/session.js +70 -2
  208. package/dist/workflows/signals.d.ts +32 -2
  209. package/dist/workflows/signals.js +25 -2
  210. package/examples/agents/tempo-composer.md +56 -56
  211. package/examples/agents/tempo-conductor.md +117 -117
  212. package/examples/agents/tempo-critic.md +73 -73
  213. package/examples/agents/tempo-improv.md +74 -74
  214. package/examples/agents/tempo-liner.md +75 -75
  215. package/examples/agents/tempo-roadie.md +61 -61
  216. package/examples/agents/tempo-soloist.md +71 -71
  217. package/examples/agents/tempo-tuner.md +94 -94
  218. package/examples/ensembles/tempo-big-band.yaml +146 -146
  219. package/examples/ensembles/tempo-dev-team.yaml +58 -58
  220. package/examples/ensembles/tempo-headless-jam.yaml +77 -77
  221. package/examples/ensembles/tempo-jam-session.yaml +41 -41
  222. package/examples/ensembles/tempo-mock-jam.yaml +79 -79
  223. package/examples/ensembles/tempo-review-squad.yaml +32 -32
  224. package/package.json +176 -173
  225. package/packaging/launchd/com.agent.tempo.plist +46 -46
  226. package/packaging/systemd/agent-tempo.service +32 -32
  227. package/packaging/windows/install-task.ps1 +71 -71
  228. package/scenarios/conductor-recruit-mock.yaml +33 -33
  229. package/scenarios/echo-roundtrip.yaml +15 -15
  230. package/scenarios/multi-player-handoff.yaml +38 -38
  231. package/scenarios/recruit-cascade.yaml +38 -38
  232. package/scenarios/two-player-conversation.yaml +33 -33
  233. package/workflow-bundle.js +97 -6
  234. package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
  235. package/dist/activities/claude-stop.d.ts +0 -21
  236. package/dist/activities/claude-stop.js +0 -94
  237. package/dist/channel.d.ts +0 -3
  238. package/dist/channel.js +0 -48
  239. package/dist/copilot-bridge.d.ts +0 -22
  240. package/dist/copilot-bridge.js +0 -565
  241. package/dist/scripts/258-spotcheck.js +0 -303
  242. package/dist/tools/detach.d.ts +0 -4
  243. package/dist/tools/detach.js +0 -45
  244. package/dist/tools/encore.d.ts +0 -4
  245. package/dist/tools/encore.js +0 -31
  246. package/dist/tools/helpers.d.ts +0 -21
  247. package/dist/tools/helpers.js +0 -25
  248. package/dist/tools/pause-ensemble.d.ts +0 -4
  249. package/dist/tools/pause-ensemble.js +0 -58
  250. package/dist/tools/resume-ensemble.d.ts +0 -4
  251. package/dist/tools/resume-ensemble.js +0 -79
  252. package/dist/tools/stop.d.ts +0 -4
  253. package/dist/tools/stop.js +0 -29
  254. package/dist/tui/client.d.ts +0 -6
  255. package/dist/tui/client.js +0 -9
  256. package/dist/tui/components/ActivityLog.d.ts +0 -16
  257. package/dist/tui/components/ActivityLog.js +0 -36
  258. package/dist/tui/components/CommandOverlay.d.ts +0 -15
  259. package/dist/tui/components/CommandOverlay.js +0 -34
  260. package/dist/tui/components/ConductorChat.d.ts +0 -16
  261. package/dist/tui/components/ConductorChat.js +0 -32
  262. package/dist/tui/components/EnsembleListView.d.ts +0 -14
  263. package/dist/tui/components/EnsembleListView.js +0 -32
  264. package/dist/tui/components/EnsemblePanel.d.ts +0 -12
  265. package/dist/tui/components/EnsemblePanel.js +0 -40
  266. package/dist/tui/components/InputBar.d.ts +0 -13
  267. package/dist/tui/components/InputBar.js +0 -58
  268. package/dist/tui/components/ScheduleOverlay.d.ts +0 -13
  269. package/dist/tui/components/ScheduleOverlay.js +0 -113
  270. package/dist/tui/components/TopBar.d.ts +0 -12
  271. package/dist/tui/components/TopBar.js +0 -15
  272. package/dist/tui/core-api.d.ts +0 -26
  273. package/dist/tui/core-api.js +0 -67
  274. package/dist/tui/hooks/useEnsembleDiscovery.d.ts +0 -3
  275. package/dist/tui/hooks/useEnsembleDiscovery.js +0 -30
  276. package/dist/tui/hooks/useMaestroPoller.d.ts +0 -3
  277. package/dist/tui/hooks/useMaestroPoller.js +0 -36
  278. package/dist/tui/hooks/useSendCommand.d.ts +0 -7
  279. package/dist/tui/hooks/useSendCommand.js +0 -29
  280. package/dist/utils/bg-preflight.d.ts +0 -25
  281. package/dist/utils/bg-preflight.js +0 -154
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InnerLoopHttpClient = exports.MAX_FRAME_BYTES = exports.INGEST_TOKEN_ENV = void 0;
4
+ /**
5
+ * Inner-loop HTTP client (3c Part 5) — the PRODUCTION {@link InnerLoopRegistry}
6
+ * impl the {@link InnerLoopPublisher} injects.
7
+ *
8
+ * Why HTTP, not in-process: a headless Pi player is a DETACHED subprocess,
9
+ * separate from the daemon that hosts the real fine-tail registry + SSE
10
+ * side-channel. So `publish` / `subscriberCount` are thin loopback-HTTP calls to
11
+ * the daemon, discovered via the port-file. This client lives on the player side
12
+ * of that boundary; lead owns the daemon endpoints.
13
+ *
14
+ * LOCKED wire contract (lead, Part 4):
15
+ * - Discovery: {@link readPortFile} → daemon HTTP port (`null` ⇒ daemon HTTP
16
+ * not up ⇒ no-op gracefully). Base `http://127.0.0.1:${port}` (loopback only).
17
+ * - Auth: `X-Ingest-Token: <token>` on BOTH calls, from `AGENT_TEMPO_INGEST_TOKEN`
18
+ * (threaded in at spawn). Token unset ⇒ no-op (publish drops, presence ⇒ 0).
19
+ * - POST `/v1/players/:ensemble/:playerId/inner/ingest` — body = the InnerFrame
20
+ * JSON object DIRECTLY (no wrapper), ≤32KB (DOS backstop). 204 ⇒ ok; ANY
21
+ * other status / network error ⇒ DROP the frame, never throw, never block
22
+ * the Pi loop (fire-and-forget).
23
+ * - GET `/v1/players/:ensemble/:playerId/inner/presence` — 200 `{subscribers:number}`;
24
+ * anything else (e.g. 403) ⇒ treat as 0.
25
+ *
26
+ * `subscriberCount` MUST be synchronous (the interface) — it returns a CACHED
27
+ * value, refreshed stale-while-revalidate: each call fires a rate-limited
28
+ * background GET (≤1/`presencePollMs`) and returns the last known count (default
29
+ * 0 until the first GET resolves, and on any failure — fail-safe: unknown ⇒ no
30
+ * forwarding). The publisher additionally rate-limits how often it calls this.
31
+ */
32
+ const port_file_1 = require("../http/port-file");
33
+ /** Env var carrying the per-player ingest token (threaded in at spawn). */
34
+ exports.INGEST_TOKEN_ENV = 'AGENT_TEMPO_INGEST_TOKEN';
35
+ /** DOS backstop — frames over this are dropped (summaries are already ~2KB-truncated). */
36
+ exports.MAX_FRAME_BYTES = 32 * 1024;
37
+ const DEFAULT_PRESENCE_POLL_MS = 1000;
38
+ const log = (...args) => {
39
+ // eslint-disable-next-line no-console
40
+ console.error('[agent-tempo:pi]', ...args);
41
+ };
42
+ /** Default transport — global `fetch` adapted to {@link InnerLoopFetch}, or a no-op. */
43
+ function resolveFetch() {
44
+ const g = globalThis.fetch;
45
+ if (typeof g !== 'function')
46
+ return null;
47
+ return g;
48
+ }
49
+ /**
50
+ * Loopback-HTTP {@link InnerLoopRegistry}. Construct one per headless player with
51
+ * its ensemble + playerId; the publisher calls `publish` / `subscriberCount`.
52
+ * The `workflowId` argument is the player's own and is unused — the URL is built
53
+ * from the ctor ensemble + playerId.
54
+ */
55
+ class InnerLoopHttpClient {
56
+ ensemble;
57
+ playerId;
58
+ ingestToken;
59
+ readPort;
60
+ fetchFn;
61
+ presencePollMs;
62
+ now;
63
+ /** Last presence count from the daemon. 0 until the first GET resolves / on failure. */
64
+ cachedSubscribers = 0;
65
+ /** 3d — last gateArmed flag from the daemon (folded into the presence response). */
66
+ cachedGateArmed = false;
67
+ lastPresenceRefresh = -Infinity;
68
+ constructor(opts) {
69
+ this.ensemble = opts.ensemble;
70
+ this.playerId = opts.playerId;
71
+ this.ingestToken = opts.ingestToken ?? process.env[exports.INGEST_TOKEN_ENV];
72
+ this.readPort = opts.readPort ?? (() => (0, port_file_1.readPortFile)());
73
+ this.fetchFn = opts.fetchFn ?? resolveFetch();
74
+ this.presencePollMs = opts.presencePollMs ?? DEFAULT_PRESENCE_POLL_MS;
75
+ this.now = opts.now ?? Date.now;
76
+ }
77
+ /** Whether the client can talk to the daemon at all (token + transport present). */
78
+ get enabled() {
79
+ return Boolean(this.ingestToken) && this.fetchFn !== null;
80
+ }
81
+ baseUrl(port) {
82
+ return `http://127.0.0.1:${port}/v1/players/` +
83
+ `${encodeURIComponent(this.ensemble)}/${encodeURIComponent(this.playerId)}/inner`;
84
+ }
85
+ /** Fire-and-forget POST of one frame. Drops (never throws/blocks) on any failure. */
86
+ publish(_workflowId, frame) {
87
+ if (!this.enabled)
88
+ return;
89
+ const port = this.readPort();
90
+ if (port == null)
91
+ return; // daemon HTTP not up → drop gracefully
92
+ let body;
93
+ try {
94
+ body = JSON.stringify(frame);
95
+ }
96
+ catch {
97
+ return; // unserializable frame → drop
98
+ }
99
+ if (Buffer.byteLength(body, 'utf8') > exports.MAX_FRAME_BYTES) {
100
+ log(`inner frame ${frame.type} exceeds ${exports.MAX_FRAME_BYTES}B — dropped`);
101
+ return;
102
+ }
103
+ void this.fetchFn(`${this.baseUrl(port)}/ingest`, {
104
+ method: 'POST',
105
+ headers: { 'X-Ingest-Token': this.ingestToken, 'Content-Type': 'application/json' },
106
+ body,
107
+ })
108
+ .then((res) => {
109
+ if (res.status !== 204) {
110
+ // 403 (loopback/token/shape) / 413 (oversize) / other — drop silently.
111
+ }
112
+ })
113
+ .catch(() => { });
114
+ }
115
+ /** Synchronous cached presence (stale-while-revalidate). Default 0 / fail-safe 0. */
116
+ subscriberCount(_workflowId) {
117
+ this.maybeRefreshPresence();
118
+ return this.cachedSubscribers;
119
+ }
120
+ /**
121
+ * 3d — synchronous cached `gateArmed` (same stale-while-revalidate presence GET
122
+ * that feeds subscriberCount; short-poll keeps it within ~1s). Default false /
123
+ * fail-safe false (a missed/failed presence read never spuriously engages the
124
+ * gate). The engagement check reads this together with subscriberCount.
125
+ */
126
+ gateArmed(_workflowId) {
127
+ this.maybeRefreshPresence();
128
+ return this.cachedGateArmed;
129
+ }
130
+ /** Fire a rate-limited background presence GET that updates the cache. */
131
+ maybeRefreshPresence() {
132
+ if (!this.enabled)
133
+ return;
134
+ const t = this.now();
135
+ if (t - this.lastPresenceRefresh < this.presencePollMs)
136
+ return;
137
+ const port = this.readPort();
138
+ if (port == null)
139
+ return;
140
+ this.lastPresenceRefresh = t;
141
+ void this.fetchFn(`${this.baseUrl(port)}/presence`, {
142
+ method: 'GET',
143
+ headers: { 'X-Ingest-Token': this.ingestToken },
144
+ })
145
+ .then(async (res) => {
146
+ if (res.status !== 200) {
147
+ this.cachedSubscribers = 0; // 403/etc → treat as no subscribers
148
+ this.cachedGateArmed = false;
149
+ return;
150
+ }
151
+ try {
152
+ const data = (await res.json());
153
+ this.cachedSubscribers = typeof data.subscribers === 'number' ? data.subscribers : 0;
154
+ this.cachedGateArmed = data.gateArmed === true;
155
+ }
156
+ catch {
157
+ this.cachedSubscribers = 0;
158
+ this.cachedGateArmed = false;
159
+ }
160
+ })
161
+ .catch(() => { this.cachedSubscribers = 0; this.cachedGateArmed = false; });
162
+ }
163
+ }
164
+ exports.InnerLoopHttpClient = InnerLoopHttpClient;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Inner-loop publisher (3c) — the single Pi-source observer of the headless
3
+ * agent loop. It subscribes to `pi.on(...)` events ONCE and routes what it
4
+ * observes two ways, never touching extension.ts (the singleton imports + wires
5
+ * this module on bootstrap):
6
+ *
7
+ * TIER 1 — COARSE, always-on (route A, poll-derived). Maintains a small
8
+ * {@link CoarseState} ({ currentTool, contextTokens?, contextPercent? }) that
9
+ * the heartbeat sender samples via {@link InnerLoopPublisher.getCoarseState}
10
+ * and piggybacks onto the EXISTING heartbeat (additive optional fields — ZERO
11
+ * new Temporal signals). lead's AggregateRunner poll-diffs that metadata into
12
+ * a `player.activity` SSE event. This module never touches the bus or
13
+ * Temporal — it only produces the values.
14
+ *
15
+ * TIER 2 — FINE, on-demand (push). Forwards inner frames to the injected
16
+ * {@link InnerLoopRegistry} — but ONLY when `subscriberCount(workflowId) > 0`
17
+ * (the subscriber-presence gate: zero watchers ⇒ zero forwarding ⇒ no
18
+ * firehose). This module owns: source coalescing of thinking/text deltas
19
+ * (flush on a timer OR a char threshold, bounding wire rate regardless of LLM
20
+ * token speed), the presence gate (rate-limited so it can't hammer the
21
+ * registry), and ~2KB summary truncation (never forward raw file/Bash output
22
+ * inline — privacy + volume). The registry owns per-subscriber backpressure
23
+ * (drop-oldest + `compacted{dropped:N}`).
24
+ *
25
+ * TOKENS ARE PULL-ONLY (Pi 0.78): there is no token event. Usage comes from the
26
+ * handler's 2nd arg `ctx.getContextUsage()` (sampled at `turn_end`), surfaced as
27
+ * context-window PRESSURE (tokens + percent) — the useful operational signal.
28
+ *
29
+ * Cross-process note: the headless Pi player is a DETACHED subprocess, separate
30
+ * from the daemon that hosts the real registry — so the production
31
+ * {@link InnerLoopRegistry} impl is a thin loopback-HTTP client, NOT an
32
+ * in-process call. This module is coded against the 2-method interface (DI) so
33
+ * it's unit-testable without the HTTP layer; the real client is wired at
34
+ * integration.
35
+ */
36
+ import type { ExtensionAPI, PiExtensionContext, PiMessageUpdatePayload, PiToolCallEvent, PiToolExecutionStartPayload, PiToolExecutionEndPayload, PiTurnPayload } from './pi-types';
37
+ /**
38
+ * A fine-tail frame (Tier 2). Matches lead's InnerLoopRegistry frame schema.
39
+ *
40
+ * 3d adds the two MD-G operator-gate frames. They ride the SAME /inner stream the
41
+ * operator already watches (per-player, so workflowId/playerId are implicit):
42
+ * - `inner.gate_pending` — emitted by the Pi tool_call handler when the gate
43
+ * engages; the ingest route's side-effect registers the pending in the
44
+ * GateRegistry (the "engagement IS registration" path). `argsSummary` is
45
+ * source-truncated (~2KB). `timeoutMs` lets the operator UI render a countdown.
46
+ * - `inner.gate_resolved` — emitted by the GateRegistry (via an injected
47
+ * publishToInner callback) when a decision lands (operator) or the 45s
48
+ * auto-allow fires (timeout), so the operator sees the outcome.
49
+ */
50
+ export type InnerFrame = {
51
+ type: 'inner.thinking';
52
+ delta: string;
53
+ kind: 'thinking' | 'text';
54
+ } | {
55
+ type: 'inner.tool_call';
56
+ tool: string;
57
+ argsSummary: string;
58
+ ts: number;
59
+ } | {
60
+ type: 'inner.tool_result';
61
+ tool: string;
62
+ resultSummary: string;
63
+ isError: boolean;
64
+ ts: number;
65
+ } | {
66
+ type: 'inner.token';
67
+ contextTokens?: number;
68
+ contextPercent?: number;
69
+ } | {
70
+ type: 'inner.turn';
71
+ phase: 'start' | 'end';
72
+ turnIndex: number;
73
+ ts: number;
74
+ } | {
75
+ type: 'inner.gate_pending';
76
+ requestId: string;
77
+ tool: string;
78
+ argsSummary: string;
79
+ classification: 'exec' | 'high-blast';
80
+ timeoutMs: number;
81
+ ts: number;
82
+ } | {
83
+ type: 'inner.gate_resolved';
84
+ requestId: string;
85
+ decision: 'allow' | 'deny' | 'auto-allow';
86
+ source: 'operator' | 'timeout';
87
+ ts: number;
88
+ };
89
+ /**
90
+ * The daemon-side fine-tail sink (lead's `http/inner-loop.ts`). Injected so the
91
+ * publisher is unit-testable without the HTTP layer; production is a thin
92
+ * loopback-HTTP client to the daemon.
93
+ */
94
+ export interface InnerLoopRegistry {
95
+ publish(workflowId: string, frame: InnerFrame): void;
96
+ /** Live fine-tail subscriber count for this player — the presence gate reads it. */
97
+ subscriberCount(workflowId: string): number;
98
+ }
99
+ /** Coarse always-on state the heartbeat sender samples (Tier 1). */
100
+ export interface CoarseState {
101
+ /** Tool currently executing, or null when idle between tools. */
102
+ currentTool: string | null;
103
+ /** Context tokens in use (pressure), when known. */
104
+ contextTokens?: number;
105
+ /** Context-window usage fraction/percent, when known. */
106
+ contextPercent?: number;
107
+ }
108
+ export interface InnerLoopPublisherOptions {
109
+ /** The player's fixed session workflowId (the registry key). */
110
+ workflowId: string;
111
+ /** Fine-tail sink (DI). */
112
+ registry: InnerLoopRegistry;
113
+ /** Max chars for an args/result summary before truncation (default 2048 ≈ 2KB). */
114
+ maxSummaryChars?: number;
115
+ /** Flush a coalesced thinking/text buffer after this many chars (default 2048). */
116
+ coalesceChars?: number;
117
+ /** Flush a coalesced thinking/text buffer this long after the first buffered delta (default 100ms). */
118
+ coalesceMs?: number;
119
+ /** Re-check `subscriberCount` at most this often (default 1000ms) — bounds registry calls. */
120
+ presencePollMs?: number;
121
+ /** Injected clock (tests). Defaults to `Date.now`. */
122
+ now?: () => number;
123
+ }
124
+ /** Truncate to `maxChars` with a marker — keeps a fine-tail summary bounded. */
125
+ export declare function truncateSummary(value: unknown, maxChars?: number): string;
126
+ /**
127
+ * Observes the Pi inner loop and routes coarse state + fine frames. Construct
128
+ * one per headless player; call {@link start} with the extension's `pi` on
129
+ * bootstrap and {@link stop} on teardown. Handler methods are public so unit
130
+ * tests drive them directly (no live Pi) — mirroring the CuePump test pattern.
131
+ */
132
+ export declare class InnerLoopPublisher {
133
+ private readonly workflowId;
134
+ private readonly registry;
135
+ private readonly maxSummaryChars;
136
+ private readonly coalesceChars;
137
+ private readonly coalesceMs;
138
+ private readonly presencePollMs;
139
+ private readonly now;
140
+ /** Tier-1 coarse state, sampled by the heartbeat sender. */
141
+ private coarse;
142
+ /** Coalesce buffer for thinking/text deltas (single buffer, flushed on kind-switch). */
143
+ private pending;
144
+ private flushTimer;
145
+ /** Rate-limited presence cache (avoids per-frame registry calls). */
146
+ private presentCached;
147
+ private lastPresenceCheck;
148
+ constructor(opts: InnerLoopPublisherOptions);
149
+ /** Register the Pi event handlers and start the coalesce flush timer. */
150
+ start(pi: ExtensionAPI): void;
151
+ /** Tear down the flush timer (flushing any trailing buffer first). */
152
+ stop(): void;
153
+ /** Tier-1: the current coarse state, sampled by the heartbeat sender. */
154
+ getCoarseState(): CoarseState;
155
+ /**
156
+ * Stream delta → coalesce into the pending thinking/text buffer. Thinking/text
157
+ * is PURELY fine-tail (no coarse state), so the presence gate goes BEFORE
158
+ * buffering — when nobody's watching we do zero per-delta work on this hot path.
159
+ */
160
+ handleMessageUpdate(payload: PiMessageUpdatePayload): void;
161
+ /** Tool started → set coarse currentTool. (No fine frame here; tool_call carries args.) */
162
+ handleToolStart(payload: PiToolExecutionStartPayload): void;
163
+ /**
164
+ * Tool finished → clear coarse currentTool (Tier-1, ALWAYS) + forward a fine
165
+ * tool_result frame. The presence gate goes BEFORE `truncateSummary` so a large
166
+ * tool result is never JSON-stringified when nobody's tailing.
167
+ */
168
+ handleToolEnd(payload: PiToolExecutionEndPayload): void;
169
+ /** Pre-exec tool_call → forward a fine tool_call frame (args summarized). */
170
+ handleToolCall(payload: PiToolCallEvent): void;
171
+ /** Turn began → forward a fine turn frame. */
172
+ handleTurnStart(payload: PiTurnPayload): void;
173
+ /**
174
+ * Turn ended → flush pending deltas, sample context usage (the ONLY token
175
+ * source, pull-only), update coarse state, and forward turn + token frames.
176
+ */
177
+ handleTurnEnd(payload: PiTurnPayload, ctx?: PiExtensionContext): void;
178
+ /** Pull context usage from the handler ctx and fold it into coarse state. */
179
+ private sampleUsage;
180
+ private appendDelta;
181
+ /** Emit the buffered thinking/text as one inner.thinking frame (presence-gated). */
182
+ flushPending(): void;
183
+ /** Rate-limited presence check — caches `subscriberCount > 0` per presencePollMs. */
184
+ private isPresent;
185
+ /** Forward a fine frame iff a subscriber is present (the firehose gate). */
186
+ private forward;
187
+ }
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InnerLoopPublisher = void 0;
4
+ exports.truncateSummary = truncateSummary;
5
+ const DEFAULT_MAX_SUMMARY_CHARS = 2048;
6
+ const DEFAULT_COALESCE_CHARS = 2048;
7
+ const DEFAULT_COALESCE_MS = 100;
8
+ const DEFAULT_PRESENCE_POLL_MS = 1000;
9
+ const TRUNCATION_MARKER = '…[truncated]';
10
+ const log = (...args) => {
11
+ // eslint-disable-next-line no-console
12
+ console.error('[agent-tempo:pi]', ...args);
13
+ };
14
+ /** Stringify an arbitrary tool arg/result for a summary (compact, never throws). */
15
+ function stringifySummary(value) {
16
+ if (value == null)
17
+ return '';
18
+ if (typeof value === 'string')
19
+ return value;
20
+ try {
21
+ return JSON.stringify(value);
22
+ }
23
+ catch {
24
+ return String(value);
25
+ }
26
+ }
27
+ /** Truncate to `maxChars` with a marker — keeps a fine-tail summary bounded. */
28
+ function truncateSummary(value, maxChars = DEFAULT_MAX_SUMMARY_CHARS) {
29
+ const s = stringifySummary(value);
30
+ if (s.length <= maxChars)
31
+ return s;
32
+ return s.slice(0, Math.max(0, maxChars - TRUNCATION_MARKER.length)) + TRUNCATION_MARKER;
33
+ }
34
+ /**
35
+ * Observes the Pi inner loop and routes coarse state + fine frames. Construct
36
+ * one per headless player; call {@link start} with the extension's `pi` on
37
+ * bootstrap and {@link stop} on teardown. Handler methods are public so unit
38
+ * tests drive them directly (no live Pi) — mirroring the CuePump test pattern.
39
+ */
40
+ class InnerLoopPublisher {
41
+ workflowId;
42
+ registry;
43
+ maxSummaryChars;
44
+ coalesceChars;
45
+ coalesceMs;
46
+ presencePollMs;
47
+ now;
48
+ /** Tier-1 coarse state, sampled by the heartbeat sender. */
49
+ coarse = { currentTool: null };
50
+ /** Coalesce buffer for thinking/text deltas (single buffer, flushed on kind-switch). */
51
+ pending = null;
52
+ flushTimer = null;
53
+ /** Rate-limited presence cache (avoids per-frame registry calls). */
54
+ presentCached = false;
55
+ lastPresenceCheck = -Infinity;
56
+ constructor(opts) {
57
+ this.workflowId = opts.workflowId;
58
+ this.registry = opts.registry;
59
+ this.maxSummaryChars = opts.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS;
60
+ this.coalesceChars = opts.coalesceChars ?? DEFAULT_COALESCE_CHARS;
61
+ this.coalesceMs = opts.coalesceMs ?? DEFAULT_COALESCE_MS;
62
+ this.presencePollMs = opts.presencePollMs ?? DEFAULT_PRESENCE_POLL_MS;
63
+ this.now = opts.now ?? Date.now;
64
+ }
65
+ /** Register the Pi event handlers and start the coalesce flush timer. */
66
+ start(pi) {
67
+ pi.on('message_update', (payload) => this.handleMessageUpdate(payload));
68
+ pi.on('tool_call', (payload) => this.handleToolCall(payload));
69
+ pi.on('tool_execution_start', (payload) => this.handleToolStart(payload));
70
+ pi.on('tool_execution_end', (payload) => this.handleToolEnd(payload));
71
+ pi.on('turn_start', (payload) => this.handleTurnStart(payload));
72
+ pi.on('turn_end', (payload, ctx) => this.handleTurnEnd(payload, ctx));
73
+ if (!this.flushTimer) {
74
+ this.flushTimer = setInterval(() => this.flushPending(), this.coalesceMs);
75
+ if (typeof this.flushTimer.unref === 'function')
76
+ this.flushTimer.unref();
77
+ }
78
+ }
79
+ /** Tear down the flush timer (flushing any trailing buffer first). */
80
+ stop() {
81
+ this.flushPending();
82
+ if (this.flushTimer) {
83
+ clearInterval(this.flushTimer);
84
+ this.flushTimer = null;
85
+ }
86
+ }
87
+ /** Tier-1: the current coarse state, sampled by the heartbeat sender. */
88
+ getCoarseState() {
89
+ return { ...this.coarse };
90
+ }
91
+ // ── Pi event handlers (public for unit tests) ──────────────────────────────
92
+ /**
93
+ * Stream delta → coalesce into the pending thinking/text buffer. Thinking/text
94
+ * is PURELY fine-tail (no coarse state), so the presence gate goes BEFORE
95
+ * buffering — when nobody's watching we do zero per-delta work on this hot path.
96
+ */
97
+ handleMessageUpdate(payload) {
98
+ const ev = payload.assistantMessageEvent;
99
+ if (!ev || typeof ev.delta !== 'string' || ev.delta.length === 0)
100
+ return;
101
+ const kind = ev.type === 'text_delta' ? 'text' : ev.type === 'thinking_delta' ? 'thinking' : null;
102
+ if (kind === null)
103
+ return; // ignore toolcall_delta and any other variant
104
+ if (!this.isPresent())
105
+ return; // no subscriber → don't buffer fine-tail text
106
+ this.appendDelta(kind, ev.delta);
107
+ }
108
+ /** Tool started → set coarse currentTool. (No fine frame here; tool_call carries args.) */
109
+ handleToolStart(payload) {
110
+ if (typeof payload.toolName === 'string' && payload.toolName.length > 0) {
111
+ this.coarse.currentTool = payload.toolName;
112
+ }
113
+ }
114
+ /**
115
+ * Tool finished → clear coarse currentTool (Tier-1, ALWAYS) + forward a fine
116
+ * tool_result frame. The presence gate goes BEFORE `truncateSummary` so a large
117
+ * tool result is never JSON-stringified when nobody's tailing.
118
+ */
119
+ handleToolEnd(payload) {
120
+ this.coarse.currentTool = null; // always-on coarse update — never gated
121
+ if (!this.isPresent())
122
+ return; // gate before the (potentially large) stringify
123
+ this.forward({
124
+ type: 'inner.tool_result',
125
+ tool: typeof payload.toolName === 'string' ? payload.toolName : '',
126
+ resultSummary: truncateSummary(payload.result, this.maxSummaryChars),
127
+ isError: payload.isError === true,
128
+ ts: this.now(),
129
+ });
130
+ }
131
+ /** Pre-exec tool_call → forward a fine tool_call frame (args summarized). */
132
+ handleToolCall(payload) {
133
+ if (!this.isPresent())
134
+ return; // gate before stringifying args
135
+ this.forward({
136
+ type: 'inner.tool_call',
137
+ tool: typeof payload.toolName === 'string' ? payload.toolName : '',
138
+ argsSummary: truncateSummary(payload.input, this.maxSummaryChars),
139
+ ts: this.now(),
140
+ });
141
+ }
142
+ /** Turn began → forward a fine turn frame. */
143
+ handleTurnStart(payload) {
144
+ this.forward({ type: 'inner.turn', phase: 'start', turnIndex: payload.turnIndex ?? -1, ts: this.now() });
145
+ }
146
+ /**
147
+ * Turn ended → flush pending deltas, sample context usage (the ONLY token
148
+ * source, pull-only), update coarse state, and forward turn + token frames.
149
+ */
150
+ handleTurnEnd(payload, ctx) {
151
+ this.flushPending();
152
+ this.sampleUsage(ctx);
153
+ this.forward({ type: 'inner.turn', phase: 'end', turnIndex: payload.turnIndex ?? -1, ts: this.now() });
154
+ if (this.coarse.contextTokens !== undefined || this.coarse.contextPercent !== undefined) {
155
+ this.forward({
156
+ type: 'inner.token',
157
+ ...(this.coarse.contextTokens !== undefined ? { contextTokens: this.coarse.contextTokens } : {}),
158
+ ...(this.coarse.contextPercent !== undefined ? { contextPercent: this.coarse.contextPercent } : {}),
159
+ });
160
+ }
161
+ }
162
+ /** Pull context usage from the handler ctx and fold it into coarse state. */
163
+ sampleUsage(ctx) {
164
+ if (!ctx || typeof ctx.getContextUsage !== 'function')
165
+ return;
166
+ let usage;
167
+ try {
168
+ usage = ctx.getContextUsage();
169
+ }
170
+ catch (err) {
171
+ log('getContextUsage failed:', err);
172
+ return;
173
+ }
174
+ if (!usage)
175
+ return;
176
+ if (typeof usage.tokens === 'number')
177
+ this.coarse.contextTokens = usage.tokens;
178
+ if (typeof usage.percent === 'number')
179
+ this.coarse.contextPercent = usage.percent;
180
+ }
181
+ // ── Coalescing (source-side; bounds wire rate) ─────────────────────────────
182
+ appendDelta(kind, delta) {
183
+ if (this.pending && this.pending.kind !== kind) {
184
+ // Kind switched (thinking→text or vice versa) — flush to preserve order.
185
+ this.flushPending();
186
+ }
187
+ if (!this.pending) {
188
+ this.pending = { kind, text: '', startedAt: this.now() };
189
+ }
190
+ this.pending.text += delta;
191
+ // The char-threshold flush also CAPS the `+=` concat window — the buffer
192
+ // never grows past ~coalesceChars before being cut loose, so repeated
193
+ // appends stay bounded regardless of total output length. Don't remove it.
194
+ if (this.pending.text.length >= this.coalesceChars) {
195
+ this.flushPending();
196
+ }
197
+ }
198
+ /** Emit the buffered thinking/text as one inner.thinking frame (presence-gated). */
199
+ flushPending() {
200
+ const p = this.pending;
201
+ if (!p || p.text.length === 0) {
202
+ this.pending = null;
203
+ return;
204
+ }
205
+ this.pending = null;
206
+ this.forward({ type: 'inner.thinking', delta: p.text, kind: p.kind });
207
+ }
208
+ // ── Presence gate + forward ────────────────────────────────────────────────
209
+ /** Rate-limited presence check — caches `subscriberCount > 0` per presencePollMs. */
210
+ isPresent() {
211
+ const t = this.now();
212
+ if (t - this.lastPresenceCheck >= this.presencePollMs) {
213
+ this.lastPresenceCheck = t;
214
+ try {
215
+ this.presentCached = this.registry.subscriberCount(this.workflowId) > 0;
216
+ }
217
+ catch (err) {
218
+ log('subscriberCount failed (treating as no subscribers):', err);
219
+ this.presentCached = false;
220
+ }
221
+ }
222
+ return this.presentCached;
223
+ }
224
+ /** Forward a fine frame iff a subscriber is present (the firehose gate). */
225
+ forward(frame) {
226
+ if (!this.isPresent())
227
+ return;
228
+ try {
229
+ this.registry.publish(this.workflowId, frame);
230
+ }
231
+ catch (err) {
232
+ log(`publish ${frame.type} failed:`, err);
233
+ }
234
+ }
235
+ }
236
+ exports.InnerLoopPublisher = InnerLoopPublisher;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Lazy resolution proxy (D11 — Phase 2).
3
+ *
4
+ * The Pi extension registers the FULL tool surface ONCE at extension load (Pi
5
+ * tools are registered up-front, not per session), yet each tool's neutral
6
+ * handler needs the player's live Temporal `Client` and session `WorkflowHandle`
7
+ * — which only exist AFTER the extension connects and claims a session workflow
8
+ * on `session_start`, and which are RE-ACQUIRED on a SessionManager switch
9
+ * (newSession / continueSession / fork) without caching across the switch.
10
+ *
11
+ * `createLazyProxy` bridges that gap: it returns an object that forwards every
12
+ * property access and method call to a target resolved FRESH per access via the
13
+ * `resolve` callback. So `buildAllTempoTools(opts)` can be built once at load
14
+ * with proxy `client` / `handle`, and each handler — invoked only during a live
15
+ * session — transparently hits the CURRENT client/handle.
16
+ *
17
+ * Additive to Phase 1: the proxy IS a `Client` / `WorkflowHandle` structurally,
18
+ * so the descriptor contract (`build*Tool(...)`) and the MCP renderer are
19
+ * unchanged — MCP passes a concrete handle, Pi passes a lazily-resolved one.
20
+ *
21
+ * Determinism note: client-side only (src/pi).
22
+ */
23
+ /**
24
+ * Wrap a lazily-resolved target behind a transparent proxy.
25
+ *
26
+ * @param resolve Returns the current target, or `null`/`undefined` when none is
27
+ * active yet (e.g. before the first session attaches).
28
+ * @param label Human-readable name used in the "unavailable" error.
29
+ *
30
+ * Every property read resolves the target first:
31
+ * - methods are returned bound to the live target,
32
+ * - non-function properties (e.g. `client.workflow`) are returned as-is from
33
+ * the live target,
34
+ * - if no target is active, access throws a clear error (the calling tool
35
+ * handler catches it and returns a `fail(...)` result).
36
+ */
37
+ export declare function createLazyProxy<T extends object>(resolve: () => T | null | undefined, label: string): T;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /**
3
+ * Lazy resolution proxy (D11 — Phase 2).
4
+ *
5
+ * The Pi extension registers the FULL tool surface ONCE at extension load (Pi
6
+ * tools are registered up-front, not per session), yet each tool's neutral
7
+ * handler needs the player's live Temporal `Client` and session `WorkflowHandle`
8
+ * — which only exist AFTER the extension connects and claims a session workflow
9
+ * on `session_start`, and which are RE-ACQUIRED on a SessionManager switch
10
+ * (newSession / continueSession / fork) without caching across the switch.
11
+ *
12
+ * `createLazyProxy` bridges that gap: it returns an object that forwards every
13
+ * property access and method call to a target resolved FRESH per access via the
14
+ * `resolve` callback. So `buildAllTempoTools(opts)` can be built once at load
15
+ * with proxy `client` / `handle`, and each handler — invoked only during a live
16
+ * session — transparently hits the CURRENT client/handle.
17
+ *
18
+ * Additive to Phase 1: the proxy IS a `Client` / `WorkflowHandle` structurally,
19
+ * so the descriptor contract (`build*Tool(...)`) and the MCP renderer are
20
+ * unchanged — MCP passes a concrete handle, Pi passes a lazily-resolved one.
21
+ *
22
+ * Determinism note: client-side only (src/pi).
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.createLazyProxy = createLazyProxy;
26
+ /**
27
+ * Wrap a lazily-resolved target behind a transparent proxy.
28
+ *
29
+ * @param resolve Returns the current target, or `null`/`undefined` when none is
30
+ * active yet (e.g. before the first session attaches).
31
+ * @param label Human-readable name used in the "unavailable" error.
32
+ *
33
+ * Every property read resolves the target first:
34
+ * - methods are returned bound to the live target,
35
+ * - non-function properties (e.g. `client.workflow`) are returned as-is from
36
+ * the live target,
37
+ * - if no target is active, access throws a clear error (the calling tool
38
+ * handler catches it and returns a `fail(...)` result).
39
+ */
40
+ function createLazyProxy(resolve, label) {
41
+ return new Proxy({}, {
42
+ get(_target, prop) {
43
+ const live = resolve();
44
+ if (!live) {
45
+ throw new Error(`Pi: ${label} unavailable — no active session is attached`);
46
+ }
47
+ const value = live[prop];
48
+ return typeof value === 'function' ? value.bind(live) : value;
49
+ },
50
+ has(_target, prop) {
51
+ const live = resolve();
52
+ return live ? prop in live : false;
53
+ },
54
+ });
55
+ }