agent-tempo 1.4.0 → 1.4.2
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/client/core.js +18 -0
- package/dist/client/interface.d.ts +15 -0
- package/dist/http/writes.d.ts +1 -1
- package/dist/http/writes.js +14 -0
- package/dist/pi/headless.js +29 -3
- package/dist/pi/mission-control/actions.d.ts +1 -0
- package/dist/pi/mission-control/actions.js +3 -0
- 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 +95 -18
- 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 +32 -7
- package/dist/pi/probe.d.ts +19 -0
- package/dist/pi/probe.js +25 -0
- package/dist/pi/render-tools.d.ts +5 -5
- package/dist/pi/render-tools.js +21 -7
- 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/dist/tui/index.js +1 -0
- package/package.json +1 -1
package/dashboard/package.json
CHANGED
package/dist/client/core.js
CHANGED
|
@@ -741,6 +741,24 @@ function createTempoClientCore(client, opts = {}) {
|
|
|
741
741
|
entryId,
|
|
742
742
|
};
|
|
743
743
|
},
|
|
744
|
+
async reset(ensemble, playerId, reason) {
|
|
745
|
+
// H5b: HTTP-route counterpart to the `reset` MCP tool (D14). Enqueues the
|
|
746
|
+
// SAME `'reset'` outbox entry on the maestro outbox — no new wire. D14:
|
|
747
|
+
// reset is clean-wipe only (always `fresh: true`); `invokerPlayerId:
|
|
748
|
+
// 'maestro'` is the operator identity, surfaced to the wiped session as
|
|
749
|
+
// `requestedBy`. The caller (HTTP handler) ensures the maestro exists.
|
|
750
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
751
|
+
const h = handle(maestroId);
|
|
752
|
+
const entry = {
|
|
753
|
+
type: 'reset',
|
|
754
|
+
targetPlayerId: playerId,
|
|
755
|
+
invokerPlayerId: 'maestro',
|
|
756
|
+
fresh: true,
|
|
757
|
+
...(reason !== undefined ? { reason } : {}),
|
|
758
|
+
};
|
|
759
|
+
const entryId = await h.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
|
|
760
|
+
return { playerId, entryId };
|
|
761
|
+
},
|
|
744
762
|
async detach(ensemble, playerId, deadlineMs = 5_000) {
|
|
745
763
|
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
746
764
|
const h = handle(maestroId);
|
|
@@ -97,6 +97,12 @@ export interface RestartClientResult {
|
|
|
97
97
|
/** Outbox entry id; callers can poll `submitOutbox` history or `outboxQuery` for status. */
|
|
98
98
|
entryId: string;
|
|
99
99
|
}
|
|
100
|
+
export interface ResetClientResult {
|
|
101
|
+
/** Player the reset (clean-wipe) was queued for. */
|
|
102
|
+
playerId: string;
|
|
103
|
+
/** Outbox entry id; callers can poll `submitOutbox` history or `outboxQuery` for status. */
|
|
104
|
+
entryId: string;
|
|
105
|
+
}
|
|
100
106
|
/** Per-target outcome returned by `shutdown`. */
|
|
101
107
|
export interface EnsembleShutdownDetail {
|
|
102
108
|
playerId: string;
|
|
@@ -294,6 +300,15 @@ export interface TempoClientCore {
|
|
|
294
300
|
release(ensemble: string, playerId?: string): Promise<ReleaseClientResult>;
|
|
295
301
|
/** PR-D: Restart a player — §8.2 algorithm. Works on any non-`gone` phase. */
|
|
296
302
|
restart(ensemble: string, playerId: string, opts?: RestartClientOpts): Promise<RestartClientResult>;
|
|
303
|
+
/**
|
|
304
|
+
* H5b: Clean-wipe a player's conversation context (D14 reset) via the maestro
|
|
305
|
+
* outbox — the HTTP-route counterpart to the `reset` MCP tool. Always
|
|
306
|
+
* `fresh: true`; the operator identity (`invokerPlayerId: 'maestro'`) is
|
|
307
|
+
* surfaced to the wiped session. Reuses the existing reset machinery — no new
|
|
308
|
+
* wire. The caller must ensure the maestro session exists first (the daemon
|
|
309
|
+
* HTTP handler does, mirroring `cue`).
|
|
310
|
+
*/
|
|
311
|
+
reset(ensemble: string, playerId: string, reason?: string): Promise<ResetClientResult>;
|
|
297
312
|
/** PR-D: Gracefully detach a player's adapter. Workflow survives in `detached`. */
|
|
298
313
|
detach(ensemble: string, playerId: string, deadlineMs?: number): Promise<void>;
|
|
299
314
|
/**
|
package/dist/http/writes.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export { WRITE_BODY_MAX };
|
|
|
42
42
|
* mutations. Bodies are uniform `{ playerId, reason? }` (plus per-action
|
|
43
43
|
* extras); the ensemble lives in the URL.
|
|
44
44
|
*/
|
|
45
|
-
export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "destroy", "detach", "recall"];
|
|
45
|
+
export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "reset", "destroy", "detach", "recall"];
|
|
46
46
|
export type WriteAction = (typeof WRITE_ACTIONS)[number];
|
|
47
47
|
/** Type guard — narrows an arbitrary string to a known `WriteAction`. */
|
|
48
48
|
export declare function isWriteAction(s: string): s is WriteAction;
|
package/dist/http/writes.js
CHANGED
|
@@ -26,6 +26,7 @@ exports.WRITE_ACTIONS = [
|
|
|
26
26
|
'release',
|
|
27
27
|
'recruit',
|
|
28
28
|
'restart',
|
|
29
|
+
'reset',
|
|
29
30
|
'destroy',
|
|
30
31
|
'detach',
|
|
31
32
|
'recall',
|
|
@@ -62,6 +63,7 @@ async function handleWriteRoute(req, res, client, ensemble, action) {
|
|
|
62
63
|
case 'release': return await handleRelease(res, client, ensemble, body);
|
|
63
64
|
case 'recruit': return await handleRecruit(res, client, ensemble, body);
|
|
64
65
|
case 'restart': return await handleRestart(res, client, ensemble, body);
|
|
66
|
+
case 'reset': return await handleReset(res, client, ensemble, body);
|
|
65
67
|
case 'destroy': return await handleDestroy(res, client, ensemble, body);
|
|
66
68
|
case 'detach': return await handleDetach(res, client, ensemble, body);
|
|
67
69
|
case 'recall': return await handleRecall(res, client, ensemble, body);
|
|
@@ -169,6 +171,18 @@ async function handleRestart(res, client, ensemble, body) {
|
|
|
169
171
|
const result = await client.restart(ensemble, playerId);
|
|
170
172
|
(0, responses_1.jsonResponse)(res, 202, result);
|
|
171
173
|
}
|
|
174
|
+
async function handleReset(res, client, ensemble, body) {
|
|
175
|
+
const playerId = (0, body_1.requirePlayerId)(res, body);
|
|
176
|
+
if (!playerId)
|
|
177
|
+
return;
|
|
178
|
+
const reason = (0, body_1.stringField)(body, 'reason');
|
|
179
|
+
// Reset (D14 clean-wipe) enqueues on the maestro outbox — ensure the maestro
|
|
180
|
+
// exists first (like `cue`) so a reset before it's up doesn't 500. Idempotent
|
|
181
|
+
// (USE_EXISTING). 202 + the queued entry id, mirroring `restart`.
|
|
182
|
+
await client.ensureMaestroSession(ensemble);
|
|
183
|
+
const result = await client.reset(ensemble, playerId, reason);
|
|
184
|
+
(0, responses_1.jsonResponse)(res, 202, result);
|
|
185
|
+
}
|
|
172
186
|
async function handleDestroy(res, client, ensemble, body) {
|
|
173
187
|
const playerId = (0, body_1.requirePlayerId)(res, body);
|
|
174
188
|
if (!playerId)
|
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({});
|
|
@@ -42,6 +42,7 @@ export declare class MissionControlActions {
|
|
|
42
42
|
play(release?: boolean): Promise<ActionResult>;
|
|
43
43
|
restart(playerId: string, reason?: string): Promise<ActionResult>;
|
|
44
44
|
destroy(playerId: string, reason?: string): Promise<ActionResult>;
|
|
45
|
+
reset(playerId: string, reason?: string): Promise<ActionResult>;
|
|
45
46
|
gateArm(playerId: string): Promise<ActionResult>;
|
|
46
47
|
gateDisarm(playerId: string): Promise<ActionResult>;
|
|
47
48
|
gateDecide(playerId: string, requestId: string, decision: 'allow' | 'deny'): Promise<ActionResult>;
|
|
@@ -84,6 +84,9 @@ class MissionControlActions {
|
|
|
84
84
|
destroy(playerId, reason) {
|
|
85
85
|
return this.post(`/v1/ensembles/${this.ens()}/destroy`, { playerId, ...(reason ? { reason } : {}) });
|
|
86
86
|
}
|
|
87
|
+
reset(playerId, reason) {
|
|
88
|
+
return this.post(`/v1/ensembles/${this.ens()}/reset`, { playerId, ...(reason ? { reason } : {}) });
|
|
89
|
+
}
|
|
87
90
|
// ── Operator gate plane (T3) ──
|
|
88
91
|
gateArm(playerId) {
|
|
89
92
|
return this.post(`/v1/players/${this.player(playerId)}/gate-arm`, {});
|
|
@@ -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
|
}
|
|
@@ -105,15 +161,13 @@ class Controller {
|
|
|
105
161
|
this.report(ctx, `destroy ${p}`, await this.actions.destroy(p, reason || undefined));
|
|
106
162
|
}
|
|
107
163
|
async cmdReset(args, ctx) {
|
|
108
|
-
|
|
164
|
+
// H5b: real POST /v1/ensembles/:e/reset (D14 clean-wipe) — mirrors cmdRestart.
|
|
165
|
+
const [p, reason] = Controller.splitFirst(args);
|
|
109
166
|
if (!p) {
|
|
110
|
-
this.notify(ctx, 'Usage: /reset <player>');
|
|
167
|
+
this.notify(ctx, 'Usage: /reset <player> [reason]');
|
|
111
168
|
return;
|
|
112
169
|
}
|
|
113
|
-
|
|
114
|
-
// rather than silently fail. Wiring a POST /v1/ensembles/:e/reset is a daemon
|
|
115
|
-
// follow-up (flagged to the conductor).
|
|
116
|
-
this.notify(ctx, `reset ${p}: not available over the daemon HTTP surface yet (MCP/outbox only). Flagged for a daemon route.`);
|
|
170
|
+
this.report(ctx, `reset ${p}`, await this.actions.reset(p, reason || undefined));
|
|
117
171
|
}
|
|
118
172
|
async cmdArm(args, ctx) {
|
|
119
173
|
const [p, mode] = Controller.splitFirst(args);
|
|
@@ -156,10 +210,21 @@ function createMissionControlExtension(deps = {}) {
|
|
|
156
210
|
return (pi) => {
|
|
157
211
|
const ensemble = deps.ensemble ?? (0, config_1.getConfig)().ensemble;
|
|
158
212
|
const adminToken = deps.adminToken ?? process.env[actions_1.ADMIN_TOKEN_ENV];
|
|
159
|
-
const baseUrl = resolveBaseUrl(deps.baseUrl);
|
|
160
213
|
const throttleMs = deps.renderThrottleMs ?? DEFAULT_RENDER_THROTTLE_MS;
|
|
161
|
-
|
|
162
|
-
|
|
214
|
+
// H3a: mission-control is co-located with its 127.0.0.1 daemon, so this
|
|
215
|
+
// process's hostname IS the daemon's host. (HealthV1.hostname is the noted
|
|
216
|
+
// future upgrade for a baseUrl pointing at a remote daemon — not built here.)
|
|
217
|
+
const localHost = deps.localHost ?? os.hostname();
|
|
218
|
+
// H5: do NOT pre-resolve baseUrl. createSubscribe + MissionControlActions both
|
|
219
|
+
// re-read ~/.agent-tempo/daemon.port per-call when baseUrl is undefined, so a
|
|
220
|
+
// daemon restart on a new port self-heals (a once-resolved URL wedges the
|
|
221
|
+
// board). A `deps.baseUrl` override (tests / future remote daemon) still wins.
|
|
222
|
+
const actions = new actions_1.MissionControlActions({
|
|
223
|
+
ensemble,
|
|
224
|
+
...(adminToken ? { adminToken } : {}),
|
|
225
|
+
...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
|
|
226
|
+
});
|
|
227
|
+
const ctrl = new Controller(ensemble, actions, localHost);
|
|
163
228
|
// Per-session lifecycle state (re-created on each session_start).
|
|
164
229
|
let coarseAbort = null;
|
|
165
230
|
let tailAbort = null;
|
|
@@ -172,22 +237,31 @@ function createMissionControlExtension(deps = {}) {
|
|
|
172
237
|
if (ctrl.model.revision === lastRenderedRevision)
|
|
173
238
|
return; // throttle: skip no-op ticks
|
|
174
239
|
lastRenderedRevision = ctrl.model.revision;
|
|
175
|
-
activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model), { placement: 'aboveEditor' });
|
|
240
|
+
activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model, ctrl.localHost), { placement: 'aboveEditor' });
|
|
176
241
|
};
|
|
177
242
|
const startCoarse = () => {
|
|
178
243
|
if (!adminToken) {
|
|
179
244
|
log(`no admin token (${actions_1.ADMIN_TOKEN_ENV}) — board limited / disabled`);
|
|
180
245
|
}
|
|
181
|
-
|
|
182
|
-
|
|
246
|
+
// H5: capture the controller locally. teardown nulls the outer `coarseAbort`,
|
|
247
|
+
// so the catch must check THIS signal — checking the nulled outer ref made an
|
|
248
|
+
// expected teardown abort log a spurious "coarse SSE ended: AbortError".
|
|
249
|
+
const ac = new AbortController();
|
|
250
|
+
coarseAbort = ac;
|
|
251
|
+
// H5: omit baseUrl → createSubscribe re-resolves the daemon port per
|
|
252
|
+
// (re)connect, so a daemon restart on a new port self-heals.
|
|
253
|
+
const subscribe = (0, subscribe_1.createSubscribe)({
|
|
254
|
+
...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
|
|
255
|
+
...(adminToken ? { token: adminToken } : {}),
|
|
256
|
+
});
|
|
183
257
|
void (async () => {
|
|
184
258
|
try {
|
|
185
|
-
for await (const ev of subscribe(ensemble, { signal:
|
|
259
|
+
for await (const ev of subscribe(ensemble, { signal: ac.signal })) {
|
|
186
260
|
(0, board_1.applyTempoEvent)(ctrl.model, ev);
|
|
187
261
|
}
|
|
188
262
|
}
|
|
189
263
|
catch (err) {
|
|
190
|
-
if (!
|
|
264
|
+
if (!ac.signal.aborted)
|
|
191
265
|
log('coarse SSE ended:', err instanceof Error ? err.message : err);
|
|
192
266
|
}
|
|
193
267
|
})();
|
|
@@ -198,6 +272,9 @@ function createMissionControlExtension(deps = {}) {
|
|
|
198
272
|
if (playerId === null || !adminToken)
|
|
199
273
|
return;
|
|
200
274
|
tailAbort = new AbortController();
|
|
275
|
+
// H5: resolve the daemon base URL HERE (per /tail) so a port change is
|
|
276
|
+
// picked up on the next tail instead of being pinned at session start.
|
|
277
|
+
const baseUrl = resolveBaseUrl(deps.baseUrl);
|
|
201
278
|
const fetchFn = globalThis.fetch;
|
|
202
279
|
if (!fetchFn)
|
|
203
280
|
return;
|
|
@@ -241,7 +318,7 @@ function createMissionControlExtension(deps = {}) {
|
|
|
241
318
|
pi.registerCommand('play', { description: 'Resume the ensemble (/play [release])', handler: (a, ctx) => ctrl.cmdPlay(a, ctx) });
|
|
242
319
|
pi.registerCommand('restart', { description: 'Restart a player (/restart <player> [reason])', handler: (a, ctx) => ctrl.cmdRestart(a, ctx) });
|
|
243
320
|
pi.registerCommand('destroy', { description: 'Destroy a player (/destroy <player> [reason])', handler: (a, ctx) => ctrl.cmdDestroy(a, ctx) });
|
|
244
|
-
pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player>)', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
|
|
321
|
+
pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player> [reason])', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
|
|
245
322
|
pi.registerCommand('arm', { description: 'Arm/disarm the operator gate for a player (/arm <player> [off])', handler: (a, ctx) => ctrl.cmdArm(a, ctx) });
|
|
246
323
|
pi.registerCommand('gate', { description: 'Decide a gate request for the tailed player (/gate <reqId> allow|deny)', handler: (a, ctx) => ctrl.cmdGate(a, ctx) });
|
|
247
324
|
};
|
|
@@ -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
|
@@ -179,14 +179,25 @@ export interface PiToolCallResult {
|
|
|
179
179
|
reason?: string;
|
|
180
180
|
}
|
|
181
181
|
/**
|
|
182
|
-
* Pi tool result
|
|
183
|
-
* (
|
|
184
|
-
* is
|
|
182
|
+
* Pi tool result — a Pi-free structural mirror of the real `AgentToolResult`
|
|
183
|
+
* (#653, 1.4.2). The Phase-0 `{ output, isError }` guess was WRONG: Pi's real
|
|
184
|
+
* `AgentToolResult` is `{ content: (TextContent|ImageContent)[]; details; terminate? }`
|
|
185
|
+
* (pi-agent-core types.d.ts:305), so `{ output, isError }` made every native
|
|
186
|
+
* agent-tempo tool result malformed for a Pi model (no `content[]` — the
|
|
187
|
+
* RESULT-shape mirror of #651's INPUT-shape bug).
|
|
188
|
+
*
|
|
189
|
+
* We only ever emit TEXT content. Errors are NOT content-encoded — they are
|
|
190
|
+
* THROWN from `execute` (Pi's sanctioned error path; the loop catches → sets
|
|
191
|
+
* isError + folds the message into content). So this success-shape carries no
|
|
192
|
+
* error flag. See `toPiResult` / `renderToPi` in render-tools.ts.
|
|
185
193
|
*/
|
|
186
194
|
export interface PiToolResult {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
195
|
+
content: Array<{
|
|
196
|
+
type: 'text';
|
|
197
|
+
text: string;
|
|
198
|
+
}>;
|
|
199
|
+
details: Record<string, unknown>;
|
|
200
|
+
terminate?: boolean;
|
|
190
201
|
}
|
|
191
202
|
/**
|
|
192
203
|
* Pi native tool definition. `parameters` is a TypeBox schema (NOT zod) — see
|
|
@@ -197,7 +208,21 @@ export interface PiToolDefinition {
|
|
|
197
208
|
description?: string;
|
|
198
209
|
/** TypeBox schema object. Typed `unknown` to avoid coupling pi-types to typebox. */
|
|
199
210
|
parameters: unknown;
|
|
200
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Pi invokes `execute` POSITIONALLY at runtime:
|
|
213
|
+
* execute(toolCallId, params, signal?, onUpdate?, ctx?)
|
|
214
|
+
* (installed Pi 0.78 `core/extensions/types.d.ts:354` + the wrapper at
|
|
215
|
+
* `core/tools/tool-definition-wrapper.js:10,31`). The VALIDATED params object
|
|
216
|
+
* is the SECOND positional — the FIRST is the `toolCallId` string.
|
|
217
|
+
*
|
|
218
|
+
* Declaring the full positional shape (not a single `args`) is LOAD-BEARING: a
|
|
219
|
+
* one-arg signature silently accepts `(args) => handler(args)`, which binds the
|
|
220
|
+
* toolCallId string to what the handler treats as the params object — the
|
|
221
|
+
* shipped arg-order bug (v1.4.0) this corrected type now makes a compile error.
|
|
222
|
+
* `signal`/`onUpdate`/`ctx` are optional + `unknown`: we don't consume them and
|
|
223
|
+
* avoid coupling to Pi's internal callback/context types.
|
|
224
|
+
*/
|
|
225
|
+
execute: (toolCallId: string, params: Record<string, unknown>, signal?: unknown, onUpdate?: unknown, ctx?: unknown) => Promise<PiToolResult> | PiToolResult;
|
|
201
226
|
}
|
|
202
227
|
/** The `pi` object passed to `export default function(pi: ExtensionAPI) {}`. */
|
|
203
228
|
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'));
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { TempoToolDescriptor, TempoToolResult } from '../tools/descriptor';
|
|
2
2
|
import type { ExtensionAPI, PiToolResult } from './pi-types';
|
|
3
3
|
/**
|
|
4
|
-
* Map a neutral {@link TempoToolResult} onto Pi's `AgentToolResult`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
4
|
+
* Map a SUCCESSFUL neutral {@link TempoToolResult} onto Pi's `AgentToolResult`
|
|
5
|
+
* shape (#653, 1.4.2): the text becomes a single text-content block. ERRORS are
|
|
6
|
+
* NOT mapped here — they are thrown from `execute` (Pi's sanctioned error path),
|
|
7
|
+
* so callers must check `r.isError` BEFORE calling this. `details` is an empty
|
|
8
|
+
* object (we surface no structured details); `terminate` is left unset.
|
|
9
9
|
*/
|
|
10
10
|
export declare function toPiResult(r: TempoToolResult): PiToolResult;
|
|
11
11
|
/**
|
package/dist/pi/render-tools.js
CHANGED
|
@@ -24,14 +24,14 @@ exports.renderToPi = renderToPi;
|
|
|
24
24
|
*/
|
|
25
25
|
const zod_to_typebox_1 = require("./zod-to-typebox");
|
|
26
26
|
/**
|
|
27
|
-
* Map a neutral {@link TempoToolResult} onto Pi's `AgentToolResult`
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
27
|
+
* Map a SUCCESSFUL neutral {@link TempoToolResult} onto Pi's `AgentToolResult`
|
|
28
|
+
* shape (#653, 1.4.2): the text becomes a single text-content block. ERRORS are
|
|
29
|
+
* NOT mapped here — they are thrown from `execute` (Pi's sanctioned error path),
|
|
30
|
+
* so callers must check `r.isError` BEFORE calling this. `details` is an empty
|
|
31
|
+
* object (we surface no structured details); `terminate` is left unset.
|
|
32
32
|
*/
|
|
33
33
|
function toPiResult(r) {
|
|
34
|
-
return
|
|
34
|
+
return { content: [{ type: 'text', text: r.text }], details: {} };
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Register every descriptor onto the Pi extension API. The TypeBox schema is
|
|
@@ -45,7 +45,21 @@ 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) => {
|
|
54
|
+
const r = await d.handler(params);
|
|
55
|
+
// Pi's sanctioned error path (#653): THROW on failure — the agent loop
|
|
56
|
+
// catches it → createErrorToolResult (message → content) + isError:true.
|
|
57
|
+
// Content-encoding an error WITHOUT throwing would make the model think
|
|
58
|
+
// the tool SUCCEEDED (Pi types.d.ts:327). So errors never reach toPiResult.
|
|
59
|
+
if (r.isError)
|
|
60
|
+
throw new Error(r.text);
|
|
61
|
+
return toPiResult(r);
|
|
62
|
+
},
|
|
49
63
|
});
|
|
50
64
|
}
|
|
51
65
|
}
|
|
@@ -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).
|
package/dist/tui/index.js
CHANGED