agent-tempo 1.4.0 → 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/dashboard/package.json +1 -1
- package/dist/pi/headless.js +29 -3
- package/dist/pi/mission-control/board.d.ts +35 -0
- package/dist/pi/mission-control/board.js +37 -0
- package/dist/pi/mission-control/extension.d.ts +8 -1
- package/dist/pi/mission-control/extension.js +90 -11
- package/dist/pi/mission-control/render.d.ts +1 -1
- package/dist/pi/mission-control/render.js +8 -5
- package/dist/pi/pi-types.d.ts +15 -1
- package/dist/pi/probe.d.ts +19 -0
- package/dist/pi/probe.js +25 -0
- package/dist/pi/render-tools.js +6 -1
- package/dist/pi/session-seed.d.ts +74 -0
- package/dist/pi/session-seed.js +103 -0
- package/dist/tools/recruit.js +15 -0
- package/package.json +1 -1
package/dashboard/package.json
CHANGED
package/dist/pi/headless.js
CHANGED
|
@@ -32,6 +32,7 @@ const config_1 = require("../config");
|
|
|
32
32
|
const sdk_probe_1 = require("../utils/sdk-probe");
|
|
33
33
|
const extension_1 = require("./extension");
|
|
34
34
|
const probe_1 = require("./probe");
|
|
35
|
+
const session_seed_1 = require("./session-seed");
|
|
35
36
|
const log = (...args) => {
|
|
36
37
|
// eslint-disable-next-line no-console
|
|
37
38
|
console.error('[agent-tempo:pi-headless]', ...args);
|
|
@@ -131,6 +132,16 @@ function buildPiResourceLoaderOptions(params) {
|
|
|
131
132
|
async function runHeadlessPi(opts = {}) {
|
|
132
133
|
const config = opts.config ?? (0, config_1.getConfig)();
|
|
133
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
|
+
}
|
|
134
145
|
// 1) Probe — the spawn entry is the only place the Pi SDK is REQUIRED.
|
|
135
146
|
if (!(0, sdk_probe_1.probeSdkInstall)(probe_1.PI_PACKAGE)) {
|
|
136
147
|
log(`FATAL: ${probe_1.PI_PACKAGE} is not installed — cannot run headless Pi. Exiting.`);
|
|
@@ -151,6 +162,20 @@ async function runHeadlessPi(opts = {}) {
|
|
|
151
162
|
const piSdk = await esmImport(probe_1.PI_PACKAGE);
|
|
152
163
|
const createAgentSession = piSdk.createAgentSession;
|
|
153
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 */);
|
|
154
179
|
// Pi's DefaultResourceLoader REQUIRES agentDir (normalizePath does
|
|
155
180
|
// `.startsWith()` on it) — getAgentDir() resolves ~/.pi/agent. Pass it to BOTH
|
|
156
181
|
// createAgentSession and the loader. (Found in the 3a live smoke — devops.)
|
|
@@ -171,9 +196,10 @@ async function runHeadlessPi(opts = {}) {
|
|
|
171
196
|
agentDir,
|
|
172
197
|
...(model ? { model } : {}),
|
|
173
198
|
resourceLoader,
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
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,
|
|
177
203
|
});
|
|
178
204
|
// 5) Explicit bootstrap — fires session_start → the singleton claims/attaches.
|
|
179
205
|
await session.bindExtensions({});
|
|
@@ -14,6 +14,12 @@ import type { InnerFrame } from '../inner-loop-publisher';
|
|
|
14
14
|
export interface PlayerRow {
|
|
15
15
|
playerId: string;
|
|
16
16
|
isConductor: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Daemon host the player runs on (carried from `PlayerSummaryV1.hostname`,
|
|
19
|
+
* 3f/H3a). Undefined on older pre-hostname snapshots → treated as tailable
|
|
20
|
+
* (never block on absent data). Drives {@link tailability}.
|
|
21
|
+
*/
|
|
22
|
+
hostname?: string;
|
|
17
23
|
phase?: AttachmentPhase;
|
|
18
24
|
part: string;
|
|
19
25
|
/** Tool currently executing (3c coarse), `null`/undefined = idle. */
|
|
@@ -49,5 +55,34 @@ export declare function applyTempoEvent(model: BoardModel, ev: TempoEvent): void
|
|
|
49
55
|
export declare function applyInnerFrame(model: BoardModel, frame: InnerFrame): void;
|
|
50
56
|
/** Select a player for the fine tail (clears the prior tail). No-op if absent. */
|
|
51
57
|
export declare function selectPlayer(model: BoardModel, playerId: string | null): boolean;
|
|
58
|
+
/** Result of a {@link tailability} check — whether the operator can open a fine /inner tail. */
|
|
59
|
+
export type Tailability = {
|
|
60
|
+
ok: true;
|
|
61
|
+
} | {
|
|
62
|
+
ok: false;
|
|
63
|
+
reason: 'no-such-player';
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
reason: 'ui-player';
|
|
67
|
+
} | {
|
|
68
|
+
ok: false;
|
|
69
|
+
reason: 'cross-host';
|
|
70
|
+
playerHost: string;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Pure: can the local operator open `playerId`'s fine inner-loop tail? The 3f
|
|
74
|
+
* inner-loop tail is DAEMON-LOCAL — only players on this daemon's host are
|
|
75
|
+
* tailable.
|
|
76
|
+
*
|
|
77
|
+
* - missing player → `no-such-player`
|
|
78
|
+
* - the maestro/dashboard UI player (no /inner stream) → `ui-player` (H5; checked
|
|
79
|
+
* BEFORE the host comparison so its `dashboard` sentinel isn't mis-framed as
|
|
80
|
+
* cross-host)
|
|
81
|
+
* - a real player on another host → `cross-host` (carrying `playerHost`); actual
|
|
82
|
+
* cross-host routing is the deferred H3(b) (#645)
|
|
83
|
+
* - a missing/older-snapshot `hostname` (undefined) → tailable; never block on
|
|
84
|
+
* absent data
|
|
85
|
+
*/
|
|
86
|
+
export declare function tailability(model: BoardModel, playerId: string, localHost: string): Tailability;
|
|
52
87
|
/** Sorted player ids — conductor first, then alphabetical (stable board ordering). */
|
|
53
88
|
export declare function sortedPlayerIds(model: BoardModel): string[];
|
|
@@ -5,6 +5,7 @@ exports.initBoard = initBoard;
|
|
|
5
5
|
exports.applyTempoEvent = applyTempoEvent;
|
|
6
6
|
exports.applyInnerFrame = applyInnerFrame;
|
|
7
7
|
exports.selectPlayer = selectPlayer;
|
|
8
|
+
exports.tailability = tailability;
|
|
8
9
|
exports.sortedPlayerIds = sortedPlayerIds;
|
|
9
10
|
/** Default cap on the retained fine-tail frames for the selected player. */
|
|
10
11
|
exports.DEFAULT_TAIL_LIMIT = 200;
|
|
@@ -16,6 +17,9 @@ function rowFromSummary(p) {
|
|
|
16
17
|
return {
|
|
17
18
|
playerId: p.playerId,
|
|
18
19
|
isConductor: p.isConductor,
|
|
20
|
+
// H3a: carry the host through — the board previously DROPPED it. Drives
|
|
21
|
+
// cross-host tail refusal in `tailability`.
|
|
22
|
+
...(p.hostname !== undefined ? { hostname: p.hostname } : {}),
|
|
19
23
|
...(p.phase !== undefined ? { phase: p.phase } : {}),
|
|
20
24
|
part: p.part ?? '',
|
|
21
25
|
...(p.currentTool !== undefined ? { currentTool: p.currentTool } : {}),
|
|
@@ -92,6 +96,39 @@ function selectPlayer(model, playerId) {
|
|
|
92
96
|
model.revision++;
|
|
93
97
|
return true;
|
|
94
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Sentinel hostname the TUI's own maestro/dashboard session stamps on its
|
|
101
|
+
* metadata (see `ensureMaestroSession` in `client/core.ts`). It's a UI player
|
|
102
|
+
* with NO /inner stream — non-tailable, but NOT a cross-host case (the sentinel
|
|
103
|
+
* is not a real daemon host). Mirrored locally to avoid a client→mission-control
|
|
104
|
+
* import; the maestro-tail test trips if it ever drifts. (H5)
|
|
105
|
+
*/
|
|
106
|
+
const UI_PLAYER_HOSTNAME = 'dashboard';
|
|
107
|
+
/**
|
|
108
|
+
* Pure: can the local operator open `playerId`'s fine inner-loop tail? The 3f
|
|
109
|
+
* inner-loop tail is DAEMON-LOCAL — only players on this daemon's host are
|
|
110
|
+
* tailable.
|
|
111
|
+
*
|
|
112
|
+
* - missing player → `no-such-player`
|
|
113
|
+
* - the maestro/dashboard UI player (no /inner stream) → `ui-player` (H5; checked
|
|
114
|
+
* BEFORE the host comparison so its `dashboard` sentinel isn't mis-framed as
|
|
115
|
+
* cross-host)
|
|
116
|
+
* - a real player on another host → `cross-host` (carrying `playerHost`); actual
|
|
117
|
+
* cross-host routing is the deferred H3(b) (#645)
|
|
118
|
+
* - a missing/older-snapshot `hostname` (undefined) → tailable; never block on
|
|
119
|
+
* absent data
|
|
120
|
+
*/
|
|
121
|
+
function tailability(model, playerId, localHost) {
|
|
122
|
+
const row = model.players.get(playerId);
|
|
123
|
+
if (!row)
|
|
124
|
+
return { ok: false, reason: 'no-such-player' };
|
|
125
|
+
if (row.hostname === UI_PLAYER_HOSTNAME)
|
|
126
|
+
return { ok: false, reason: 'ui-player' };
|
|
127
|
+
if (row.hostname && row.hostname !== localHost) {
|
|
128
|
+
return { ok: false, reason: 'cross-host', playerHost: row.hostname };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
95
132
|
/** Sorted player ids — conductor first, then alphabetical (stable board ordering). */
|
|
96
133
|
function sortedPlayerIds(model) {
|
|
97
134
|
return [...model.players.values()]
|
|
@@ -7,6 +7,8 @@ export interface MissionControlDeps {
|
|
|
7
7
|
adminToken?: string;
|
|
8
8
|
baseUrl?: string;
|
|
9
9
|
renderThrottleMs?: number;
|
|
10
|
+
/** Local daemon host for tailability (test override; defaults to `os.hostname()`). */
|
|
11
|
+
localHost?: string;
|
|
10
12
|
}
|
|
11
13
|
/**
|
|
12
14
|
* The operator-command + board controller. Holds the model + the action client;
|
|
@@ -16,9 +18,14 @@ export interface MissionControlDeps {
|
|
|
16
18
|
export declare class Controller {
|
|
17
19
|
readonly model: BoardModel;
|
|
18
20
|
readonly actions: MissionControlActions;
|
|
21
|
+
/**
|
|
22
|
+
* This daemon's host (`os.hostname()`). The fine /inner tail is daemon-local,
|
|
23
|
+
* so only same-host players are tailable — see {@link tailability}.
|
|
24
|
+
*/
|
|
25
|
+
readonly localHost: string;
|
|
19
26
|
/** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
|
|
20
27
|
onTailRequest: ((playerId: string | null) => void) | null;
|
|
21
|
-
constructor(ensemble: string, actions: MissionControlActions);
|
|
28
|
+
constructor(ensemble: string, actions: MissionControlActions, localHost?: string);
|
|
22
29
|
private notify;
|
|
23
30
|
private report;
|
|
24
31
|
/** First whitespace-delimited token + the remainder. */
|
|
@@ -1,4 +1,37 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.Controller = void 0;
|
|
4
37
|
exports.createMissionControlExtension = createMissionControlExtension;
|
|
@@ -17,6 +50,7 @@ exports.createMissionControlExtension = createMissionControlExtension;
|
|
|
17
50
|
* Node `subscribe` path), starts the render tick, and registers operator
|
|
18
51
|
* commands; `session_shutdown` tears all of it down + clears the widget.
|
|
19
52
|
*/
|
|
53
|
+
const os = __importStar(require("os"));
|
|
20
54
|
const config_1 = require("../../config");
|
|
21
55
|
const port_file_1 = require("../../http/port-file");
|
|
22
56
|
const subscribe_1 = require("../../client/subscribe");
|
|
@@ -35,11 +69,17 @@ const DEFAULT_PORT = 8473;
|
|
|
35
69
|
class Controller {
|
|
36
70
|
model;
|
|
37
71
|
actions;
|
|
72
|
+
/**
|
|
73
|
+
* This daemon's host (`os.hostname()`). The fine /inner tail is daemon-local,
|
|
74
|
+
* so only same-host players are tailable — see {@link tailability}.
|
|
75
|
+
*/
|
|
76
|
+
localHost;
|
|
38
77
|
/** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
|
|
39
78
|
onTailRequest = null;
|
|
40
|
-
constructor(ensemble, actions) {
|
|
79
|
+
constructor(ensemble, actions, localHost = os.hostname()) {
|
|
41
80
|
this.model = (0, board_1.initBoard)(ensemble);
|
|
42
81
|
this.actions = actions;
|
|
82
|
+
this.localHost = localHost;
|
|
43
83
|
}
|
|
44
84
|
notify(ctx, msg) {
|
|
45
85
|
if (ctx.hasUI)
|
|
@@ -66,10 +106,26 @@ class Controller {
|
|
|
66
106
|
this.notify(ctx, 'Inner-loop tail off.');
|
|
67
107
|
return;
|
|
68
108
|
}
|
|
69
|
-
|
|
70
|
-
|
|
109
|
+
// H3a: the /inner tail is daemon-local — refuse a cross-host tail with an
|
|
110
|
+
// actionable message rather than silently selecting a player that would only
|
|
111
|
+
// ever show "(no inner-loop activity yet)". Decided BEFORE select/open so a
|
|
112
|
+
// cross-host player is never selected and no tail SSE is opened.
|
|
113
|
+
const t = (0, board_1.tailability)(this.model, target, this.localHost);
|
|
114
|
+
if (!t.ok) {
|
|
115
|
+
let msg;
|
|
116
|
+
if (t.reason === 'cross-host') {
|
|
117
|
+
msg = `Inner tail unavailable: ${target} runs on host ${t.playerHost}, not this daemon's host (${this.localHost}). Cross-host tail is a tracked follow-up (#645 / H3b).`;
|
|
118
|
+
}
|
|
119
|
+
else if (t.reason === 'ui-player') {
|
|
120
|
+
msg = `${target} is a UI player — nothing to tail.`;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
msg = `No such player: ${target}`;
|
|
124
|
+
}
|
|
125
|
+
this.notify(ctx, msg);
|
|
71
126
|
return;
|
|
72
127
|
}
|
|
128
|
+
(0, board_1.selectPlayer)(this.model, target);
|
|
73
129
|
this.onTailRequest?.(target);
|
|
74
130
|
this.notify(ctx, `Tailing ${target}.`);
|
|
75
131
|
}
|
|
@@ -156,10 +212,21 @@ function createMissionControlExtension(deps = {}) {
|
|
|
156
212
|
return (pi) => {
|
|
157
213
|
const ensemble = deps.ensemble ?? (0, config_1.getConfig)().ensemble;
|
|
158
214
|
const adminToken = deps.adminToken ?? process.env[actions_1.ADMIN_TOKEN_ENV];
|
|
159
|
-
const baseUrl = resolveBaseUrl(deps.baseUrl);
|
|
160
215
|
const throttleMs = deps.renderThrottleMs ?? DEFAULT_RENDER_THROTTLE_MS;
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
// H3a: mission-control is co-located with its 127.0.0.1 daemon, so this
|
|
217
|
+
// process's hostname IS the daemon's host. (HealthV1.hostname is the noted
|
|
218
|
+
// future upgrade for a baseUrl pointing at a remote daemon — not built here.)
|
|
219
|
+
const localHost = deps.localHost ?? os.hostname();
|
|
220
|
+
// H5: do NOT pre-resolve baseUrl. createSubscribe + MissionControlActions both
|
|
221
|
+
// re-read ~/.agent-tempo/daemon.port per-call when baseUrl is undefined, so a
|
|
222
|
+
// daemon restart on a new port self-heals (a once-resolved URL wedges the
|
|
223
|
+
// board). A `deps.baseUrl` override (tests / future remote daemon) still wins.
|
|
224
|
+
const actions = new actions_1.MissionControlActions({
|
|
225
|
+
ensemble,
|
|
226
|
+
...(adminToken ? { adminToken } : {}),
|
|
227
|
+
...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
|
|
228
|
+
});
|
|
229
|
+
const ctrl = new Controller(ensemble, actions, localHost);
|
|
163
230
|
// Per-session lifecycle state (re-created on each session_start).
|
|
164
231
|
let coarseAbort = null;
|
|
165
232
|
let tailAbort = null;
|
|
@@ -172,22 +239,31 @@ function createMissionControlExtension(deps = {}) {
|
|
|
172
239
|
if (ctrl.model.revision === lastRenderedRevision)
|
|
173
240
|
return; // throttle: skip no-op ticks
|
|
174
241
|
lastRenderedRevision = ctrl.model.revision;
|
|
175
|
-
activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model), { placement: 'aboveEditor' });
|
|
242
|
+
activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model, ctrl.localHost), { placement: 'aboveEditor' });
|
|
176
243
|
};
|
|
177
244
|
const startCoarse = () => {
|
|
178
245
|
if (!adminToken) {
|
|
179
246
|
log(`no admin token (${actions_1.ADMIN_TOKEN_ENV}) — board limited / disabled`);
|
|
180
247
|
}
|
|
181
|
-
|
|
182
|
-
|
|
248
|
+
// H5: capture the controller locally. teardown nulls the outer `coarseAbort`,
|
|
249
|
+
// so the catch must check THIS signal — checking the nulled outer ref made an
|
|
250
|
+
// expected teardown abort log a spurious "coarse SSE ended: AbortError".
|
|
251
|
+
const ac = new AbortController();
|
|
252
|
+
coarseAbort = ac;
|
|
253
|
+
// H5: omit baseUrl → createSubscribe re-resolves the daemon port per
|
|
254
|
+
// (re)connect, so a daemon restart on a new port self-heals.
|
|
255
|
+
const subscribe = (0, subscribe_1.createSubscribe)({
|
|
256
|
+
...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
|
|
257
|
+
...(adminToken ? { token: adminToken } : {}),
|
|
258
|
+
});
|
|
183
259
|
void (async () => {
|
|
184
260
|
try {
|
|
185
|
-
for await (const ev of subscribe(ensemble, { signal:
|
|
261
|
+
for await (const ev of subscribe(ensemble, { signal: ac.signal })) {
|
|
186
262
|
(0, board_1.applyTempoEvent)(ctrl.model, ev);
|
|
187
263
|
}
|
|
188
264
|
}
|
|
189
265
|
catch (err) {
|
|
190
|
-
if (!
|
|
266
|
+
if (!ac.signal.aborted)
|
|
191
267
|
log('coarse SSE ended:', err instanceof Error ? err.message : err);
|
|
192
268
|
}
|
|
193
269
|
})();
|
|
@@ -198,6 +274,9 @@ function createMissionControlExtension(deps = {}) {
|
|
|
198
274
|
if (playerId === null || !adminToken)
|
|
199
275
|
return;
|
|
200
276
|
tailAbort = new AbortController();
|
|
277
|
+
// H5: resolve the daemon base URL HERE (per /tail) so a port change is
|
|
278
|
+
// picked up on the next tail instead of being pinned at session start.
|
|
279
|
+
const baseUrl = resolveBaseUrl(deps.baseUrl);
|
|
201
280
|
const fetchFn = globalThis.fetch;
|
|
202
281
|
if (!fetchFn)
|
|
203
282
|
return;
|
|
@@ -3,4 +3,4 @@ import { type BoardModel } from './board';
|
|
|
3
3
|
* Render the full board. Header + player rows (conductor first), then — when a
|
|
4
4
|
* player is selected — a fine inner-loop tail (last {@link TAIL_RENDER_LINES}).
|
|
5
5
|
*/
|
|
6
|
-
export declare function renderBoard(model: BoardModel): string[];
|
|
6
|
+
export declare function renderBoard(model: BoardModel, localHost?: string): string[];
|
|
@@ -24,14 +24,17 @@ function pct(contextPercent) {
|
|
|
24
24
|
const p = contextPercent <= 1 ? contextPercent * 100 : contextPercent;
|
|
25
25
|
return `${Math.round(p)}%`;
|
|
26
26
|
}
|
|
27
|
-
function renderRow(row, selected) {
|
|
27
|
+
function renderRow(row, selected, localHost) {
|
|
28
28
|
const sel = selected ? '>' : ' ';
|
|
29
29
|
const glyph = phaseGlyph(row.phase);
|
|
30
30
|
const tool = row.currentTool ? `[${row.currentTool}]` : '';
|
|
31
31
|
const ctx = pct(row.contextPercent);
|
|
32
32
|
const part = row.part ? ` ${row.part}` : '';
|
|
33
|
-
//
|
|
34
|
-
|
|
33
|
+
// H3a: flag cross-host players (`@host`) so the operator sees at a glance which
|
|
34
|
+
// are non-tailable (the /inner tail is daemon-local). Only when localHost is known.
|
|
35
|
+
const host = localHost && row.hostname && row.hostname !== localHost ? `@${row.hostname}` : '';
|
|
36
|
+
// sel glyph id part tool ctx @host
|
|
37
|
+
return [`${sel}${glyph} ${row.playerId}`, part, tool, ctx, host]
|
|
35
38
|
.filter((s) => s !== '')
|
|
36
39
|
.join(' ')
|
|
37
40
|
.trimEnd();
|
|
@@ -67,7 +70,7 @@ function oneLine(s, max) {
|
|
|
67
70
|
* Render the full board. Header + player rows (conductor first), then — when a
|
|
68
71
|
* player is selected — a fine inner-loop tail (last {@link TAIL_RENDER_LINES}).
|
|
69
72
|
*/
|
|
70
|
-
function renderBoard(model) {
|
|
73
|
+
function renderBoard(model, localHost) {
|
|
71
74
|
const ids = (0, board_1.sortedPlayerIds)(model);
|
|
72
75
|
const lines = [];
|
|
73
76
|
lines.push(`MISSION CONTROL · ${model.ensemble} · ${ids.length} player${ids.length === 1 ? '' : 's'}`);
|
|
@@ -77,7 +80,7 @@ function renderBoard(model) {
|
|
|
77
80
|
else {
|
|
78
81
|
for (const id of ids) {
|
|
79
82
|
const row = model.players.get(id);
|
|
80
|
-
lines.push(renderRow(row, id === model.selected));
|
|
83
|
+
lines.push(renderRow(row, id === model.selected, localHost));
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
if (model.selected) {
|
package/dist/pi/pi-types.d.ts
CHANGED
|
@@ -197,7 +197,21 @@ export interface PiToolDefinition {
|
|
|
197
197
|
description?: string;
|
|
198
198
|
/** TypeBox schema object. Typed `unknown` to avoid coupling pi-types to typebox. */
|
|
199
199
|
parameters: unknown;
|
|
200
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Pi invokes `execute` POSITIONALLY at runtime:
|
|
202
|
+
* execute(toolCallId, params, signal?, onUpdate?, ctx?)
|
|
203
|
+
* (installed Pi 0.78 `core/extensions/types.d.ts:354` + the wrapper at
|
|
204
|
+
* `core/tools/tool-definition-wrapper.js:10,31`). The VALIDATED params object
|
|
205
|
+
* is the SECOND positional — the FIRST is the `toolCallId` string.
|
|
206
|
+
*
|
|
207
|
+
* Declaring the full positional shape (not a single `args`) is LOAD-BEARING: a
|
|
208
|
+
* one-arg signature silently accepts `(args) => handler(args)`, which binds the
|
|
209
|
+
* toolCallId string to what the handler treats as the params object — the
|
|
210
|
+
* shipped arg-order bug (v1.4.0) this corrected type now makes a compile error.
|
|
211
|
+
* `signal`/`onUpdate`/`ctx` are optional + `unknown`: we don't consume them and
|
|
212
|
+
* avoid coupling to Pi's internal callback/context types.
|
|
213
|
+
*/
|
|
214
|
+
execute: (toolCallId: string, params: Record<string, unknown>, signal?: unknown, onUpdate?: unknown, ctx?: unknown) => Promise<PiToolResult> | PiToolResult;
|
|
201
215
|
}
|
|
202
216
|
/** The `pi` object passed to `export default function(pi: ExtensionAPI) {}`. */
|
|
203
217
|
export interface ExtensionAPI {
|
package/dist/pi/probe.d.ts
CHANGED
|
@@ -31,6 +31,25 @@ export declare function probePi(): PiProbeResult;
|
|
|
31
31
|
* (conservative: unknown version is treated as below the floor).
|
|
32
32
|
*/
|
|
33
33
|
export declare function meetsVersionFloor(installed: string, floor?: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Node-floor preflight (Decision B, #645). The Pi optional deps require
|
|
36
|
+
* Node >= {@link PI_NODE_FLOOR} (22.19). Enforced ONLY at the Pi recruit/spawn
|
|
37
|
+
* boundary — `package.json#engines` stays `>=20` so non-Pi users are untouched.
|
|
38
|
+
*
|
|
39
|
+
* Pure + injectable (`nodeVersion` defaults to the live runtime) so it is the
|
|
40
|
+
* single source of truth shared by the recruit pre-flight (authoritative,
|
|
41
|
+
* pre-spawn) AND the {@link runHeadlessPi} backstop (direct/manual launches).
|
|
42
|
+
* Reuses {@link meetsVersionFloor}'s major.minor.patch comparison.
|
|
43
|
+
*
|
|
44
|
+
* @returns `{ ok: true }` when the floor is met, else `{ ok: false, reason }`
|
|
45
|
+
* with an actionable, fail-clean message (mirrors the `probeSdkInstall` /
|
|
46
|
+
* `resolveModel` style). The `reason` omits any `force: true` hint so each
|
|
47
|
+
* caller can frame the bypass in its own voice.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkPiNodeFloor(nodeVersion?: string): {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
reason?: string;
|
|
52
|
+
};
|
|
34
53
|
/**
|
|
35
54
|
* Injectable collaborators for {@link probeCopilotPiPreflight}. All default to
|
|
36
55
|
* real implementations; tests override them to exercise each branch without a
|
package/dist/pi/probe.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.PI_NODE_FLOOR = exports.PI_VERSION_FLOOR = exports.TESTED_PI_VERSION = exports.PI_AI_PACKAGE = exports.PI_PACKAGE = void 0;
|
|
4
4
|
exports.probePi = probePi;
|
|
5
5
|
exports.meetsVersionFloor = meetsVersionFloor;
|
|
6
|
+
exports.checkPiNodeFloor = checkPiNodeFloor;
|
|
6
7
|
exports.probeCopilotPiPreflight = probeCopilotPiPreflight;
|
|
7
8
|
/**
|
|
8
9
|
* Pi dependency preflight — mirrors the opencode / claude-api optional-dep gate.
|
|
@@ -74,6 +75,30 @@ function meetsVersionFloor(installed, floor = exports.PI_VERSION_FLOOR) {
|
|
|
74
75
|
return iMin > fMin;
|
|
75
76
|
return iPat >= fPat;
|
|
76
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Node-floor preflight (Decision B, #645). The Pi optional deps require
|
|
80
|
+
* Node >= {@link PI_NODE_FLOOR} (22.19). Enforced ONLY at the Pi recruit/spawn
|
|
81
|
+
* boundary — `package.json#engines` stays `>=20` so non-Pi users are untouched.
|
|
82
|
+
*
|
|
83
|
+
* Pure + injectable (`nodeVersion` defaults to the live runtime) so it is the
|
|
84
|
+
* single source of truth shared by the recruit pre-flight (authoritative,
|
|
85
|
+
* pre-spawn) AND the {@link runHeadlessPi} backstop (direct/manual launches).
|
|
86
|
+
* Reuses {@link meetsVersionFloor}'s major.minor.patch comparison.
|
|
87
|
+
*
|
|
88
|
+
* @returns `{ ok: true }` when the floor is met, else `{ ok: false, reason }`
|
|
89
|
+
* with an actionable, fail-clean message (mirrors the `probeSdkInstall` /
|
|
90
|
+
* `resolveModel` style). The `reason` omits any `force: true` hint so each
|
|
91
|
+
* caller can frame the bypass in its own voice.
|
|
92
|
+
*/
|
|
93
|
+
function checkPiNodeFloor(nodeVersion = process.versions.node) {
|
|
94
|
+
if (meetsVersionFloor(nodeVersion, exports.PI_NODE_FLOOR))
|
|
95
|
+
return { ok: true };
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: `agent: "pi" requires Node >= ${exports.PI_NODE_FLOOR}, but this host runs Node ${nodeVersion}. ` +
|
|
99
|
+
`Upgrade Node (e.g. via nvm/fnm) and retry.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
77
102
|
/** Default `~/.pi/agent/auth.json` presence check. */
|
|
78
103
|
function defaultAuthFileExists() {
|
|
79
104
|
return (0, fs_1.existsSync)((0, path_1.join)((0, os_1.homedir)(), '.pi', 'agent', 'auth.json'));
|
package/dist/pi/render-tools.js
CHANGED
|
@@ -45,7 +45,12 @@ function renderToPi(pi, descriptors) {
|
|
|
45
45
|
name: d.name,
|
|
46
46
|
description: d.description,
|
|
47
47
|
parameters: (0, zod_to_typebox_1.zodShapeToTypeBox)(d.params, d.name),
|
|
48
|
-
execute:
|
|
48
|
+
// Pi calls execute POSITIONALLY: (toolCallId, params, signal, onUpdate, ctx).
|
|
49
|
+
// The validated params object is the SECOND positional — ignore the
|
|
50
|
+
// toolCallId string (1st) and hand `params` to the descriptor handler.
|
|
51
|
+
// (Passing the 1st positional was the v1.4.0 arg-order bug: handlers got the
|
|
52
|
+
// toolCallId string instead of params.) See PiToolDefinition.execute.
|
|
53
|
+
execute: async (_toolCallId, params) => toPiResult(await d.handler(params)),
|
|
49
54
|
});
|
|
50
55
|
}
|
|
51
56
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi session seeding — the SINGLE chokepoint for `appendMessage` on a headless
|
|
3
|
+
* Pi `SessionManager` (H1, epic #645).
|
|
4
|
+
*
|
|
5
|
+
* WHY a chokepoint (load-bearing, PoC-verified against Pi 0.78.0):
|
|
6
|
+
* Pi's `SessionManager.appendMessage` NEVER validates `content` — every
|
|
7
|
+
* malformed shape appends "ok". The throw (`"content is not iterable"`) fires
|
|
8
|
+
* purely at CONSUMPTION: `getLastAssistantText()` /context-build /compaction
|
|
9
|
+
* scans iterate `message.content` (agent-session.js:2486/2493 in Pi 0.78). So
|
|
10
|
+
* seed-time sanitization is the ONLY lever agent-tempo controls — a
|
|
11
|
+
* consumption-site guard would be Pi-internal.
|
|
12
|
+
*
|
|
13
|
+
* Therefore {@link seedSessionManager} is the one and only place that calls
|
|
14
|
+
* `appendMessage`, and it runs every entry through {@link sanitizeTranscriptEntry}
|
|
15
|
+
* first. `headless.ts` MUST NOT call `appendMessage` directly.
|
|
16
|
+
*
|
|
17
|
+
* RESERVED CHOKEPOINT (#645 / H2 OPT-A outcome): no live caller passes a
|
|
18
|
+
* non-empty transcript today — Pi's `loadFromState` resume rides the existing
|
|
19
|
+
* `deliverRestart` → `receiveMessage('self-restart')` → cue-pump path (the
|
|
20
|
+
* restored-state cue lands on the in-memory session; verified by smoke +
|
|
21
|
+
* test/pi-cue-pump-restore.test.ts). `seedSessionManager` is retained as (1) the
|
|
22
|
+
* tested safety chokepoint and (2) the reserved seed gate for the DEFERRED
|
|
23
|
+
* verbatim-transcript epic; the sanitizer is defense-in-depth. NOT dead code.
|
|
24
|
+
*
|
|
25
|
+
* PURE module: no Pi SDK import. Unit-tested with a fake recording
|
|
26
|
+
* `SessionManager` (test/pi-session-seed.test.ts), mirroring
|
|
27
|
+
* `buildPiResourceLoaderOptions`' SDK-free regression test.
|
|
28
|
+
*
|
|
29
|
+
* Determinism boundary: client-side only (no wire/workflow impact).
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* A loose structural view of a Pi transcript entry. Replay data is untrusted
|
|
33
|
+
* (it may be anything that survived durable storage), so the sanitizer accepts
|
|
34
|
+
* `unknown` and narrows.
|
|
35
|
+
*/
|
|
36
|
+
export interface TranscriptEntry {
|
|
37
|
+
/** Discriminator for the `Message | CustomMessage | BashExecutionMessage` union. */
|
|
38
|
+
role?: unknown;
|
|
39
|
+
/** The crash-bearing field — must be `string | unknown[]` to survive consumption. */
|
|
40
|
+
content?: unknown;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Minimal structural view of Pi's `SessionManager` — only the method the seed
|
|
45
|
+
* path touches. Real signature: `appendMessage(msg): string` (the entry id); we
|
|
46
|
+
* type the return as `unknown` since the seed path ignores it.
|
|
47
|
+
*/
|
|
48
|
+
export interface SeedableSessionManager {
|
|
49
|
+
appendMessage(message: TranscriptEntry): unknown;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate a single replay entry. KEEP (return the entry unchanged) iff its
|
|
53
|
+
* `role` is an `appendMessage`-accepted shape AND its `content` is well-shaped
|
|
54
|
+
* (`string | array`); otherwise DROP (return `null`).
|
|
55
|
+
*
|
|
56
|
+
* DROP is the ruled default (architect): a malformed entry has no recoverable
|
|
57
|
+
* content and role-alternation is not a Pi hard-requirement. Coerce-to-`[]` is a
|
|
58
|
+
* documented one-line escape hatch reserved for a turn-pairing sensitivity that
|
|
59
|
+
* has not surfaced — it is intentionally NOT the default.
|
|
60
|
+
*
|
|
61
|
+
* Crash sites this guard protects (Pi 0.78): `agent-session.js:2486` (context
|
|
62
|
+
* build) and `:2493` (`getLastAssistantText` — `for (const c of msg.content)`).
|
|
63
|
+
*/
|
|
64
|
+
export declare function sanitizeTranscriptEntry(entry: unknown): TranscriptEntry | null;
|
|
65
|
+
/**
|
|
66
|
+
* Seed a fresh in-memory `SessionManager` from a durable replay transcript — the
|
|
67
|
+
* ONLY code path that calls `sm.appendMessage`. Each entry is run through
|
|
68
|
+
* {@link sanitizeTranscriptEntry}; survivors are appended in order, malformed
|
|
69
|
+
* entries are dropped. No-op for an empty/undefined transcript (a fresh recruit
|
|
70
|
+
* — the H1 case).
|
|
71
|
+
*
|
|
72
|
+
* @returns the number of entries actually appended (for logging + tests).
|
|
73
|
+
*/
|
|
74
|
+
export declare function seedSessionManager(sm: SeedableSessionManager, transcript: readonly unknown[] | undefined | null): number;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pi session seeding — the SINGLE chokepoint for `appendMessage` on a headless
|
|
4
|
+
* Pi `SessionManager` (H1, epic #645).
|
|
5
|
+
*
|
|
6
|
+
* WHY a chokepoint (load-bearing, PoC-verified against Pi 0.78.0):
|
|
7
|
+
* Pi's `SessionManager.appendMessage` NEVER validates `content` — every
|
|
8
|
+
* malformed shape appends "ok". The throw (`"content is not iterable"`) fires
|
|
9
|
+
* purely at CONSUMPTION: `getLastAssistantText()` /context-build /compaction
|
|
10
|
+
* scans iterate `message.content` (agent-session.js:2486/2493 in Pi 0.78). So
|
|
11
|
+
* seed-time sanitization is the ONLY lever agent-tempo controls — a
|
|
12
|
+
* consumption-site guard would be Pi-internal.
|
|
13
|
+
*
|
|
14
|
+
* Therefore {@link seedSessionManager} is the one and only place that calls
|
|
15
|
+
* `appendMessage`, and it runs every entry through {@link sanitizeTranscriptEntry}
|
|
16
|
+
* first. `headless.ts` MUST NOT call `appendMessage` directly.
|
|
17
|
+
*
|
|
18
|
+
* RESERVED CHOKEPOINT (#645 / H2 OPT-A outcome): no live caller passes a
|
|
19
|
+
* non-empty transcript today — Pi's `loadFromState` resume rides the existing
|
|
20
|
+
* `deliverRestart` → `receiveMessage('self-restart')` → cue-pump path (the
|
|
21
|
+
* restored-state cue lands on the in-memory session; verified by smoke +
|
|
22
|
+
* test/pi-cue-pump-restore.test.ts). `seedSessionManager` is retained as (1) the
|
|
23
|
+
* tested safety chokepoint and (2) the reserved seed gate for the DEFERRED
|
|
24
|
+
* verbatim-transcript epic; the sanitizer is defense-in-depth. NOT dead code.
|
|
25
|
+
*
|
|
26
|
+
* PURE module: no Pi SDK import. Unit-tested with a fake recording
|
|
27
|
+
* `SessionManager` (test/pi-session-seed.test.ts), mirroring
|
|
28
|
+
* `buildPiResourceLoaderOptions`' SDK-free regression test.
|
|
29
|
+
*
|
|
30
|
+
* Determinism boundary: client-side only (no wire/workflow impact).
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.sanitizeTranscriptEntry = sanitizeTranscriptEntry;
|
|
34
|
+
exports.seedSessionManager = seedSessionManager;
|
|
35
|
+
/**
|
|
36
|
+
* Roles `appendMessage` accepts, derived from the installed Pi 0.78 type defs
|
|
37
|
+
* (NOT assumed):
|
|
38
|
+
* - `@earendil-works/pi-ai` `Message` union → `user` | `assistant` | `toolResult`
|
|
39
|
+
* (types.d.ts:192/197/211)
|
|
40
|
+
* - `@earendil-works/pi-coding-agent` extras → `bashExecution` | `custom`
|
|
41
|
+
* (core/messages.d.ts:16/32)
|
|
42
|
+
* An entry whose `role` is not one of these is not a message Pi can append → DROP.
|
|
43
|
+
*/
|
|
44
|
+
const ACCEPTED_ROLES = new Set([
|
|
45
|
+
'user',
|
|
46
|
+
'assistant',
|
|
47
|
+
'toolResult',
|
|
48
|
+
'bashExecution',
|
|
49
|
+
'custom',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* The load-bearing predicate (PoC-confirmed against Pi 0.78.0 — no refinement
|
|
53
|
+
* needed). `content` must be a string or an array; anything else (`null`,
|
|
54
|
+
* `undefined`, `{}`, a number) is non-iterable and throws at consumption.
|
|
55
|
+
*/
|
|
56
|
+
function isWellShapedContent(content) {
|
|
57
|
+
return typeof content === 'string' || Array.isArray(content);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validate a single replay entry. KEEP (return the entry unchanged) iff its
|
|
61
|
+
* `role` is an `appendMessage`-accepted shape AND its `content` is well-shaped
|
|
62
|
+
* (`string | array`); otherwise DROP (return `null`).
|
|
63
|
+
*
|
|
64
|
+
* DROP is the ruled default (architect): a malformed entry has no recoverable
|
|
65
|
+
* content and role-alternation is not a Pi hard-requirement. Coerce-to-`[]` is a
|
|
66
|
+
* documented one-line escape hatch reserved for a turn-pairing sensitivity that
|
|
67
|
+
* has not surfaced — it is intentionally NOT the default.
|
|
68
|
+
*
|
|
69
|
+
* Crash sites this guard protects (Pi 0.78): `agent-session.js:2486` (context
|
|
70
|
+
* build) and `:2493` (`getLastAssistantText` — `for (const c of msg.content)`).
|
|
71
|
+
*/
|
|
72
|
+
function sanitizeTranscriptEntry(entry) {
|
|
73
|
+
if (entry === null || typeof entry !== 'object')
|
|
74
|
+
return null;
|
|
75
|
+
const e = entry;
|
|
76
|
+
if (typeof e.role !== 'string' || !ACCEPTED_ROLES.has(e.role))
|
|
77
|
+
return null;
|
|
78
|
+
if (!isWellShapedContent(e.content))
|
|
79
|
+
return null;
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Seed a fresh in-memory `SessionManager` from a durable replay transcript — the
|
|
84
|
+
* ONLY code path that calls `sm.appendMessage`. Each entry is run through
|
|
85
|
+
* {@link sanitizeTranscriptEntry}; survivors are appended in order, malformed
|
|
86
|
+
* entries are dropped. No-op for an empty/undefined transcript (a fresh recruit
|
|
87
|
+
* — the H1 case).
|
|
88
|
+
*
|
|
89
|
+
* @returns the number of entries actually appended (for logging + tests).
|
|
90
|
+
*/
|
|
91
|
+
function seedSessionManager(sm, transcript) {
|
|
92
|
+
if (!transcript || transcript.length === 0)
|
|
93
|
+
return 0;
|
|
94
|
+
let appended = 0;
|
|
95
|
+
for (const raw of transcript) {
|
|
96
|
+
const entry = sanitizeTranscriptEntry(raw);
|
|
97
|
+
if (entry === null)
|
|
98
|
+
continue; // DROP — never reaches the Pi session
|
|
99
|
+
sm.appendMessage(entry);
|
|
100
|
+
appended += 1;
|
|
101
|
+
}
|
|
102
|
+
return appended;
|
|
103
|
+
}
|
package/dist/tools/recruit.js
CHANGED
|
@@ -266,6 +266,21 @@ function buildRecruitTool(client, config, getPlayerId, handle, ownAgentType = 'c
|
|
|
266
266
|
return (0, descriptor_1.fail)(`agent: "copilot" requires the @github/copilot-sdk optional dependency. Install with \`npm install @github/copilot-sdk\` and retry, or use \`force: true\` to bypass this check.`);
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
|
+
// Node-floor (Decision B, #645) — AUTHORITATIVE, NON-BYPASSABLE gate, so it
|
|
270
|
+
// sits ABOVE the `&& !force` block below. Unlike sdk-probe (a filesystem
|
|
271
|
+
// heuristic with possible false-negatives, where `force` stays legitimate),
|
|
272
|
+
// the Node floor has NO false-negative — `process.versions.node` is
|
|
273
|
+
// authoritative and Pi literally cannot import on sub-22.19 — so there is no
|
|
274
|
+
// legitimate override. Checked even under `force: true`, matching the
|
|
275
|
+
// unconditional runHeadlessPi backstop, so recruit fails clean BEFORE spawn
|
|
276
|
+
// in ALL local cases. Local recruit only — cross-host (`host` set) defers to
|
|
277
|
+
// the target daemon's availableAgentTypes. Gates BOTH the Copilot and
|
|
278
|
+
// Anthropic/Pi-default paths below.
|
|
279
|
+
if (agent === 'pi' && !host) {
|
|
280
|
+
const nodeFloor = (0, probe_1.checkPiNodeFloor)();
|
|
281
|
+
if (!nodeFloor.ok)
|
|
282
|
+
return (0, descriptor_1.fail)(`agent: "pi" — ${nodeFloor.reason}`);
|
|
283
|
+
}
|
|
269
284
|
// Phase 3a — headless Pi pre-flight. The Pi SDK is an optional Node-22.19+
|
|
270
285
|
// dep required at the headless entry; gate at recruit (cross-host skips —
|
|
271
286
|
// the target daemon's availableAgentTypes is the gate there).
|