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.
- package/CLAUDE.md +39 -5
- package/README.md +6 -2
- 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 +1 -1
- package/dashboard/package.json +1 -1
- 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/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/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 +32 -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 +250 -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 +88 -0
- package/dist/pi/mission-control/board.js +141 -0
- package/dist/pi/mission-control/extension.d.ts +51 -0
- package/dist/pi/mission-control/extension.js +330 -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 +98 -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 +222 -0
- package/dist/pi/pi-types.js +21 -0
- package/dist/pi/probe.d.ts +99 -0
- package/dist/pi/probe.js +179 -0
- package/dist/pi/render-tools.d.ts +17 -0
- package/dist/pi/render-tools.js +56 -0
- package/dist/pi/reset-pump.d.ts +47 -0
- package/dist/pi/reset-pump.js +85 -0
- package/dist/pi/session-seed.d.ts +74 -0
- package/dist/pi/session-seed.js +103 -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/server-tools.d.ts +2 -0
- package/dist/server-tools.js +50 -46
- package/dist/spawn.d.ts +55 -0
- package/dist/spawn.js +72 -0
- 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 +29 -24
- package/dist/tools/coat-check-get.d.ts +2 -2
- package/dist/tools/coat-check-get.js +38 -33
- 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 +38 -33
- 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 +42 -37
- 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 +340 -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 +31 -26
- 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/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/package.json +4 -1
- package/workflow-bundle.js +97 -6
- package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
- package/dist/tools/helpers.d.ts +0 -21
- package/dist/tools/helpers.js +0 -25
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Pi runtime (Phase 3a). Spawned by the daemon (spawnPiHeadless →
|
|
3
|
+
* src/adapters/pi/adapter.ts) for a recruited `agent: 'pi'` player. No human, no
|
|
4
|
+
* terminal: it constructs Pi's `createAgentSession` with the agent-tempo
|
|
5
|
+
* extension injected INLINE, and the module-scope extension singleton
|
|
6
|
+
* (createPiExtension, mode='headless') owns claim/heartbeat/tool-registration/
|
|
7
|
+
* cue-pump on `session_start`. Reuses ~everything from Phases 1–2.
|
|
8
|
+
*
|
|
9
|
+
* Lifecycle:
|
|
10
|
+
* 1. probe Pi SDK; resolve the model via pi-ai `getModel` — a bad/unindexed
|
|
11
|
+
* model fails CLEAN (exit before attach, no orphan — architect's backstop).
|
|
12
|
+
* 2. createAgentSession({ resourceLoader: DefaultResourceLoader({
|
|
13
|
+
* extensionFactories: [ext] }), model? }).
|
|
14
|
+
* 3. await session.bindExtensions({}) → fires session_start → the singleton
|
|
15
|
+
* attaches (claim + heartbeat + tools + cue pump). bindExtensions IS the
|
|
16
|
+
* explicit bootstrap (not "hope session_start fires").
|
|
17
|
+
* 4. stay alive until a shutdown signal (SIGTERM/SIGINT).
|
|
18
|
+
* 5. RELIABLE detach (headless owns the exit): detachAllPiRuntimesForExit()
|
|
19
|
+
* [await adapterExited] → session.dispose() → process exit.
|
|
20
|
+
*
|
|
21
|
+
* ESM note: the Pi SDK is an ESM-only optional dep; we import it via a
|
|
22
|
+
* `Function`-wrapped dynamic `import()` so tsc (module=commonjs) doesn't
|
|
23
|
+
* downlevel it to `require()` — Node resolves the real ESM module at runtime.
|
|
24
|
+
*
|
|
25
|
+
* Determinism boundary: client-side only.
|
|
26
|
+
*/
|
|
27
|
+
import { type Config } from '../config';
|
|
28
|
+
import { type PiToolAccess } from './extension';
|
|
29
|
+
export interface RunHeadlessPiOptions {
|
|
30
|
+
config?: Config;
|
|
31
|
+
toolAccess?: PiToolAccess;
|
|
32
|
+
/** `provider/model` selector; absent → Pi default. */
|
|
33
|
+
model?: string;
|
|
34
|
+
/** Restart-resume: prior Pi conversation id (A4 wires SessionManager). */
|
|
35
|
+
continueSessionId?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the `DefaultResourceLoader` options for a headless Pi player.
|
|
39
|
+
*
|
|
40
|
+
* SECURITY — S2 (MD-C deny-list soundness). The `restricted` tool gate is a
|
|
41
|
+
* DENY-LIST over shell/exec tool *names* (tool-capability.ts EXEC_TOOLS, via
|
|
42
|
+
* `classify(name) === 'exec'` — F1 replaced extension.ts's former local set). That
|
|
43
|
+
* guarantee — "restricted = no host execution" — holds ONLY IF no third-party
|
|
44
|
+
* extension can register an un-blacklisted execution tool (e.g. a custom
|
|
45
|
+
* `python` / `npm` / `run` tool). It therefore depends on a hard structural
|
|
46
|
+
* fact: which extensions Pi loads.
|
|
47
|
+
*
|
|
48
|
+
* Verified against the installed Pi SDK 0.78 source (NOT assumed):
|
|
49
|
+
* - `DefaultResourceLoader.reload()` (resource-loader.js:271-276) builds
|
|
50
|
+
* `extensionPaths = noExtensions ? cliEnabledExtensions
|
|
51
|
+
* : merge(cliEnabledExtensions, enabledExtensions)`
|
|
52
|
+
* where `enabledExtensions` (line 229) are the DISK/package extensions from
|
|
53
|
+
* `packageManager.resolve()` (`~/.pi/agent/extensions/`, `<cwd>/.pi/extensions/`,
|
|
54
|
+
* installed packages). `loadExtensions(extensionPaths)` then loads them and
|
|
55
|
+
* MERGES with our inline factories (lines 274-276).
|
|
56
|
+
* - `noExtensions` defaults to `false` (constructor, line 132) — so the naive
|
|
57
|
+
* loader DOES load disk extensions. That is the S2 gap.
|
|
58
|
+
*
|
|
59
|
+
* Fix (= security's "exclude the extensions dir", done structurally):
|
|
60
|
+
* - `noExtensions: true` → `extensionPaths` collapses to `cliEnabledExtensions`,
|
|
61
|
+
* which is empty because we pass NO `additionalExtensionPaths`. So
|
|
62
|
+
* `loadExtensions([])` registers nothing from disk/packages.
|
|
63
|
+
* - Inline `extensionFactories` load UNCONDITIONALLY (reload() line 275 is not
|
|
64
|
+
* gated by `noExtensions`), so our agent-tempo extension still attaches.
|
|
65
|
+
* Net: the ONLY tools present are Pi's built-ins (bash/read/edit/write/grep —
|
|
66
|
+
* all covered by the deny-list) + our agent-tempo MCP tools (no exec). No
|
|
67
|
+
* third-party tool can slip past the deny-list. Skills/prompts/themes cannot
|
|
68
|
+
* register tools, so they are not a vector and are left at defaults.
|
|
69
|
+
*
|
|
70
|
+
* Kept as a pure, exported helper so the `noExtensions: true` invariant has a
|
|
71
|
+
* unit regression test (test/pi-headless-loader.test.ts) without needing the Pi
|
|
72
|
+
* SDK installed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function buildPiResourceLoaderOptions(params: {
|
|
75
|
+
cwd: string;
|
|
76
|
+
agentDir: string;
|
|
77
|
+
/** The inline agent-tempo extension factory (`createPiExtension(...)`). Typed
|
|
78
|
+
* with a bottom param so any concrete factory arity is assignable. */
|
|
79
|
+
extensionFactory: (pi: never) => void;
|
|
80
|
+
}): Record<string, unknown>;
|
|
81
|
+
/**
|
|
82
|
+
* Boot + run a headless Pi player until shutdown. Resolves when the process has
|
|
83
|
+
* cleanly detached + disposed (it also calls process.exit on the terminal path).
|
|
84
|
+
*/
|
|
85
|
+
export declare function runHeadlessPi(opts?: RunHeadlessPiOptions): Promise<void>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildPiResourceLoaderOptions = buildPiResourceLoaderOptions;
|
|
4
|
+
exports.runHeadlessPi = runHeadlessPi;
|
|
5
|
+
/**
|
|
6
|
+
* Headless Pi runtime (Phase 3a). Spawned by the daemon (spawnPiHeadless →
|
|
7
|
+
* src/adapters/pi/adapter.ts) for a recruited `agent: 'pi'` player. No human, no
|
|
8
|
+
* terminal: it constructs Pi's `createAgentSession` with the agent-tempo
|
|
9
|
+
* extension injected INLINE, and the module-scope extension singleton
|
|
10
|
+
* (createPiExtension, mode='headless') owns claim/heartbeat/tool-registration/
|
|
11
|
+
* cue-pump on `session_start`. Reuses ~everything from Phases 1–2.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* 1. probe Pi SDK; resolve the model via pi-ai `getModel` — a bad/unindexed
|
|
15
|
+
* model fails CLEAN (exit before attach, no orphan — architect's backstop).
|
|
16
|
+
* 2. createAgentSession({ resourceLoader: DefaultResourceLoader({
|
|
17
|
+
* extensionFactories: [ext] }), model? }).
|
|
18
|
+
* 3. await session.bindExtensions({}) → fires session_start → the singleton
|
|
19
|
+
* attaches (claim + heartbeat + tools + cue pump). bindExtensions IS the
|
|
20
|
+
* explicit bootstrap (not "hope session_start fires").
|
|
21
|
+
* 4. stay alive until a shutdown signal (SIGTERM/SIGINT).
|
|
22
|
+
* 5. RELIABLE detach (headless owns the exit): detachAllPiRuntimesForExit()
|
|
23
|
+
* [await adapterExited] → session.dispose() → process exit.
|
|
24
|
+
*
|
|
25
|
+
* ESM note: the Pi SDK is an ESM-only optional dep; we import it via a
|
|
26
|
+
* `Function`-wrapped dynamic `import()` so tsc (module=commonjs) doesn't
|
|
27
|
+
* downlevel it to `require()` — Node resolves the real ESM module at runtime.
|
|
28
|
+
*
|
|
29
|
+
* Determinism boundary: client-side only.
|
|
30
|
+
*/
|
|
31
|
+
const config_1 = require("../config");
|
|
32
|
+
const sdk_probe_1 = require("../utils/sdk-probe");
|
|
33
|
+
const extension_1 = require("./extension");
|
|
34
|
+
const probe_1 = require("./probe");
|
|
35
|
+
const session_seed_1 = require("./session-seed");
|
|
36
|
+
const log = (...args) => {
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.error('[agent-tempo:pi-headless]', ...args);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* True dynamic ESM import that survives tsc's commonjs downleveling. `import(x)`
|
|
42
|
+
* with a literal would be rewritten to a `require`-based helper (breaks on an
|
|
43
|
+
* ESM-only package); the `Function` indirection keeps a native `import()` at
|
|
44
|
+
* runtime so Node loads the real ESM module.
|
|
45
|
+
*/
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
47
|
+
const esmImport = new Function('specifier', 'return import(specifier)');
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a Pi `Model` object from a `provider/model` string via pi-ai's
|
|
50
|
+
* `getModel`. Returns `{ model }` on success, `{ fatal }` with an actionable
|
|
51
|
+
* message on an invalid/unindexed model (getModel returns `undefined` — a plain
|
|
52
|
+
* check, no throw), or `{}` when no model was requested (Pi uses its own default
|
|
53
|
+
* — the 3a anthropic-default path).
|
|
54
|
+
*/
|
|
55
|
+
async function resolveModel(modelStr) {
|
|
56
|
+
if (!modelStr)
|
|
57
|
+
return {};
|
|
58
|
+
const slash = modelStr.indexOf('/');
|
|
59
|
+
if (slash <= 0 || slash === modelStr.length - 1) {
|
|
60
|
+
return { fatal: `Invalid Pi model "${modelStr}" — expected "provider/model" (e.g. anthropic/claude-opus-4-5).` };
|
|
61
|
+
}
|
|
62
|
+
const provider = modelStr.slice(0, slash);
|
|
63
|
+
const modelName = modelStr.slice(slash + 1);
|
|
64
|
+
try {
|
|
65
|
+
const piAi = await esmImport(probe_1.PI_AI_PACKAGE);
|
|
66
|
+
const getModel = piAi.getModel;
|
|
67
|
+
const model = getModel(provider, modelName);
|
|
68
|
+
if (model === undefined || model === null) {
|
|
69
|
+
return {
|
|
70
|
+
fatal: `Pi model "${modelStr}" not found in Pi's provider index (provider="${provider}"). ` +
|
|
71
|
+
`Check the model id against \`pi --list-models\` / models.dev.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { model };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
return { fatal: `Failed to resolve Pi model "${modelStr}": ${err instanceof Error ? err.message : String(err)}` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build the `DefaultResourceLoader` options for a headless Pi player.
|
|
82
|
+
*
|
|
83
|
+
* SECURITY — S2 (MD-C deny-list soundness). The `restricted` tool gate is a
|
|
84
|
+
* DENY-LIST over shell/exec tool *names* (tool-capability.ts EXEC_TOOLS, via
|
|
85
|
+
* `classify(name) === 'exec'` — F1 replaced extension.ts's former local set). That
|
|
86
|
+
* guarantee — "restricted = no host execution" — holds ONLY IF no third-party
|
|
87
|
+
* extension can register an un-blacklisted execution tool (e.g. a custom
|
|
88
|
+
* `python` / `npm` / `run` tool). It therefore depends on a hard structural
|
|
89
|
+
* fact: which extensions Pi loads.
|
|
90
|
+
*
|
|
91
|
+
* Verified against the installed Pi SDK 0.78 source (NOT assumed):
|
|
92
|
+
* - `DefaultResourceLoader.reload()` (resource-loader.js:271-276) builds
|
|
93
|
+
* `extensionPaths = noExtensions ? cliEnabledExtensions
|
|
94
|
+
* : merge(cliEnabledExtensions, enabledExtensions)`
|
|
95
|
+
* where `enabledExtensions` (line 229) are the DISK/package extensions from
|
|
96
|
+
* `packageManager.resolve()` (`~/.pi/agent/extensions/`, `<cwd>/.pi/extensions/`,
|
|
97
|
+
* installed packages). `loadExtensions(extensionPaths)` then loads them and
|
|
98
|
+
* MERGES with our inline factories (lines 274-276).
|
|
99
|
+
* - `noExtensions` defaults to `false` (constructor, line 132) — so the naive
|
|
100
|
+
* loader DOES load disk extensions. That is the S2 gap.
|
|
101
|
+
*
|
|
102
|
+
* Fix (= security's "exclude the extensions dir", done structurally):
|
|
103
|
+
* - `noExtensions: true` → `extensionPaths` collapses to `cliEnabledExtensions`,
|
|
104
|
+
* which is empty because we pass NO `additionalExtensionPaths`. So
|
|
105
|
+
* `loadExtensions([])` registers nothing from disk/packages.
|
|
106
|
+
* - Inline `extensionFactories` load UNCONDITIONALLY (reload() line 275 is not
|
|
107
|
+
* gated by `noExtensions`), so our agent-tempo extension still attaches.
|
|
108
|
+
* Net: the ONLY tools present are Pi's built-ins (bash/read/edit/write/grep —
|
|
109
|
+
* all covered by the deny-list) + our agent-tempo MCP tools (no exec). No
|
|
110
|
+
* third-party tool can slip past the deny-list. Skills/prompts/themes cannot
|
|
111
|
+
* register tools, so they are not a vector and are left at defaults.
|
|
112
|
+
*
|
|
113
|
+
* Kept as a pure, exported helper so the `noExtensions: true` invariant has a
|
|
114
|
+
* unit regression test (test/pi-headless-loader.test.ts) without needing the Pi
|
|
115
|
+
* SDK installed.
|
|
116
|
+
*/
|
|
117
|
+
function buildPiResourceLoaderOptions(params) {
|
|
118
|
+
return {
|
|
119
|
+
cwd: params.cwd,
|
|
120
|
+
agentDir: params.agentDir,
|
|
121
|
+
extensionFactories: [params.extensionFactory],
|
|
122
|
+
// SECURITY (S2): hard-exclude all disk/package extensions. Do NOT add
|
|
123
|
+
// `additionalExtensionPaths` here — that would re-introduce the exec-tool
|
|
124
|
+
// vector this flag closes.
|
|
125
|
+
noExtensions: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Boot + run a headless Pi player until shutdown. Resolves when the process has
|
|
130
|
+
* cleanly detached + disposed (it also calls process.exit on the terminal path).
|
|
131
|
+
*/
|
|
132
|
+
async function runHeadlessPi(opts = {}) {
|
|
133
|
+
const config = opts.config ?? (0, config_1.getConfig)();
|
|
134
|
+
const toolAccess = opts.toolAccess ?? 'restricted';
|
|
135
|
+
// 0) Node-floor backstop (Decision B, #645). The recruit pre-flight is the
|
|
136
|
+
// AUTHORITATIVE gate; this covers direct/manual launches that bypass recruit.
|
|
137
|
+
// Checked before the SDK probe because Pi's ESM packages can't even import on
|
|
138
|
+
// sub-22.19 Node — a clean floor message beats a cryptic import failure.
|
|
139
|
+
const nodeFloor = (0, probe_1.checkPiNodeFloor)();
|
|
140
|
+
if (!nodeFloor.ok) {
|
|
141
|
+
log(`FATAL: ${nodeFloor.reason} Exiting.`);
|
|
142
|
+
process.exit(3);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// 1) Probe — the spawn entry is the only place the Pi SDK is REQUIRED.
|
|
146
|
+
if (!(0, sdk_probe_1.probeSdkInstall)(probe_1.PI_PACKAGE)) {
|
|
147
|
+
log(`FATAL: ${probe_1.PI_PACKAGE} is not installed — cannot run headless Pi. Exiting.`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// 2) Resolve the model BEFORE creating the session — a bad model fails clean
|
|
152
|
+
// (exit before attach, no half-attached orphan).
|
|
153
|
+
const { model, fatal } = await resolveModel(opts.model);
|
|
154
|
+
if (fatal) {
|
|
155
|
+
log(`FATAL: ${fatal} Exiting without attaching.`);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// 3) Inline extension factory — headless mode → the MD-C tool gate is active.
|
|
160
|
+
const extensionFactory = (0, extension_1.createPiExtension)({ mode: 'headless', toolAccess });
|
|
161
|
+
// 4) Construct the Pi SDK session with the extension injected inline.
|
|
162
|
+
const piSdk = await esmImport(probe_1.PI_PACKAGE);
|
|
163
|
+
const createAgentSession = piSdk.createAgentSession;
|
|
164
|
+
const DefaultResourceLoader = piSdk.DefaultResourceLoader;
|
|
165
|
+
// H1 (#645): ALWAYS run in-memory. A disk-backed SessionManager loads
|
|
166
|
+
// ~/.pi/agent/sessions/<cwd>/*.jsonl on startup; a stale/partial entry with
|
|
167
|
+
// malformed `content` throws "content is not iterable" during Pi's
|
|
168
|
+
// compaction/consumption scan (agent-session.js:2486/2493). inMemory loads
|
|
169
|
+
// nothing from disk → that crash vector is gone. `sessionManager` and
|
|
170
|
+
// `resourceLoader` are INDEPENDENT createAgentSession options (no conflict with
|
|
171
|
+
// the noExtensions/reload() path above).
|
|
172
|
+
const SessionManager = piSdk.SessionManager;
|
|
173
|
+
const sessionManager = SessionManager.inMemory(process.cwd());
|
|
174
|
+
// Single appendMessage chokepoint (session-seed.ts). For a fresh recruit the
|
|
175
|
+
// transcript is empty → no-op; H2 supplies the durable replay read here
|
|
176
|
+
// (fetch_state on ENV.PI_CONTINUE_SESSION). headless.ts NEVER calls
|
|
177
|
+
// appendMessage directly — sanitization is the only crash lever we control.
|
|
178
|
+
(0, session_seed_1.seedSessionManager)(sessionManager, undefined /* H2: durable replay transcript */);
|
|
179
|
+
// Pi's DefaultResourceLoader REQUIRES agentDir (normalizePath does
|
|
180
|
+
// `.startsWith()` on it) — getAgentDir() resolves ~/.pi/agent. Pass it to BOTH
|
|
181
|
+
// createAgentSession and the loader. (Found in the 3a live smoke — devops.)
|
|
182
|
+
const getAgentDir = piSdk.getAgentDir;
|
|
183
|
+
const agentDir = getAgentDir();
|
|
184
|
+
const resourceLoader = new DefaultResourceLoader(buildPiResourceLoaderOptions({ cwd: process.cwd(), agentDir, extensionFactory }));
|
|
185
|
+
// CRITICAL (3a live smoke — devops): createAgentSession only calls
|
|
186
|
+
// resourceLoader.reload() when IT constructs the loader (sdk.js:99-101). When we
|
|
187
|
+
// pass our OWN loader, reload() is skipped — and DefaultResourceLoader inits
|
|
188
|
+
// `extensionsResult.extensions = []` (resource-loader.js:146) and only populates
|
|
189
|
+
// it during reload(). So without this explicit reload our extension never
|
|
190
|
+
// registers, and session_start fires into an empty handler list (no claim, no
|
|
191
|
+
// heartbeat). The SDK's own doc comment (sdk.js:74-83) prescribes this exact
|
|
192
|
+
// construct → reload() → pass-as-resourceLoader sequence.
|
|
193
|
+
await resourceLoader.reload();
|
|
194
|
+
const { session } = await createAgentSession({
|
|
195
|
+
cwd: process.cwd(),
|
|
196
|
+
agentDir,
|
|
197
|
+
...(model ? { model } : {}),
|
|
198
|
+
resourceLoader,
|
|
199
|
+
// H1 (#645): in-memory session (seeded above via the session-seed chokepoint).
|
|
200
|
+
// H2 will seed it from agent-tempo durable state (ENV.PI_CONTINUE_SESSION
|
|
201
|
+
// saveable-state key) before this call — a true continuation, not a cue.
|
|
202
|
+
sessionManager,
|
|
203
|
+
});
|
|
204
|
+
// 5) Explicit bootstrap — fires session_start → the singleton claims/attaches.
|
|
205
|
+
await session.bindExtensions({});
|
|
206
|
+
// Headless session_start carries NO `session` field (interactive does), so the
|
|
207
|
+
// extension can't wire the cue pump's session ref itself. We hold the SDK
|
|
208
|
+
// session here — set it on the now-claimed runtime so the cue pump can inject.
|
|
209
|
+
// (3a live smoke — devops; the headless-vs-interactive session-wiring gap.)
|
|
210
|
+
const playerId = process.env[config_1.ENV.PLAYER_NAME] || `pi-${process.pid}`;
|
|
211
|
+
(0, extension_1.setRuntimeSession)((0, config_1.sessionWorkflowId)(config.ensemble, playerId), session);
|
|
212
|
+
log(`headless Pi session bound (toolAccess=${toolAccess}, ` +
|
|
213
|
+
`model=${opts.model ?? 'pi-default'}${opts.continueSessionId ? `, continue=${opts.continueSessionId}` : ''}, ` +
|
|
214
|
+
`sessionId=${session.sessionId ?? '?'})`);
|
|
215
|
+
// 6) Stay alive until a shutdown signal, then RELIABLE detach → dispose → exit.
|
|
216
|
+
// Keep the event loop alive with a REF'd timer: the heartbeat + cue-pump timers
|
|
217
|
+
// are `.unref()`'d by design (so they never block a clean exit), and the
|
|
218
|
+
// SIGTERM/SIGINT once-listeners aren't active handles — so WITHOUT this the loop
|
|
219
|
+
// drains and Node exits code 0 immediately after bindExtensions in headless mode.
|
|
220
|
+
// (Found in the 3a live smoke — devops.)
|
|
221
|
+
const keepAlive = setInterval(() => { }, 30_000);
|
|
222
|
+
try {
|
|
223
|
+
await new Promise((resolveShutdown) => {
|
|
224
|
+
const onSignal = (sig) => { log(`received ${sig} — shutting down`); resolveShutdown(); };
|
|
225
|
+
process.once('SIGTERM', () => onSignal('SIGTERM'));
|
|
226
|
+
process.once('SIGINT', () => onSignal('SIGINT'));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
clearInterval(keepAlive);
|
|
231
|
+
}
|
|
232
|
+
// Headless owns the exit sequence: await adapterExited (unmaps the runtime)
|
|
233
|
+
// THEN dispose the SDK session (the dispose-fired session_shutdown finds no
|
|
234
|
+
// mapped runtime → no-op, so no double-detach).
|
|
235
|
+
try {
|
|
236
|
+
await (0, extension_1.detachAllPiRuntimesForExit)();
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
log('detach failed (reaper backstops):', err);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
session.dispose();
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
log('dispose failed:', err);
|
|
246
|
+
}
|
|
247
|
+
log('headless Pi clean-exit complete');
|
|
248
|
+
// eslint-disable-next-line no-process-exit
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-tempo Pi integration — barrel.
|
|
3
|
+
*
|
|
4
|
+
* Default export is the Pi extension factory (`export default function(pi)`).
|
|
5
|
+
* Named exports expose the testable units and the client-side wrapper for reuse
|
|
6
|
+
* by the headless Pi runtime (Phase 3).
|
|
7
|
+
*
|
|
8
|
+
* Tool registration: the extension renders the shared transport-neutral tool
|
|
9
|
+
* descriptors (src/tools/descriptor.ts) onto Pi via `renderToPi`, deriving
|
|
10
|
+
* TypeBox param schemas from zod through `zod-to-typebox.ts`. There is no
|
|
11
|
+
* Pi-specific re-implementation of any tool.
|
|
12
|
+
*
|
|
13
|
+
* See src/pi/README.md for the Phase 0/2 findings (abrupt-death / MD-A, D12a)
|
|
14
|
+
* and known limitations.
|
|
15
|
+
*/
|
|
16
|
+
export { default } from './extension';
|
|
17
|
+
export { PhaseDriver } from './phase-driver';
|
|
18
|
+
export type { PiPhase, WorkflowAction, PhaseDriverResult } from './phase-driver';
|
|
19
|
+
export { PiWorkflowClient } from './workflow-client';
|
|
20
|
+
export type { PiWorkflowClientOptions } from './workflow-client';
|
|
21
|
+
export { CuePump } from './cue-pump';
|
|
22
|
+
export type { CueSource, SessionResolver, CuePumpOptions } from './cue-pump';
|
|
23
|
+
export { renderToPi, toPiResult } from './render-tools';
|
|
24
|
+
export { createLazyProxy } from './lazy-proxy';
|
|
25
|
+
export { zodShapeToTypeBox, UnsupportedZodFeatureError } from './zod-to-typebox';
|
|
26
|
+
export { probePi, PI_PACKAGE, PI_AI_PACKAGE, TESTED_PI_VERSION, PI_NODE_FLOOR } from './probe';
|
|
27
|
+
export type { PiProbeResult } from './probe';
|
|
28
|
+
export type { ExtensionAPI, PiExtension, PiAgentSession, PiEventPayload, PiToolDefinition, PiToolResult, PiLifecycleEvent, } from './pi-types';
|
package/dist/pi/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PI_NODE_FLOOR = exports.TESTED_PI_VERSION = exports.PI_AI_PACKAGE = exports.PI_PACKAGE = exports.probePi = exports.UnsupportedZodFeatureError = exports.zodShapeToTypeBox = exports.createLazyProxy = exports.toPiResult = exports.renderToPi = exports.CuePump = exports.PiWorkflowClient = exports.PhaseDriver = exports.default = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* agent-tempo Pi integration — barrel.
|
|
9
|
+
*
|
|
10
|
+
* Default export is the Pi extension factory (`export default function(pi)`).
|
|
11
|
+
* Named exports expose the testable units and the client-side wrapper for reuse
|
|
12
|
+
* by the headless Pi runtime (Phase 3).
|
|
13
|
+
*
|
|
14
|
+
* Tool registration: the extension renders the shared transport-neutral tool
|
|
15
|
+
* descriptors (src/tools/descriptor.ts) onto Pi via `renderToPi`, deriving
|
|
16
|
+
* TypeBox param schemas from zod through `zod-to-typebox.ts`. There is no
|
|
17
|
+
* Pi-specific re-implementation of any tool.
|
|
18
|
+
*
|
|
19
|
+
* See src/pi/README.md for the Phase 0/2 findings (abrupt-death / MD-A, D12a)
|
|
20
|
+
* and known limitations.
|
|
21
|
+
*/
|
|
22
|
+
var extension_1 = require("./extension");
|
|
23
|
+
Object.defineProperty(exports, "default", { enumerable: true, get: function () { return __importDefault(extension_1).default; } });
|
|
24
|
+
var phase_driver_1 = require("./phase-driver");
|
|
25
|
+
Object.defineProperty(exports, "PhaseDriver", { enumerable: true, get: function () { return phase_driver_1.PhaseDriver; } });
|
|
26
|
+
var workflow_client_1 = require("./workflow-client");
|
|
27
|
+
Object.defineProperty(exports, "PiWorkflowClient", { enumerable: true, get: function () { return workflow_client_1.PiWorkflowClient; } });
|
|
28
|
+
var cue_pump_1 = require("./cue-pump");
|
|
29
|
+
Object.defineProperty(exports, "CuePump", { enumerable: true, get: function () { return cue_pump_1.CuePump; } });
|
|
30
|
+
var render_tools_1 = require("./render-tools");
|
|
31
|
+
Object.defineProperty(exports, "renderToPi", { enumerable: true, get: function () { return render_tools_1.renderToPi; } });
|
|
32
|
+
Object.defineProperty(exports, "toPiResult", { enumerable: true, get: function () { return render_tools_1.toPiResult; } });
|
|
33
|
+
var lazy_proxy_1 = require("./lazy-proxy");
|
|
34
|
+
Object.defineProperty(exports, "createLazyProxy", { enumerable: true, get: function () { return lazy_proxy_1.createLazyProxy; } });
|
|
35
|
+
var zod_to_typebox_1 = require("./zod-to-typebox");
|
|
36
|
+
Object.defineProperty(exports, "zodShapeToTypeBox", { enumerable: true, get: function () { return zod_to_typebox_1.zodShapeToTypeBox; } });
|
|
37
|
+
Object.defineProperty(exports, "UnsupportedZodFeatureError", { enumerable: true, get: function () { return zod_to_typebox_1.UnsupportedZodFeatureError; } });
|
|
38
|
+
var probe_1 = require("./probe");
|
|
39
|
+
Object.defineProperty(exports, "probePi", { enumerable: true, get: function () { return probe_1.probePi; } });
|
|
40
|
+
Object.defineProperty(exports, "PI_PACKAGE", { enumerable: true, get: function () { return probe_1.PI_PACKAGE; } });
|
|
41
|
+
Object.defineProperty(exports, "PI_AI_PACKAGE", { enumerable: true, get: function () { return probe_1.PI_AI_PACKAGE; } });
|
|
42
|
+
Object.defineProperty(exports, "TESTED_PI_VERSION", { enumerable: true, get: function () { return probe_1.TESTED_PI_VERSION; } });
|
|
43
|
+
Object.defineProperty(exports, "PI_NODE_FLOOR", { enumerable: true, get: function () { return probe_1.PI_NODE_FLOOR; } });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { InnerFrame, InnerLoopRegistry } from './inner-loop-publisher';
|
|
2
|
+
/** Env var carrying the per-player ingest token (threaded in at spawn). */
|
|
3
|
+
export declare const INGEST_TOKEN_ENV = "AGENT_TEMPO_INGEST_TOKEN";
|
|
4
|
+
/** DOS backstop — frames over this are dropped (summaries are already ~2KB-truncated). */
|
|
5
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
6
|
+
/** Minimal `fetch` shape this client needs — injectable for tests. */
|
|
7
|
+
export type InnerLoopFetch = (url: string, init: {
|
|
8
|
+
method: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
body?: string;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
status: number;
|
|
13
|
+
json(): Promise<unknown>;
|
|
14
|
+
}>;
|
|
15
|
+
export interface InnerLoopHttpClientOptions {
|
|
16
|
+
/** The player's ensemble (URL path segment). */
|
|
17
|
+
ensemble: string;
|
|
18
|
+
/** The player's id (URL path segment). */
|
|
19
|
+
playerId: string;
|
|
20
|
+
/** Ingest token. Defaults to `process.env[INGEST_TOKEN_ENV]`. */
|
|
21
|
+
ingestToken?: string;
|
|
22
|
+
/** Daemon port discovery. Defaults to {@link readPortFile}. */
|
|
23
|
+
readPort?: () => number | null;
|
|
24
|
+
/** HTTP transport. Defaults to global `fetch` (no-op if absent). */
|
|
25
|
+
fetchFn?: InnerLoopFetch;
|
|
26
|
+
/** Min interval between presence GETs (default 1000ms). */
|
|
27
|
+
presencePollMs?: number;
|
|
28
|
+
/** Injected clock (tests). Defaults to `Date.now`. */
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Loopback-HTTP {@link InnerLoopRegistry}. Construct one per headless player with
|
|
33
|
+
* its ensemble + playerId; the publisher calls `publish` / `subscriberCount`.
|
|
34
|
+
* The `workflowId` argument is the player's own and is unused — the URL is built
|
|
35
|
+
* from the ctor ensemble + playerId.
|
|
36
|
+
*/
|
|
37
|
+
export declare class InnerLoopHttpClient implements InnerLoopRegistry {
|
|
38
|
+
private readonly ensemble;
|
|
39
|
+
private readonly playerId;
|
|
40
|
+
private readonly ingestToken;
|
|
41
|
+
private readonly readPort;
|
|
42
|
+
private readonly fetchFn;
|
|
43
|
+
private readonly presencePollMs;
|
|
44
|
+
private readonly now;
|
|
45
|
+
/** Last presence count from the daemon. 0 until the first GET resolves / on failure. */
|
|
46
|
+
private cachedSubscribers;
|
|
47
|
+
/** 3d — last gateArmed flag from the daemon (folded into the presence response). */
|
|
48
|
+
private cachedGateArmed;
|
|
49
|
+
private lastPresenceRefresh;
|
|
50
|
+
constructor(opts: InnerLoopHttpClientOptions);
|
|
51
|
+
/** Whether the client can talk to the daemon at all (token + transport present). */
|
|
52
|
+
private get enabled();
|
|
53
|
+
private baseUrl;
|
|
54
|
+
/** Fire-and-forget POST of one frame. Drops (never throws/blocks) on any failure. */
|
|
55
|
+
publish(_workflowId: string, frame: InnerFrame): void;
|
|
56
|
+
/** Synchronous cached presence (stale-while-revalidate). Default 0 / fail-safe 0. */
|
|
57
|
+
subscriberCount(_workflowId: string): number;
|
|
58
|
+
/**
|
|
59
|
+
* 3d — synchronous cached `gateArmed` (same stale-while-revalidate presence GET
|
|
60
|
+
* that feeds subscriberCount; short-poll keeps it within ~1s). Default false /
|
|
61
|
+
* fail-safe false (a missed/failed presence read never spuriously engages the
|
|
62
|
+
* gate). The engagement check reads this together with subscriberCount.
|
|
63
|
+
*/
|
|
64
|
+
gateArmed(_workflowId: string): boolean;
|
|
65
|
+
/** Fire a rate-limited background presence GET that updates the cache. */
|
|
66
|
+
private maybeRefreshPresence;
|
|
67
|
+
}
|
|
@@ -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;
|