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