agent-tempo 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -210,7 +210,7 @@ daemon worker notes, `npx ts-node` dev runner).
210
210
  - **Claude API adapter** (`agent: 'claude-api'`, #131): Headless adapter that drives sessions via the Anthropic Messages API (`@anthropic-ai/sdk`) — no terminal, no Claude Code CLI. Requires `ANTHROPIC_API_KEY` env var and the `@anthropic-ai/sdk` optional dependency. Default model `claude-opus-4-7` (overridable via `model` recruit arg or `CLAUDE_TEMPO_API_MODEL` env). Claude-API players have access to agent-tempo MCP tools (cue, report, recall, ensemble, …) but not file-edit/shell/web tools. See `src/adapters/claude-api/`.
211
211
  - **OpenCode adapter** (`agent: 'opencode'`, #449): Headless multi-provider adapter that drives sessions via [SST OpenCode](https://opencode.ai) as a managed subprocess — supports Anthropic, OpenAI, Bedrock, Vertex, Ollama, and ~70 other providers via OpenCode's `provider/model` selector. Requires OpenCode CLI (`npm install -g opencode-ai`) and the `@opencode-ai/sdk` optional dependency. Recruit with `model: 'provider/name'` (e.g. `'anthropic/claude-opus-4-7'`). Tool bridging is MCP-native — OpenCode spawns `dist/server.js` as its own stdio MCP child. Session state is persisted server-side by OpenCode; the adapter stashes the session id on workflow metadata for reconnect across `opencode serve` restarts. See `src/adapters/opencode/`.
212
212
  - **Claude Code headless adapter** (`agent: 'claude-code-headless'`, #520): Headless adapter that drives sessions via the official `claude` CLI as a per-turn `claude -p --output-format stream-json` subprocess. The whole point: turns bill against the host's existing Claude Code subscription extra-usage credits (Pro / Max plans) rather than a Console workspace API key — the only ToS-clean way for a third-party tool to tap that pool. Requires the `claude` binary on PATH AND a logged-in Claude Code session (`claude auth login`); recruit pre-flight rejects with an actionable error otherwise. Tool surface is the union of full Claude Code built-ins (Bash / Read / Write / Edit / Glob / Grep / WebSearch / WebFetch) and the agent-tempo MCP surface — registered via inline `--mcp-config` so `claude` spawns `dist/server.js` as its own MCP child (no in-process bridge). Recruit knobs: `permissionMode` (default `'acceptEdits'`) or `dangerouslySkipPermissions: true` (mutually exclusive). Sessions resume across restart via the existing `sessionId` metadata field — the same UUID is shared with the interactive `claude-code` adapter (per-cwd JSONL is per-cwd, not per-adapter). See `src/adapters/claude-code-headless/` and `examples/ensembles/tempo-headless-jam.yaml`.
213
- - **Pi adapter** (`agent: 'pi'`, #632 Phase 3a): Headless adapter that runs a Pi AI session as a first-class agent-tempo player — no terminal, no BaseAttachment. Recruit injects the Phase 2 `src/pi` extension into Pi's `createAgentSession`; the module-scope singleton owns claim/heartbeat/tools/cue pump (MD-D). Requires the `pi-ai` optional dependency (`npm install -g pi-ai`) on Node 22.19+. Optional `model: 'provider/model'` selector (e.g. `'anthropic/claude-opus-4-7'`, `'github-copilot/gpt-4o'`); absent Pi's own default. MD-C tool-access policy: `toolAccess: 'restricted'` (default — Bash/shell/exec HARD-BLOCKED) | `'standard'` (scoped Bash) | `'full'` (unsandboxed; requires `force: true`). `noExtensions: true` closes the S2 exec-tool-bypass gap (no disk/package extensions load alongside the agent-tempo extension). See `src/adapters/pi/` and `src/pi/headless.ts`.
213
+ - **Pi adapter** (`agent: 'pi'`, #632 / #666): Two modes. **(1) Interactive conductor** (#666): `agent-tempo up --agent pi --ensemble <name>` launches `pi` in a real terminal with the agent-tempo extension auto-loaded (`pi -e dist/pi/extension.js`); the Pi session self-bootstraps its Temporal workflow and attaches as a conductor/player — no separate recruiter step. From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor — set `conductor.agent: pi` in that ensemble's lineup to make it a Pi conductor. Requires `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model). The `AGENT_TEMPO_*` env is auto-wired by `up`; power users can invoke the extension directly with `pi -e dist/pi/extension.js`. `--model provider/model` selector (e.g. `'github-copilot/gpt-4o'`) is a fast-follow. **(2) Headless player** (Phase 3a): `recruit` with `agent: 'pi'` no terminal, no BaseAttachment; runs `createAgentSession` with an in-memory `SessionManager`; the module-scope singleton owns claim/heartbeat/tools/cue pump (MD-D). MD-C tool-access policy: `toolAccess: 'restricted'` (default — Bash/shell/exec HARD-BLOCKED) | `'standard'` (scoped Bash) | `'full'` (unsandboxed; requires `force: true`). `noExtensions: true` closes the S2 exec-tool-bypass gap. See `src/adapters/pi/` and `src/pi/headless.ts`.
214
214
  - **Mission-control Pi extension** (3f): An observer-only Pi extension that turns one interactive Pi TUI into a live ensemble board + operator controller. HTTP-drives the daemon — coarse ensemble view via `/v1/events/:ensemble` SSE + fine per-player tail via `/inner` (T3); operator controls (cue/pause/play/restart/destroy + gate arm/disarm/decide) POST to the daemon write surface using `AGENT_TEMPO_HTTP_ADMIN_TOKEN`. **Never claims attachment or registers as a player** — invisible to the ensemble. ~200ms render throttle from an in-memory `BoardModel`. Requires an interactive Pi session with `ctx.hasUI`. See `src/pi/mission-control/`.
215
215
  - **Mock adapter** (`agent: 'mock'`, dev mode only): Four modes: `echo` (echoes input), `scripted` (replays YAML scenario rules), `silent` (drains messages without replying — heartbeat-stale validation), `chaos` (probabilistic fail/crash injection via seeded PRNG). Only registered when `isDevMode()` is true; stripped from the npm tarball by `prepack`. See `src/adapters/mock/`.
216
216
  - **Saveable state** (#334, ADR 0011): Per-player curated state slots — the player itself decides what context survives a restart. Three MCP tools: `save_state` (owner-only write, max 4 slots × 32 KiB), `fetch_state` (read self or peer; audit identity recorded on each entry's `savedBy`), `clear_state` (owner-only). `restart` accepts `loadFromState: true | 'someKey'` to seed the new session from a saved-state slot instead of (or, with `transcript: 'replay'`, alongside) transcript replay. Saved-state delivery uses `from: 'self-restart'` as a stable system identity. Empty-slot fallback: graceful — falls through to transcript replay with a log line. See [docs/design/334-player-saveable-state.md](docs/design/334-player-saveable-state.md).
package/README.md CHANGED
@@ -37,7 +37,7 @@ Each session registers as a **player** in Temporal. Players discover each other
37
37
  | 🖥️ **Terminal UI** | Chat-focused TUI with slash commands, overlays, and interactive wizards |
38
38
  | 🌐 **Cross-machine** | Any session that can reach your Temporal server can join the ensemble |
39
39
  | ⏸️ **Hold / Pause / Resume** | Pre-warm a full team before delivering tasks; pause and resume mid-session |
40
- | 🤖 **Headless adapters** | Copilot bridge, Claude API, OpenCode, Claude Code headless (`claude -p` — bills against your Claude Code subscription), and Pi AI — mix providers and headless agents in the same ensemble |
40
+ | 🤖 **Headless adapters** | Copilot bridge, Claude API, OpenCode, Claude Code headless (`claude -p` — bills against your Claude Code subscription), and Pi AI (headless player or interactive conductor) — mix providers and headless agents in the same ensemble |
41
41
 
42
42
  ## Installation
43
43
 
@@ -261,6 +261,26 @@ GitHub Copilot CLI sessions can join an ensemble using `--agent copilot`. Recrui
261
261
 
262
262
  📖 [Copilot bridge setup and limitations → docs/copilot.md](docs/copilot.md)
263
263
 
264
+ ## Pi AI Integration
265
+
266
+ Pi AI sessions can join an ensemble in two modes:
267
+
268
+ **Interactive conductor** — launch Pi in a real terminal with the agent-tempo extension auto-loaded:
269
+
270
+ ```
271
+ agent-tempo up --agent pi --ensemble <name>
272
+ ```
273
+
274
+ The Pi session self-bootstraps its Temporal workflow and attaches as a conductor or player. The `AGENT_TEMPO_*` environment is wired automatically. For power users, the underlying extension path is `dist/pi/extension.js` — invoke directly with `pi -e dist/pi/extension.js`.
275
+
276
+ From the TUI, `/recruit-conductor` relaunches the active ensemble's conductor — set `conductor.agent: pi` in that ensemble's lineup to make it a Pi conductor.
277
+
278
+ **Prerequisites:** `@earendil-works/pi-coding-agent` on Node ≥ 22.19. Recommended: `ANTHROPIC_API_KEY` (without it the session falls back to Pi's own auth/default model).
279
+
280
+ **Headless Pi players** — recruit as a background agent slot using `agent: 'pi'` (see [docs/design/pi-hardening-h1-h2-h3.md](docs/design/pi-hardening-h1-h2-h3.md)).
281
+
282
+ 📖 [Pi integration reference → docs/design/pi-hardening-h1-h2-h3.md](docs/design/pi-hardening-h1-h2-h3.md)
283
+
264
284
  ## Worker Daemon
265
285
 
266
286
  The daemon runs Temporal workers as a background process — it starts automatically on first use. Manage it explicitly with `agent-tempo daemon start|stop|status|logs`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.4.1",
4
+ "version": "1.5.0",
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": {
@@ -62,6 +62,7 @@ const crypto_1 = require("crypto");
62
62
  const croner_1 = require("croner");
63
63
  const client_1 = require("@temporalio/client");
64
64
  const spawn_1 = require("../spawn");
65
+ const probe_1 = require("../pi/probe");
65
66
  const config_1 = require("../config");
66
67
  const git_info_1 = require("../git-info");
67
68
  const connection_1 = require("../connection");
@@ -1163,8 +1164,9 @@ async function up(opts) {
1163
1164
  // outside dev mode so a mis-configured lineup doesn't spawn a real session
1164
1165
  // unexpectedly (mirrors the player-level guard at ~line 209).
1165
1166
  const conductorAgent = lineup?.conductor?.agent === 'copilot' ? 'copilot' :
1166
- lineup?.conductor?.agent === 'mock' && (0, config_1.isDevMode)() ? 'mock' :
1167
- opts.agent;
1167
+ lineup?.conductor?.agent === 'pi' ? 'pi' :
1168
+ lineup?.conductor?.agent === 'mock' && (0, config_1.isDevMode)() ? 'mock' :
1169
+ opts.agent;
1168
1170
  // Step 5: Connect to Temporal and check for existing conductor
1169
1171
  console.log();
1170
1172
  const connection = await (0, connection_1.createTemporalConnection)(config);
@@ -1304,6 +1306,41 @@ async function up(opts) {
1304
1306
  workDir: process.cwd(),
1305
1307
  }));
1306
1308
  }
1309
+ else if (conductorAgent === 'pi') {
1310
+ // Interactive Pi conductor (#666). MUST launch `pi` in a REAL TERMINAL —
1311
+ // Pi only fires session_start / attaches in a TTY (headless/print-mode does
1312
+ // NOT). So this uses launchInTerminal, NOT spawnPiHeadless (that's recruited
1313
+ // players). One branch serves `up --agent pi` AND TUI /recruit-conductor.
1314
+ //
1315
+ // PREFLIGHT — fail clean BEFORE launching a terminal that would die:
1316
+ const nodeFloor = (0, probe_1.checkPiNodeFloor)(); // best-effort proxy on the daemon's Node
1317
+ if (!nodeFloor.ok) {
1318
+ out.error(`Cannot start Pi conductor — ${nodeFloor.reason}`);
1319
+ process.exit(1);
1320
+ }
1321
+ if (!process.env.ANTHROPIC_API_KEY) {
1322
+ out.warn('ANTHROPIC_API_KEY is not set — the Pi conductor will fall back to Pi\'s own auth/default model. Set it if Pi needs an Anthropic key.');
1323
+ }
1324
+ let piSpawn;
1325
+ try {
1326
+ // resolvePiInteractiveBinary / resolvePiExtensionPath throw fail-clean
1327
+ // (Pi CLI missing / extension unbuilt) — caught here, no terminal launched.
1328
+ piSpawn = (0, spawn_1.buildPiConductorSpawn)({
1329
+ ensemble: opts.ensemble,
1330
+ sessionName,
1331
+ temporalEnvVars,
1332
+ taskQueue: config.taskQueue,
1333
+ devMode: (0, config_1.isDevMode)(),
1334
+ conductorTypeName: resolvedConductorType?.name || conductorTypeName,
1335
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
1336
+ });
1337
+ }
1338
+ catch (err) {
1339
+ out.error(`Cannot start Pi conductor — ${err instanceof Error ? err.message : String(err)}`);
1340
+ process.exit(1);
1341
+ }
1342
+ ({ pid } = (0, spawn_1.launchInTerminal)(piSpawn.cmd, piSpawn.args, process.cwd(), piSpawn.env));
1343
+ }
1307
1344
  else {
1308
1345
  const claudeArgs = [
1309
1346
  '--dangerously-skip-permissions',
@@ -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)
@@ -1,15 +1,57 @@
1
1
  import type { Client } from '@temporalio/client';
2
2
  import { type Config } from '../config';
3
3
  import type { ExtensionAPI, PiAgentSession } from './pi-types';
4
+ import { PhaseDriver } from './phase-driver';
5
+ import { PiWorkflowClient } from './workflow-client';
6
+ import { CuePump } from './cue-pump';
7
+ import { ResetPump } from './reset-pump';
8
+ import { InnerLoopPublisher } from './inner-loop-publisher';
4
9
  /** Runtime mode. Headless = recruited unsupervised player (MD-C gate active). */
5
10
  export type PiExtensionMode = 'interactive' | 'headless';
6
11
  export type PiToolAccess = 'restricted' | 'standard' | 'full';
12
+ /**
13
+ * B1 runtime guard (#645 H4) — the type gate's blind spot.
14
+ *
15
+ * `PiEventPayload.session` is UNDECLARED in Pi 0.78's `.d.ts` — it's an
16
+ * interactive-only RUNTIME field, so the pi-drift type gate can't assert it. In
17
+ * INTERACTIVE mode the `session_start` payload MUST carry `session` (the cue +
18
+ * reset pumps inject into it); a null session there means injection is silently
19
+ * inert — a likely Pi API drift. (Headless legitimately omits it — it wires
20
+ * `rt.session` via `setRuntimeSession` — so the guard is interactive-only.)
21
+ *
22
+ * Pure + injected `warn` so it unit-tests without the workflow harness.
23
+ */
24
+ export declare function warnIfInteractiveSessionMissing(mode: PiExtensionMode, payload: {
25
+ session?: unknown;
26
+ }, warn: (msg: string) => void): void;
7
27
  export interface PiExtensionOptions {
8
28
  /** Default `'interactive'`. Headless installs the MD-C tool_call gate. */
9
29
  mode?: PiExtensionMode;
10
30
  /** MD-C tool-class policy (headless only). Default `'restricted'`. */
11
31
  toolAccess?: PiToolAccess;
12
32
  }
33
+ /**
34
+ * Per-PLAYER runtime — lives in the module-scope `runtimes` map and SURVIVES Pi
35
+ * extension-instance rebuilds. Holds the durable attachment (handle + lease +
36
+ * heartbeat, inside `wf`), the phase driver, the cue pump, and the session ptr.
37
+ */
38
+ interface PiPlayerRuntime {
39
+ readonly workflowId: string;
40
+ readonly wf: PiWorkflowClient;
41
+ readonly driver: PhaseDriver;
42
+ readonly pump: CuePump;
43
+ /**
44
+ * 3c — inner-loop publisher: observes Pi events to maintain Tier-1 coarse state
45
+ * (sampled by the heartbeat via `wf.setCoarseProvider`) and forward Tier-2 fine
46
+ * frames to the daemon (presence-gated, off-wire). Started on first attach,
47
+ * stopped on teardown.
48
+ */
49
+ readonly pub: InnerLoopPublisher;
50
+ /** 3d D14 — polls the workflow's pending reset → clean-wipe (newSession) + ack. */
51
+ readonly reset: ResetPump;
52
+ session: PiAgentSession | null;
53
+ lastSessionId?: string;
54
+ }
13
55
  /**
14
56
  * Build the Pi extension factory. `mode='headless'` installs the MD-C tool_call
15
57
  * gate; `mode='interactive'` (default) does not (the human owns their machine).
@@ -40,6 +82,18 @@ export declare function setRuntimeSession(workflowId: string, session: PiAgentSe
40
82
  export declare function __setPiClientFactoryForTests(factory: (config: Config) => Promise<Client>): void;
41
83
  /** Stop timers, clear the per-player runtime map + shared-client singletons + factory. */
42
84
  export declare function __resetPiRuntimesForTests(): void;
85
+ /**
86
+ * Seed a fake runtime into the module-scope `runtimes` map. TEST ESCAPE HATCH —
87
+ * do NOT call from production code. The map is otherwise unreachable from a test;
88
+ * this is the seam for covering lifecycle paths like {@link detachAllPiRuntimesForExit}.
89
+ */
90
+ export declare function __seedRuntimeForTests(workflowId: string, rt: PiPlayerRuntime): void;
91
+ /**
92
+ * Clear the module-scope `runtimes` map WITHOUT timer/heartbeat teardown (for
93
+ * afterEach isolation in runtime-seeding tests; use {@link __resetPiRuntimesForTests}
94
+ * for the full singleton reset). TEST ESCAPE HATCH — do NOT call from production code.
95
+ */
96
+ export declare function __clearRuntimesForTests(): void;
43
97
  /** Default export — interactive-mode extension (the human `pi` CLI entry). */
44
98
  declare const piExtension: (pi: ExtensionAPI) => void;
45
99
  export default piExtension;
@@ -33,11 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.warnIfInteractiveSessionMissing = warnIfInteractiveSessionMissing;
36
37
  exports.createPiExtension = createPiExtension;
37
38
  exports.detachAllPiRuntimesForExit = detachAllPiRuntimesForExit;
38
39
  exports.setRuntimeSession = setRuntimeSession;
39
40
  exports.__setPiClientFactoryForTests = __setPiClientFactoryForTests;
40
41
  exports.__resetPiRuntimesForTests = __resetPiRuntimesForTests;
42
+ exports.__seedRuntimeForTests = __seedRuntimeForTests;
43
+ exports.__clearRuntimesForTests = __clearRuntimesForTests;
41
44
  /**
42
45
  * agent-tempo Pi extension — interactive (Phase 2) + headless (Phase 3a) runtime.
43
46
  *
@@ -91,6 +94,24 @@ const log = (...args) => {
91
94
  };
92
95
  const nowIso = () => new Date().toISOString();
93
96
  const PI_AGENT_TYPE = 'claude'; // Pi is not yet a first-class AgentType.
97
+ /**
98
+ * B1 runtime guard (#645 H4) — the type gate's blind spot.
99
+ *
100
+ * `PiEventPayload.session` is UNDECLARED in Pi 0.78's `.d.ts` — it's an
101
+ * interactive-only RUNTIME field, so the pi-drift type gate can't assert it. In
102
+ * INTERACTIVE mode the `session_start` payload MUST carry `session` (the cue +
103
+ * reset pumps inject into it); a null session there means injection is silently
104
+ * inert — a likely Pi API drift. (Headless legitimately omits it — it wires
105
+ * `rt.session` via `setRuntimeSession` — so the guard is interactive-only.)
106
+ *
107
+ * Pure + injected `warn` so it unit-tests without the workflow harness.
108
+ */
109
+ function warnIfInteractiveSessionMissing(mode, payload, warn) {
110
+ if (mode === 'interactive' && payload.session == null) {
111
+ warn('WARNING: interactive session_start carried no session — cue/reset injection inert; ' +
112
+ 'possible Pi API drift (#645)');
113
+ }
114
+ }
94
115
  // MD-C shell/exec tool-class membership is owned by `tool-capability.ts`
95
116
  // (`classify(name) === 'exec'`, content signed off by tempo-security). F1
96
117
  // import-refactor (3d): this REPLACES the former local `SHELL_TOOL_NAMES` set —
@@ -282,6 +303,8 @@ function createPiExtension(options = {}) {
282
303
  }
283
304
  // ── Lifecycle: session_start → first attach OR re-bind ──
284
305
  pi.on('session_start', async (payload) => {
306
+ // B1 (#645 H4): warn loudly if interactive session_start lost its session.
307
+ warnIfInteractiveSessionMissing(mode, payload, log);
285
308
  try {
286
309
  const rt = await attachOrRebind(payload);
287
310
  await refreshSessionId(rt, rt.session?.id);
@@ -402,6 +425,22 @@ function __resetPiRuntimesForTests() {
402
425
  connectedClient = null;
403
426
  clientFactory = getSharedClient;
404
427
  }
428
+ /**
429
+ * Seed a fake runtime into the module-scope `runtimes` map. TEST ESCAPE HATCH —
430
+ * do NOT call from production code. The map is otherwise unreachable from a test;
431
+ * this is the seam for covering lifecycle paths like {@link detachAllPiRuntimesForExit}.
432
+ */
433
+ function __seedRuntimeForTests(workflowId, rt) {
434
+ runtimes.set(workflowId, rt);
435
+ }
436
+ /**
437
+ * Clear the module-scope `runtimes` map WITHOUT timer/heartbeat teardown (for
438
+ * afterEach isolation in runtime-seeding tests; use {@link __resetPiRuntimesForTests}
439
+ * for the full singleton reset). TEST ESCAPE HATCH — do NOT call from production code.
440
+ */
441
+ function __clearRuntimesForTests() {
442
+ runtimes.clear();
443
+ }
405
444
  /** Default export — interactive-mode extension (the human `pi` CLI entry). */
406
445
  const piExtension = createPiExtension();
407
446
  exports.default = piExtension;
@@ -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`, {});
@@ -161,15 +161,13 @@ class Controller {
161
161
  this.report(ctx, `destroy ${p}`, await this.actions.destroy(p, reason || undefined));
162
162
  }
163
163
  async cmdReset(args, ctx) {
164
- const p = args.trim();
164
+ // H5b: real POST /v1/ensembles/:e/reset (D14 clean-wipe) — mirrors cmdRestart.
165
+ const [p, reason] = Controller.splitFirst(args);
165
166
  if (!p) {
166
- this.notify(ctx, 'Usage: /reset <player>');
167
+ this.notify(ctx, 'Usage: /reset <player> [reason]');
167
168
  return;
168
169
  }
169
- // D14 reset has NO daemon HTTP route yet (MCP/outbox only). Surface clearly
170
- // rather than silently fail. Wiring a POST /v1/ensembles/:e/reset is a daemon
171
- // follow-up (flagged to the conductor).
172
- 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));
173
171
  }
174
172
  async cmdArm(args, ctx) {
175
173
  const [p, mode] = Controller.splitFirst(args);
@@ -320,7 +318,7 @@ function createMissionControlExtension(deps = {}) {
320
318
  pi.registerCommand('play', { description: 'Resume the ensemble (/play [release])', handler: (a, ctx) => ctrl.cmdPlay(a, ctx) });
321
319
  pi.registerCommand('restart', { description: 'Restart a player (/restart <player> [reason])', handler: (a, ctx) => ctrl.cmdRestart(a, ctx) });
322
320
  pi.registerCommand('destroy', { description: 'Destroy a player (/destroy <player> [reason])', handler: (a, ctx) => ctrl.cmdDestroy(a, ctx) });
323
- 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) });
324
322
  pi.registerCommand('arm', { description: 'Arm/disarm the operator gate for a player (/arm <player> [off])', handler: (a, ctx) => ctrl.cmdArm(a, ctx) });
325
323
  pi.registerCommand('gate', { description: 'Decide a gate request for the tailed player (/gate <reqId> allow|deny)', handler: (a, ctx) => ctrl.cmdGate(a, ctx) });
326
324
  };
@@ -37,13 +37,19 @@ export interface PiCustomMessageOptions {
37
37
  */
38
38
  deliverAs?: 'steer' | 'followUp';
39
39
  }
40
- /** A message injected into a live Pi session. */
40
+ /**
41
+ * A message injected into a live Pi session. Real `sendCustomMessage` msg param =
42
+ * `Pick<CustomMessage, "customType" | "content" | "display" | "details">` — so
43
+ * `customType` + `display` are REQUIRED (C1, #645 H4). `content` is kept
44
+ * `string` (narrower than the real `string | (TextContent | ImageContent)[]`,
45
+ * still assignable — we only ever inject text).
46
+ */
41
47
  export interface PiOutboundMessage {
42
48
  /** Free-form tag Pi surfaces to the agent (we use `'cue'`). */
43
- customType?: string;
49
+ customType: string;
44
50
  content: string;
45
51
  /** Render the injected content in the human-visible transcript. */
46
- display?: boolean;
52
+ display: boolean;
47
53
  }
48
54
  /**
49
55
  * The live, human-attached agent session. `sendCustomMessage` is bound in the
@@ -73,7 +79,13 @@ export interface PiAgentSession {
73
79
  * RE-ACQUIRE the session from each payload (never cache across switches — D11).
74
80
  */
75
81
  export interface PiEventPayload {
76
- /** The live session, present on most lifecycle events. */
82
+ /**
83
+ * UNDECLARED in Pi 0.78 `.d.ts` — an interactive-only RUNTIME field; headless
84
+ * populates `rt.session` via `setRuntimeSession` instead. NOT type-assertable,
85
+ * so the pi-drift gate EXCLUDES it (see test/pi-drift/assert.ts) and the
86
+ * runtime guard B1 (extension.ts) covers it: a null session on interactive
87
+ * `session_start` warns of possible Pi API drift.
88
+ */
77
89
  session?: PiAgentSession;
78
90
  /** A per-message/per-turn identifier when the event carries one. */
79
91
  messageId?: string;
@@ -90,7 +102,8 @@ export interface PiEventPayload {
90
102
  /**
91
103
  * Pi context-window usage — the PULL-only token signal (3c). There is NO token
92
104
  * event in Pi 0.78; usage is queried on demand via `ExtensionContext.getContextUsage()`.
93
- * Mirrors pi-ai's `ContextUsage` (verified against the installed 0.78 `.d.ts`).
105
+ * Mirrors `ContextUsage`, exported from `@earendil-works/pi-coding-agent`
106
+ * (extensions surface), NOT pi-ai (verified against the installed 0.78 `.d.ts`).
94
107
  */
95
108
  export interface PiContextUsage {
96
109
  /** Estimated context tokens in use, or `null` when unknown. */
@@ -179,14 +192,25 @@ export interface PiToolCallResult {
179
192
  reason?: string;
180
193
  }
181
194
  /**
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`.
195
+ * Pi tool result a Pi-free structural mirror of the real `AgentToolResult`
196
+ * (#653, 1.4.2). The Phase-0 `{ output, isError }` guess was WRONG: Pi's real
197
+ * `AgentToolResult` is `{ content: (TextContent|ImageContent)[]; details; terminate? }`
198
+ * (pi-agent-core types.d.ts:305), so `{ output, isError }` made every native
199
+ * agent-tempo tool result malformed for a Pi model (no `content[]` — the
200
+ * RESULT-shape mirror of #651's INPUT-shape bug).
201
+ *
202
+ * We only ever emit TEXT content. Errors are NOT content-encoded — they are
203
+ * THROWN from `execute` (Pi's sanctioned error path; the loop catches → sets
204
+ * isError + folds the message into content). So this success-shape carries no
205
+ * error flag. See `toPiResult` / `renderToPi` in render-tools.ts.
185
206
  */
186
207
  export interface PiToolResult {
187
- output?: string;
188
- isError?: boolean;
189
- [key: string]: unknown;
208
+ content: Array<{
209
+ type: 'text';
210
+ text: string;
211
+ }>;
212
+ details: Record<string, unknown>;
213
+ terminate?: boolean;
190
214
  }
191
215
  /**
192
216
  * Pi native tool definition. `parameters` is a TypeBox schema (NOT zod) — see
@@ -194,6 +218,12 @@ export interface PiToolResult {
194
218
  */
195
219
  export interface PiToolDefinition {
196
220
  name: string;
221
+ /**
222
+ * Human-facing label. The real `ToolDefinition` REQUIRES `label` (C4, #645 H4);
223
+ * render-tools sets it to the tool name. Omitting it would RED the gate's
224
+ * ToolDefinition row.
225
+ */
226
+ label: string;
197
227
  description?: string;
198
228
  /** TypeBox schema object. Typed `unknown` to avoid coupling pi-types to typebox. */
199
229
  parameters: unknown;
@@ -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
@@ -43,6 +43,8 @@ function renderToPi(pi, descriptors) {
43
43
  for (const d of descriptors) {
44
44
  pi.registerTool({
45
45
  name: d.name,
46
+ // Real ToolDefinition REQUIRES `label` (C4, #645 H4) — use the tool name.
47
+ label: d.name,
46
48
  description: d.description,
47
49
  parameters: (0, zod_to_typebox_1.zodShapeToTypeBox)(d.params, d.name),
48
50
  // Pi calls execute POSITIONALLY: (toolCallId, params, signal, onUpdate, ctx).
@@ -50,7 +52,16 @@ function renderToPi(pi, descriptors) {
50
52
  // toolCallId string (1st) and hand `params` to the descriptor handler.
51
53
  // (Passing the 1st positional was the v1.4.0 arg-order bug: handlers got the
52
54
  // toolCallId string instead of params.) See PiToolDefinition.execute.
53
- execute: async (_toolCallId, params) => toPiResult(await d.handler(params)),
55
+ execute: async (_toolCallId, params) => {
56
+ const r = await d.handler(params);
57
+ // Pi's sanctioned error path (#653): THROW on failure — the agent loop
58
+ // catches it → createErrorToolResult (message → content) + isError:true.
59
+ // Content-encoding an error WITHOUT throwing would make the model think
60
+ // the tool SUCCEEDED (Pi types.d.ts:327). So errors never reach toPiResult.
61
+ if (r.isError)
62
+ throw new Error(r.text);
63
+ return toPiResult(r);
64
+ },
54
65
  });
55
66
  }
56
67
  }
package/dist/spawn.d.ts CHANGED
@@ -38,9 +38,12 @@ export declare function findLinuxTerminal(): string | null;
38
38
  * Build a shell command string that sets env vars and runs claude.
39
39
  * Uses inline `KEY=val` syntax which works in bash, zsh, AND fish.
40
40
  */
41
- export declare function buildClaudeCommand(claudeBin: string, claudeArgs: string[], envVars: Record<string, string>): string;
41
+ export declare function buildTerminalCommand(bin: string, binArgs: string[], envVars: Record<string, string>): string;
42
42
  /**
43
- * Spawn a Claude Code session in a visible terminal window.
43
+ * Launch ANY binary in a visible terminal window (the cross-platform core
44
+ * extracted from `spawnInTerminal`, #666 C1). Generic over `bin`/`args` so it
45
+ * drives both Claude (via the {@link spawnInTerminal} wrapper) and the
46
+ * interactive Pi conductor (`pi -e <ext>`).
44
47
  *
45
48
  * Strategy per terminal:
46
49
  * - Ghostty: `initial input` into a normal window (preserves full shell env)
@@ -49,11 +52,82 @@ export declare function buildClaudeCommand(claudeBin: string, claudeArgs: string
49
52
  * - Windows: shell:true with env vars
50
53
  * - Linux: terminal emulator with -e flag
51
54
  */
55
+ export declare function launchInTerminal(bin: string, args: string[], workDir: string, envVars: Record<string, string>): {
56
+ pid: number | undefined;
57
+ };
58
+ /**
59
+ * Spawn a Claude Code session in a visible terminal window — a thin, unchanged
60
+ * wrapper over {@link launchInTerminal} (resolves the claude binary, forwards
61
+ * the rest). Signature preserved for the existing callers (commands.ts conductor
62
+ * + outbox.ts recruit-spawn) + the spawn-route regression tests (#666 C1).
63
+ */
52
64
  export declare function spawnInTerminal(claudeArgs: string[], workDir: string, envVars: Record<string, string>, options?: {
53
65
  claudeBin?: string;
54
66
  }): {
55
67
  pid: number | undefined;
56
68
  };
69
+ /**
70
+ * Resolve the INTERACTIVE Pi CLI binary (#666) — the human-TTY `pi`, DISTINCT
71
+ * from {@link resolvePiPath} (the headless adapter entry). Interactive TTY mode
72
+ * is REQUIRED for a conductor: Pi only fires `session_start` / attaches in a real
73
+ * terminal (non-TTY / `--print` → print-mode → tools register but NO attach).
74
+ *
75
+ * `pi` on PATH wins; else fall back to the installed package CLI via `node`.
76
+ * THROWS fail-clean if neither resolves, so the caller never launches a terminal
77
+ * that would immediately die. Collaborators injectable for unit tests.
78
+ */
79
+ export declare function resolvePiInteractiveBinary(deps?: {
80
+ onPath?: (bin: string) => boolean;
81
+ exists?: (p: string) => boolean;
82
+ }): {
83
+ cmd: string;
84
+ args: string[];
85
+ };
86
+ /**
87
+ * Resolve the absolute path to the BUNDLED `dist/pi/extension.js` for `pi -e <abs>`
88
+ * (#666). Pi loads the BUILT CommonJS extension even in dev. Mirrors
89
+ * {@link resolvePiPath}'s dev/prod `__dirname` split: prod `__dirname` = `dist/`
90
+ * (→ `dist/pi/extension.js`); dev `__dirname` = `src/` (→ sibling `dist/pi/…`).
91
+ * Existence-checked + fail-clean ("run npm run build"). Injectable for tests.
92
+ */
93
+ export declare function resolvePiExtensionPath(deps?: {
94
+ exists?: (p: string) => boolean;
95
+ isDev?: boolean;
96
+ baseDir?: string;
97
+ }): string;
98
+ /** Inputs for {@link buildPiConductorSpawn} (pure — unit-tested without spawning). */
99
+ export interface PiConductorSpawnOpts {
100
+ ensemble: string;
101
+ sessionName: string;
102
+ /** Temporal env (address/namespace/api-key/tls) built by the caller. */
103
+ temporalEnvVars: Record<string, string>;
104
+ /** Temporal task queue — the Pi extension's PiWorkflowClient needs it (confirm #1). */
105
+ taskQueue: string;
106
+ devMode: boolean;
107
+ /** Conductor agent-type name → AGENT_TEMPO_PLAYER_TYPE, when typed. */
108
+ conductorTypeName?: string;
109
+ /** Forwarded if set (warn-not-fail upstream when unset). */
110
+ anthropicApiKey?: string;
111
+ /** Injectable resolvers (default to the real ones, which fail-clean on miss). */
112
+ resolveBinary?: () => {
113
+ cmd: string;
114
+ args: string[];
115
+ };
116
+ resolveExtension?: () => string;
117
+ }
118
+ /**
119
+ * Build the interactive Pi conductor spawn spec — `{ cmd, args, env }` for
120
+ * {@link launchInTerminal} (#666 C3). PURE + injectable so the env/args mapping is
121
+ * unit-tested. The default resolvers THROW fail-clean (binary missing / extension
122
+ * unbuilt) BEFORE a terminal is launched. `args` = `[...binArgs, '-e', <ext>]`;
123
+ * conductor INSTRUCTIONS arrive via the lineup-baked workflow messages → cue pump
124
+ * (no `--system-prompt` for the MVP).
125
+ */
126
+ export declare function buildPiConductorSpawn(opts: PiConductorSpawnOpts): {
127
+ cmd: string;
128
+ args: string[];
129
+ env: Record<string, string>;
130
+ };
57
131
  export interface CopilotBridgeOpts {
58
132
  name: string;
59
133
  ensemble: string;
package/dist/spawn.js CHANGED
@@ -6,8 +6,12 @@ exports.shellQuote = shellQuote;
6
6
  exports.resolveClaudePath = resolveClaudePath;
7
7
  exports.detectMacTerminal = detectMacTerminal;
8
8
  exports.findLinuxTerminal = findLinuxTerminal;
9
- exports.buildClaudeCommand = buildClaudeCommand;
9
+ exports.buildTerminalCommand = buildTerminalCommand;
10
+ exports.launchInTerminal = launchInTerminal;
10
11
  exports.spawnInTerminal = spawnInTerminal;
12
+ exports.resolvePiInteractiveBinary = resolvePiInteractiveBinary;
13
+ exports.resolvePiExtensionPath = resolvePiExtensionPath;
14
+ exports.buildPiConductorSpawn = buildPiConductorSpawn;
11
15
  exports.spawnCopilotBridge = spawnCopilotBridge;
12
16
  exports.spawnMockAdapter = spawnMockAdapter;
13
17
  exports.spawnClaudeApiAdapter = spawnClaudeApiAdapter;
@@ -234,17 +238,20 @@ function findLinuxTerminal() {
234
238
  * Build a shell command string that sets env vars and runs claude.
235
239
  * Uses inline `KEY=val` syntax which works in bash, zsh, AND fish.
236
240
  */
237
- function buildClaudeCommand(claudeBin, claudeArgs, envVars) {
241
+ function buildTerminalCommand(bin, binArgs, envVars) {
238
242
  const envInline = Object.entries(envVars)
239
243
  .map(([k, v]) => `${k}=${shellQuote(v)}`)
240
244
  .join(' ');
241
245
  // Quote the binary path if it contains spaces (e.g., "C:\Program Files\...")
242
- const quotedBin = claudeBin.includes(' ') ? shellQuote(claudeBin) : claudeBin;
243
- const args = claudeArgs.map(a => shellQuote(a)).join(' ');
246
+ const quotedBin = bin.includes(' ') ? shellQuote(bin) : bin;
247
+ const args = binArgs.map(a => shellQuote(a)).join(' ');
244
248
  return envInline ? `${envInline} ${quotedBin} ${args}` : `${quotedBin} ${args}`;
245
249
  }
246
250
  /**
247
- * Spawn a Claude Code session in a visible terminal window.
251
+ * Launch ANY binary in a visible terminal window (the cross-platform core
252
+ * extracted from `spawnInTerminal`, #666 C1). Generic over `bin`/`args` so it
253
+ * drives both Claude (via the {@link spawnInTerminal} wrapper) and the
254
+ * interactive Pi conductor (`pi -e <ext>`).
248
255
  *
249
256
  * Strategy per terminal:
250
257
  * - Ghostty: `initial input` into a normal window (preserves full shell env)
@@ -253,9 +260,14 @@ function buildClaudeCommand(claudeBin, claudeArgs, envVars) {
253
260
  * - Windows: shell:true with env vars
254
261
  * - Linux: terminal emulator with -e flag
255
262
  */
256
- function spawnInTerminal(claudeArgs, workDir, envVars, options) {
257
- const claudeBin = resolveClaudePath(options?.claudeBin);
258
- const claudeInvocation = buildClaudeCommand(claudeBin, claudeArgs, envVars);
263
+ function launchInTerminal(bin, args, workDir, envVars) {
264
+ // Internal aliases keep the platform body below byte-identical to the original
265
+ // spawnInTerminal (behavior-preserving extraction, #666 C1 — minimal blast
266
+ // radius; the existing terminal-spawn tests are the proof). The terminal logic
267
+ // is bin/args-agnostic; the `claude*` names are historical.
268
+ const claudeBin = bin;
269
+ const claudeArgs = args;
270
+ const claudeInvocation = buildTerminalCommand(claudeBin, claudeArgs, envVars);
259
271
  if (process.platform === 'darwin') {
260
272
  const detected = detectMacTerminal();
261
273
  log(`Terminal detection: TERM_PROGRAM=${JSON.stringify(process.env.TERM_PROGRAM)}, detected=${detected}`);
@@ -414,6 +426,105 @@ function spawnInTerminal(claudeArgs, workDir, envVars, options) {
414
426
  child.unref();
415
427
  return { pid: child.pid };
416
428
  }
429
+ /**
430
+ * Spawn a Claude Code session in a visible terminal window — a thin, unchanged
431
+ * wrapper over {@link launchInTerminal} (resolves the claude binary, forwards
432
+ * the rest). Signature preserved for the existing callers (commands.ts conductor
433
+ * + outbox.ts recruit-spawn) + the spawn-route regression tests (#666 C1).
434
+ */
435
+ function spawnInTerminal(claudeArgs, workDir, envVars, options) {
436
+ return launchInTerminal(resolveClaudePath(options?.claudeBin), claudeArgs, workDir, envVars);
437
+ }
438
+ // --- Interactive Pi conductor (#666) ---
439
+ /** Is `bin` resolvable on PATH? (where/which, mirrors resolveClaudePath.) */
440
+ function binaryOnPath(bin) {
441
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
442
+ try {
443
+ (0, child_process_1.execFileSync)(cmd, [bin], { stdio: ['ignore', 'ignore', 'ignore'] });
444
+ return true;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
450
+ /** Walk up from `__dirname` for the installed Pi package's CLI entry. */
451
+ function findPiPackageCli(exists) {
452
+ let dir = __dirname;
453
+ for (let i = 0; i < 8; i += 1) {
454
+ const candidate = (0, path_1.join)(dir, 'node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js');
455
+ if (exists(candidate))
456
+ return candidate;
457
+ const parent = (0, path_1.dirname)(dir);
458
+ if (parent === dir)
459
+ break;
460
+ dir = parent;
461
+ }
462
+ return null;
463
+ }
464
+ /**
465
+ * Resolve the INTERACTIVE Pi CLI binary (#666) — the human-TTY `pi`, DISTINCT
466
+ * from {@link resolvePiPath} (the headless adapter entry). Interactive TTY mode
467
+ * is REQUIRED for a conductor: Pi only fires `session_start` / attaches in a real
468
+ * terminal (non-TTY / `--print` → print-mode → tools register but NO attach).
469
+ *
470
+ * `pi` on PATH wins; else fall back to the installed package CLI via `node`.
471
+ * THROWS fail-clean if neither resolves, so the caller never launches a terminal
472
+ * that would immediately die. Collaborators injectable for unit tests.
473
+ */
474
+ function resolvePiInteractiveBinary(deps = {}) {
475
+ const onPath = deps.onPath ?? binaryOnPath;
476
+ const exists = deps.exists ?? fs_1.existsSync;
477
+ if (onPath('pi'))
478
+ return { cmd: 'pi', args: [] };
479
+ const cli = findPiPackageCli(exists);
480
+ if (cli)
481
+ return { cmd: 'node', args: [cli] };
482
+ throw new Error('Pi CLI not found. Install it with `npm install -g pi-ai` and ensure `pi` is on PATH ' +
483
+ '(or add the @earendil-works/pi-coding-agent package). The conductor needs the interactive Pi CLI.');
484
+ }
485
+ /**
486
+ * Resolve the absolute path to the BUNDLED `dist/pi/extension.js` for `pi -e <abs>`
487
+ * (#666). Pi loads the BUILT CommonJS extension even in dev. Mirrors
488
+ * {@link resolvePiPath}'s dev/prod `__dirname` split: prod `__dirname` = `dist/`
489
+ * (→ `dist/pi/extension.js`); dev `__dirname` = `src/` (→ sibling `dist/pi/…`).
490
+ * Existence-checked + fail-clean ("run npm run build"). Injectable for tests.
491
+ */
492
+ function resolvePiExtensionPath(deps = {}) {
493
+ const exists = deps.exists ?? fs_1.existsSync;
494
+ const isDev = deps.isDev ?? __filename.endsWith('.ts');
495
+ const base = deps.baseDir ?? __dirname;
496
+ const extPath = isDev
497
+ ? (0, path_1.resolve)(base, '..', 'dist', 'pi', 'extension.js') // dev: src/ → repo/dist/pi/extension.js
498
+ : (0, path_1.resolve)(base, 'pi', 'extension.js'); // prod: dist/ → dist/pi/extension.js
499
+ if (!exists(extPath)) {
500
+ throw new Error(`Pi conductor extension not found at ${extPath}. Run \`npm run build\` first.`);
501
+ }
502
+ return extPath;
503
+ }
504
+ /**
505
+ * Build the interactive Pi conductor spawn spec — `{ cmd, args, env }` for
506
+ * {@link launchInTerminal} (#666 C3). PURE + injectable so the env/args mapping is
507
+ * unit-tested. The default resolvers THROW fail-clean (binary missing / extension
508
+ * unbuilt) BEFORE a terminal is launched. `args` = `[...binArgs, '-e', <ext>]`;
509
+ * conductor INSTRUCTIONS arrive via the lineup-baked workflow messages → cue pump
510
+ * (no `--system-prompt` for the MVP).
511
+ */
512
+ function buildPiConductorSpawn(opts) {
513
+ const { cmd, args: binArgs } = (opts.resolveBinary ?? resolvePiInteractiveBinary)();
514
+ const extPath = (opts.resolveExtension ?? resolvePiExtensionPath)();
515
+ const args = [...binArgs, '-e', extPath];
516
+ const env = {
517
+ ...opts.temporalEnvVars,
518
+ [config_1.ENV.TASK_QUEUE]: opts.taskQueue,
519
+ [config_1.ENV.ENSEMBLE]: opts.ensemble,
520
+ [config_1.ENV.CONDUCTOR]: 'true', // codebase-consistent; the Pi extension accepts '1'|'true'
521
+ [config_1.ENV.PLAYER_NAME]: opts.sessionName,
522
+ ...(opts.devMode ? { [config_1.ENV.DEV_MODE]: '1' } : {}),
523
+ ...(opts.anthropicApiKey ? { ANTHROPIC_API_KEY: opts.anthropicApiKey } : {}),
524
+ ...(opts.conductorTypeName ? { [config_1.ENV.PLAYER_TYPE]: opts.conductorTypeName } : {}),
525
+ };
526
+ return { cmd, args, env };
527
+ }
417
528
  /**
418
529
  * Resolve the path to the compiled copilot bridge adapter entry point.
419
530
  * In dev (ts-node), returns a ts-node command; in production, returns the dist path.
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.1",
3
+ "version": "1.5.0",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -81,7 +81,8 @@
81
81
  "lint:lockstep-version": "node -e \"const r=require('./package.json').version,d=require('./dashboard/package.json').version;if(r!==d){console.error('Version drift: root='+r+' dashboard='+d+'. Bump dashboard/package.json#version to match root.');process.exit(1);}console.log('Lockstep OK: '+r);\"",
82
82
  "lint:lockfile-canonical": "bash scripts/check-lockfile-canonical.sh",
83
83
  "lint:dashboard-css-sync": "npm run build:scripts && node dist/scripts/check-components-css-sync.js",
84
- "check:all": "npm run lint:test-ensemble-literals && npm run lint:skip-reasons && npm run lint:lockstep-version && npm run lint:lockfile-canonical && npm run lint:surface-drift && npm run lint:no-stale-scaffold && npm run build && npm run lint:dashboard-css-sync && npm test && npm --prefix dashboard run lint && npm --prefix dashboard run test && npm run size-limit && npm run verify-tarball"
84
+ "lint:pi-drift": "node scripts/check-pi-drift.js",
85
+ "check:all": "npm run lint:test-ensemble-literals && npm run lint:skip-reasons && npm run lint:lockstep-version && npm run lint:lockfile-canonical && npm run lint:surface-drift && npm run lint:no-stale-scaffold && npm run build && npm run lint:pi-drift && npm run lint:dashboard-css-sync && npm test && npm --prefix dashboard run lint && npm --prefix dashboard run test && npm run size-limit && npm run verify-tarball"
85
86
  },
86
87
  "optionalDependencies": {
87
88
  "@anthropic-ai/sdk": "~0.91.1",