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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "type": "module",
6
6
  "description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
7
7
  "scripts": {
@@ -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
- // NOTE (A4): restart-resume via a SessionManager seeded from
175
- // opts.continueSessionId / ENV.PI_CONTINUE_SESSION lands in A4; 3a proves the
176
- // loop on a fresh session.
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
- if (!(0, board_1.selectPlayer)(this.model, target)) {
70
- this.notify(ctx, `No such player: ${target}`);
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
- const actions = new actions_1.MissionControlActions({ ensemble, ...(adminToken ? { adminToken } : {}), baseUrl });
162
- const ctrl = new Controller(ensemble, actions);
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
- coarseAbort = new AbortController();
182
- const subscribe = (0, subscribe_1.createSubscribe)({ baseUrl, ...(adminToken ? { token: adminToken } : {}) });
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: coarseAbort.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 (!coarseAbort?.signal.aborted)
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
- // sel glyph id part tool ctx
34
- return [`${sel}${glyph} ${row.playerId}`, part, tool, ctx]
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) {
@@ -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
- execute: (args: Record<string, unknown>) => Promise<PiToolResult> | PiToolResult;
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 {
@@ -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'));
@@ -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: async (args) => toPiResult(await d.handler(args)),
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
+ }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",