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.
@@ -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.2",
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": {
@@ -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
  /**
@@ -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;
@@ -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)
@@ -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({});
@@ -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
- 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
  }
@@ -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
- const p = args.trim();
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
- // D14 reset has NO daemon HTTP route yet (MCP/outbox only). Surface clearly
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
- const actions = new actions_1.MissionControlActions({ ensemble, ...(adminToken ? { adminToken } : {}), baseUrl });
162
- const ctrl = new Controller(ensemble, actions);
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
- coarseAbort = new AbortController();
182
- const subscribe = (0, subscribe_1.createSubscribe)({ baseUrl, ...(adminToken ? { token: adminToken } : {}) });
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: coarseAbort.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 (!coarseAbort?.signal.aborted)
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
- // 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) {
@@ -179,14 +179,25 @@ export interface PiToolCallResult {
179
179
  reason?: string;
180
180
  }
181
181
  /**
182
- * Pi tool result (`AgentToolResult`). The exact streaming shape is UNCONFIRMED
183
- * (spike gap D12b) Phase 0 uses the minimal `{ output, isError }` form, which
184
- * is sufficient for a non-streaming tool like `report`.
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
- output?: string;
188
- isError?: boolean;
189
- [key: string]: unknown;
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
- execute: (args: Record<string, unknown>) => Promise<PiToolResult> | PiToolResult;
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 {
@@ -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` shape.
5
- *
6
- * Phase 0 confirmed Pi's result is `{ output, isError }` for a non-streaming
7
- * tool (D12). The neutral `{ text, isError? }` maps directly: `text output`,
8
- * `isError` passes through.
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
  /**
@@ -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` shape.
28
- *
29
- * Phase 0 confirmed Pi's result is `{ output, isError }` for a non-streaming
30
- * tool (D12). The neutral `{ text, isError? }` maps directly: `text output`,
31
- * `isError` passes through.
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 r.isError ? { output: r.text, isError: true } : { output: r.text };
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: 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) => {
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
+ }
@@ -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
@@ -118,6 +118,7 @@ function createDummyClient() {
118
118
  recruit: fail,
119
119
  release: fail,
120
120
  restart: fail,
121
+ reset: fail,
121
122
  detach: fail,
122
123
  destroy: fail,
123
124
  migrate: fail,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",