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.
- package/CLAUDE.md +253 -219
- package/LICENSE +21 -21
- package/README.md +293 -289
- package/assets/icon-dark.svg +9 -9
- package/assets/icon.svg +9 -9
- package/assets/logo-dark.svg +11 -11
- package/assets/logo-light.svg +11 -11
- package/dashboard/README.md +91 -91
- package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
- package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
- package/dashboard/dist/index.html +20 -20
- package/dashboard/package.json +47 -47
- package/dist/activities/outbox.d.ts +30 -1
- package/dist/activities/outbox.js +96 -3
- package/dist/adapters/base.js +5 -0
- package/dist/adapters/copilot/adapter.js +12 -1
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/pi/adapter.d.ts +2 -0
- package/dist/adapters/pi/adapter.js +43 -0
- package/dist/adapters/pi/index.d.ts +16 -0
- package/dist/adapters/pi/index.js +10 -0
- package/dist/cli/global-wrapper.d.ts +19 -0
- package/dist/cli/global-wrapper.js +169 -0
- package/dist/cli/help-text.js +97 -97
- package/dist/cli/startup.js +11 -0
- package/dist/cli/upgrade-command.js +81 -81
- package/dist/cli.js +12 -0
- package/dist/client/core.js +9 -2
- package/dist/client/interface.d.ts +6 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +74 -0
- package/dist/daemon.js +37 -1
- package/dist/http/aggregate.d.ts +22 -1
- package/dist/http/aggregate.js +41 -0
- package/dist/http/auth.d.ts +94 -8
- package/dist/http/auth.js +93 -9
- package/dist/http/body.d.ts +4 -1
- package/dist/http/body.js +6 -3
- package/dist/http/event-bus.js +1 -0
- package/dist/http/event-types.d.ts +34 -2
- package/dist/http/event-types.js +1 -0
- package/dist/http/gate-audit.d.ts +12 -0
- package/dist/http/gate-audit.js +95 -0
- package/dist/http/gate-registry.d.ts +167 -0
- package/dist/http/gate-registry.js +163 -0
- package/dist/http/gate-routes.d.ts +48 -0
- package/dist/http/gate-routes.js +102 -0
- package/dist/http/ingest-registry.d.ts +30 -0
- package/dist/http/ingest-registry.js +108 -0
- package/dist/http/inner-loop-routes.d.ts +66 -0
- package/dist/http/inner-loop-routes.js +182 -0
- package/dist/http/inner-loop.d.ts +92 -0
- package/dist/http/inner-loop.js +155 -0
- package/dist/http/server.d.ts +38 -3
- package/dist/http/server.js +211 -6
- package/dist/http/snapshot.d.ts +6 -0
- package/dist/http/snapshot.js +6 -0
- package/dist/pi/cue-pump.d.ts +61 -0
- package/dist/pi/cue-pump.js +95 -0
- package/dist/pi/extension.d.ts +45 -0
- package/dist/pi/extension.js +407 -0
- package/dist/pi/gate-client.d.ts +54 -0
- package/dist/pi/gate-client.js +136 -0
- package/dist/pi/headless.d.ts +85 -0
- package/dist/pi/headless.js +224 -0
- package/dist/pi/index.d.ts +28 -0
- package/dist/pi/index.js +43 -0
- package/dist/pi/inner-loop-client.d.ts +67 -0
- package/dist/pi/inner-loop-client.js +164 -0
- package/dist/pi/inner-loop-publisher.d.ts +187 -0
- package/dist/pi/inner-loop-publisher.js +236 -0
- package/dist/pi/lazy-proxy.d.ts +37 -0
- package/dist/pi/lazy-proxy.js +55 -0
- package/dist/pi/mission-control/actions.d.ts +48 -0
- package/dist/pi/mission-control/actions.js +98 -0
- package/dist/pi/mission-control/board.d.ts +53 -0
- package/dist/pi/mission-control/board.js +104 -0
- package/dist/pi/mission-control/extension.d.ts +44 -0
- package/dist/pi/mission-control/extension.js +251 -0
- package/dist/pi/mission-control/index.d.ts +15 -0
- package/dist/pi/mission-control/index.js +32 -0
- package/dist/pi/mission-control/inner-tail.d.ts +48 -0
- package/dist/pi/mission-control/inner-tail.js +76 -0
- package/dist/pi/mission-control/pi-ui.d.ts +43 -0
- package/dist/pi/mission-control/pi-ui.js +10 -0
- package/dist/pi/mission-control/render.d.ts +6 -0
- package/dist/pi/mission-control/render.js +95 -0
- package/dist/pi/phase-driver.d.ts +74 -0
- package/dist/pi/phase-driver.js +122 -0
- package/dist/pi/pi-types.d.ts +208 -0
- package/dist/pi/pi-types.js +21 -0
- package/dist/pi/probe.d.ts +80 -0
- package/dist/pi/probe.js +154 -0
- package/dist/pi/render-tools.d.ts +17 -0
- package/dist/pi/render-tools.js +51 -0
- package/dist/pi/reset-pump.d.ts +47 -0
- package/dist/pi/reset-pump.js +85 -0
- package/dist/pi/tool-capability.d.ts +60 -0
- package/dist/pi/tool-capability.js +156 -0
- package/dist/pi/workflow-client.d.ts +158 -0
- package/dist/pi/workflow-client.js +289 -0
- package/dist/pi/zod-to-typebox.d.ts +74 -0
- package/dist/pi/zod-to-typebox.js +191 -0
- package/dist/scripts/verify-daemon-isolation-guard.js +24 -24
- package/dist/server-tools.d.ts +2 -0
- package/dist/server-tools.js +50 -46
- package/dist/server.js +4 -0
- package/dist/spawn.d.ts +55 -0
- package/dist/spawn.js +84 -12
- package/dist/tools/agent-types.d.ts +2 -2
- package/dist/tools/agent-types.js +22 -17
- package/dist/tools/attachment-info.d.ts +2 -2
- package/dist/tools/attachment-info.js +38 -33
- package/dist/tools/broadcast.d.ts +2 -2
- package/dist/tools/broadcast.js +69 -64
- package/dist/tools/cancel-stage.d.ts +2 -2
- package/dist/tools/cancel-stage.js +20 -15
- package/dist/tools/clear-state.d.ts +2 -2
- package/dist/tools/clear-state.js +25 -20
- package/dist/tools/coat-check-evict.d.ts +2 -2
- package/dist/tools/coat-check-evict.js +30 -25
- package/dist/tools/coat-check-get.d.ts +2 -2
- package/dist/tools/coat-check-get.js +39 -34
- package/dist/tools/coat-check-list.d.ts +2 -2
- package/dist/tools/coat-check-list.js +48 -43
- package/dist/tools/coat-check-put.d.ts +2 -2
- package/dist/tools/coat-check-put.js +41 -36
- package/dist/tools/cue.d.ts +2 -2
- package/dist/tools/cue.js +57 -52
- package/dist/tools/descriptor.d.ts +72 -0
- package/dist/tools/descriptor.js +39 -0
- package/dist/tools/destroy.d.ts +2 -2
- package/dist/tools/destroy.js +153 -148
- package/dist/tools/ensemble.d.ts +2 -2
- package/dist/tools/ensemble.js +71 -66
- package/dist/tools/evaluate-gate.d.ts +2 -2
- package/dist/tools/evaluate-gate.js +33 -27
- package/dist/tools/fetch-state.d.ts +2 -2
- package/dist/tools/fetch-state.js +43 -38
- package/dist/tools/gates.d.ts +2 -2
- package/dist/tools/gates.js +39 -34
- package/dist/tools/hosts.d.ts +2 -2
- package/dist/tools/hosts.js +25 -20
- package/dist/tools/listen.d.ts +2 -2
- package/dist/tools/listen.js +23 -18
- package/dist/tools/load-lineup.d.ts +2 -2
- package/dist/tools/load-lineup.js +324 -319
- package/dist/tools/migrate.d.ts +2 -2
- package/dist/tools/migrate.js +45 -40
- package/dist/tools/pause.d.ts +2 -2
- package/dist/tools/pause.js +34 -29
- package/dist/tools/play.d.ts +2 -2
- package/dist/tools/play.js +53 -48
- package/dist/tools/quality-gate.d.ts +2 -2
- package/dist/tools/quality-gate.js +26 -21
- package/dist/tools/recall.d.ts +2 -2
- package/dist/tools/recall.js +32 -27
- package/dist/tools/recruit.d.ts +2 -2
- package/dist/tools/recruit.js +325 -256
- package/dist/tools/release.d.ts +2 -2
- package/dist/tools/release.js +85 -80
- package/dist/tools/report.d.ts +2 -2
- package/dist/tools/report.js +28 -23
- package/dist/tools/reset.d.ts +3 -0
- package/dist/tools/reset.js +51 -0
- package/dist/tools/restart.d.ts +2 -2
- package/dist/tools/restart.js +51 -46
- package/dist/tools/restore.d.ts +2 -2
- package/dist/tools/restore.js +76 -71
- package/dist/tools/save-lineup.d.ts +2 -2
- package/dist/tools/save-lineup.js +32 -27
- package/dist/tools/save-state.d.ts +2 -2
- package/dist/tools/save-state.js +43 -38
- package/dist/tools/schedule.d.ts +2 -2
- package/dist/tools/schedule.js +133 -128
- package/dist/tools/schedules.d.ts +2 -2
- package/dist/tools/schedules.js +41 -36
- package/dist/tools/set-ensemble-description.d.ts +2 -2
- package/dist/tools/set-ensemble-description.js +26 -21
- package/dist/tools/set-name.d.ts +2 -2
- package/dist/tools/set-name.js +38 -33
- package/dist/tools/set-part.d.ts +2 -2
- package/dist/tools/set-part.js +20 -15
- package/dist/tools/shutdown.d.ts +2 -2
- package/dist/tools/shutdown.js +39 -34
- package/dist/tools/stage.d.ts +2 -2
- package/dist/tools/stage.js +28 -23
- package/dist/tools/stages.d.ts +2 -2
- package/dist/tools/stages.js +36 -31
- package/dist/tools/unschedule.d.ts +2 -2
- package/dist/tools/unschedule.js +30 -25
- package/dist/tools/who-am-i.d.ts +2 -2
- package/dist/tools/who-am-i.js +36 -31
- package/dist/tools/worktree.d.ts +2 -2
- package/dist/tools/worktree.js +134 -129
- package/dist/tui/index.js +6 -6
- package/dist/types.d.ts +47 -2
- package/dist/types.js +1 -1
- package/dist/utils/default-part.js +1 -0
- package/dist/utils/grpc-shutdown-guard.d.ts +52 -0
- package/dist/utils/grpc-shutdown-guard.js +88 -0
- package/dist/utils/sdk-probe.d.ts +23 -0
- package/dist/utils/sdk-probe.js +46 -7
- package/dist/worker.d.ts +3 -1
- package/dist/worker.js +6 -2
- package/dist/workflows/session.js +70 -2
- package/dist/workflows/signals.d.ts +32 -2
- package/dist/workflows/signals.js +25 -2
- package/examples/agents/tempo-composer.md +56 -56
- package/examples/agents/tempo-conductor.md +117 -117
- package/examples/agents/tempo-critic.md +73 -73
- package/examples/agents/tempo-improv.md +74 -74
- package/examples/agents/tempo-liner.md +75 -75
- package/examples/agents/tempo-roadie.md +61 -61
- package/examples/agents/tempo-soloist.md +71 -71
- package/examples/agents/tempo-tuner.md +94 -94
- package/examples/ensembles/tempo-big-band.yaml +146 -146
- package/examples/ensembles/tempo-dev-team.yaml +58 -58
- package/examples/ensembles/tempo-headless-jam.yaml +77 -77
- package/examples/ensembles/tempo-jam-session.yaml +41 -41
- package/examples/ensembles/tempo-mock-jam.yaml +79 -79
- package/examples/ensembles/tempo-review-squad.yaml +32 -32
- package/package.json +176 -173
- package/packaging/launchd/com.agent.tempo.plist +46 -46
- package/packaging/systemd/agent-tempo.service +32 -32
- package/packaging/windows/install-task.ps1 +71 -71
- package/scenarios/conductor-recruit-mock.yaml +33 -33
- package/scenarios/echo-roundtrip.yaml +15 -15
- package/scenarios/multi-player-handoff.yaml +38 -38
- package/scenarios/recruit-cascade.yaml +38 -38
- package/scenarios/two-player-conversation.yaml +33 -33
- package/workflow-bundle.js +97 -6
- package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
- package/dist/activities/claude-stop.d.ts +0 -21
- package/dist/activities/claude-stop.js +0 -94
- package/dist/channel.d.ts +0 -3
- package/dist/channel.js +0 -48
- package/dist/copilot-bridge.d.ts +0 -22
- package/dist/copilot-bridge.js +0 -565
- package/dist/scripts/258-spotcheck.js +0 -303
- package/dist/tools/detach.d.ts +0 -4
- package/dist/tools/detach.js +0 -45
- package/dist/tools/encore.d.ts +0 -4
- package/dist/tools/encore.js +0 -31
- package/dist/tools/helpers.d.ts +0 -21
- package/dist/tools/helpers.js +0 -25
- package/dist/tools/pause-ensemble.d.ts +0 -4
- package/dist/tools/pause-ensemble.js +0 -58
- package/dist/tools/resume-ensemble.d.ts +0 -4
- package/dist/tools/resume-ensemble.js +0 -79
- package/dist/tools/stop.d.ts +0 -4
- package/dist/tools/stop.js +0 -29
- package/dist/tui/client.d.ts +0 -6
- package/dist/tui/client.js +0 -9
- package/dist/tui/components/ActivityLog.d.ts +0 -16
- package/dist/tui/components/ActivityLog.js +0 -36
- package/dist/tui/components/CommandOverlay.d.ts +0 -15
- package/dist/tui/components/CommandOverlay.js +0 -34
- package/dist/tui/components/ConductorChat.d.ts +0 -16
- package/dist/tui/components/ConductorChat.js +0 -32
- package/dist/tui/components/EnsembleListView.d.ts +0 -14
- package/dist/tui/components/EnsembleListView.js +0 -32
- package/dist/tui/components/EnsemblePanel.d.ts +0 -12
- package/dist/tui/components/EnsemblePanel.js +0 -40
- package/dist/tui/components/InputBar.d.ts +0 -13
- package/dist/tui/components/InputBar.js +0 -58
- package/dist/tui/components/ScheduleOverlay.d.ts +0 -13
- package/dist/tui/components/ScheduleOverlay.js +0 -113
- package/dist/tui/components/TopBar.d.ts +0 -12
- package/dist/tui/components/TopBar.js +0 -15
- package/dist/tui/core-api.d.ts +0 -26
- package/dist/tui/core-api.js +0 -67
- package/dist/tui/hooks/useEnsembleDiscovery.d.ts +0 -3
- package/dist/tui/hooks/useEnsembleDiscovery.js +0 -30
- package/dist/tui/hooks/useMaestroPoller.d.ts +0 -3
- package/dist/tui/hooks/useMaestroPoller.js +0 -36
- package/dist/tui/hooks/useSendCommand.d.ts +0 -7
- package/dist/tui/hooks/useSendCommand.js +0 -29
- package/dist/utils/bg-preflight.d.ts +0 -25
- 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
|
+
}
|