agent-tempo 1.7.0-beta.5 → 1.7.0-beta.7

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
@@ -104,6 +104,7 @@ src/
104
104
  │ ├── hosts.ts / set-ensemble-description.ts
105
105
  │ ├── save-state.ts / fetch-state.ts / clear-state.ts
106
106
  │ ├── coat-check-put.ts / coat-check-get.ts / coat-check-list.ts / coat-check-evict.ts
107
+ │ ├── respond.ts
107
108
  │ └── descriptor.ts # Transport-neutral tool descriptor (TempoToolDescriptor) + renderToMcp; per-tool `build*Tool` factories live in each tool file (MD-B, Phase 1)
108
109
  ├── pi/ # Pi-native integration — a Pi session as a first-class player over the Temporal core
109
110
  │ ├── extension.ts # `export default function(pi)` — interactive runtime entry. Holds the MODULE-SCOPE singleton `Map<workflowId, PiPlayerRuntime>` that survives Pi's per-switch instance rebuild (rebind, not re-claim); full tool surface via renderToPi; Option-C reason-discriminated teardown
@@ -211,7 +212,7 @@ daemon worker notes, `npx ts-node` dev runner).
211
212
  - **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
213
  - **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
214
  - **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
- - **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
+ - **Mission-control / Command-center** (3f, #700 P2): An interactive Pi TUI that is both a live ensemble board + operator controller and an LLM planner. Board side: 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`. Planner side: registers LLM tools (`ask` / `handoff` / `cue` / `recruit` / `observe_board`) via `MissionControlActions` — HTTP-backed, distinct from the player extension's `renderToPi` MCP surface. **Never claims attachment or registers as a player** — invisible to the ensemble. Launch with **`agent-tempo command-center [ensemble]`** (aliases: `cc`, `board`) — sets `AGENT_TEMPO_MISSION_CONTROL=1`. **Role-gated and mutually exclusive** with the player extension (`resolvePiRole` discriminator: explicit `AGENT_TEMPO_PI_ROLE` → `PLAYER_NAME` present → `AGENT_TEMPO_MISSION_CONTROL` → `none`): a bare `pi` keeps both extensions dormant (plain coding session); a player session (`up --agent pi` / `recruit`) keeps the board dormant. Power users invoking `pi -e dist/pi/extension.js` directly without `PLAYER_NAME` also resolve to `none` (dormant) — set `AGENT_TEMPO_PI_ROLE=player` to force player mode. See `src/pi/mission-control/`.
215
216
  - **Command-center planner** (#700 P2): An inbox-less interactive Pi session (the operator's planning seat) that routes questions to players via correlated `cue` tags (`[Q <questionId>]`). Players answer with the `respond` MCP tool, which parks the answer on the per-ensemble maestro Q&A mailbox (TTL 1h, 20-slot cap). The planner is woken by an `answer` SSE event when the answer lands (`docs/SSE-PROTOCOL.md` §6). `/handoff` cues hand active work to a conductor (a registered player with a Temporal inbox). See `docs/concepts.md` for the Q&A mechanics.
216
217
  - **Guardrail policy** (`guardrailPolicy` recruit arg, #700 P2): Per-player posture for the operator gate on headless Pi players. Four values — `'autonomous'` (default; no gate), `'monitored'` (gate engaged; fail-open: auto-allow after 45 s), `'supervised'` (gate engaged; fail-closed: auto-deny after 300 s; **client-cooperative in P2, not tamper-proof**; daemon-side enforcement is P2.1 #44), `'observe-only'` (non-`low-risk` tools hard-blocked outright). Persisted on `SessionMetadata`; re-sourced at each `(re)attach` from `getMetadata`. See `docs/concepts.md` for operator gate mechanics.
217
218
  - **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/`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.7.0-beta.5",
4
+ "version": "1.7.0-beta.7",
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": {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `agent-tempo command-center` (#729) — launch an interactive Pi mission-control
3
+ * board for an ensemble.
4
+ *
5
+ * The board is the OPERATOR seat: it observes the ensemble (coarse SSE) and POSTs
6
+ * operator actions (cue/pause/restart/gate/…) to the daemon's write/gate surface.
7
+ * It is NOT a player — it never claims attachment or registers as an ensemble
8
+ * member.
9
+ *
10
+ * The deliberate launch path (#729 A2): this sets `AGENT_TEMPO_MISSION_CONTROL=1`
11
+ * so {@link resolvePiRole} resolves `'command-center'`, which activates the
12
+ * mission-control extension and keeps the player extension dormant — exactly one
13
+ * of the two auto-loaded Pi extensions registers tools, so their `cue`/`recruit`
14
+ * names can't collide. It MUST NOT set `PLAYER_NAME`/`CONDUCTOR` (those flip the
15
+ * role to player). A bare `pi` (neither signal) keeps BOTH dormant — plain coding
16
+ * sessions stay pristine.
17
+ */
18
+ import { CliOverrides } from '../config';
19
+ export interface CommandCenterArgs extends CliOverrides {
20
+ /** Ensemble to observe (positional / `--ensemble`); falls back to config default. */
21
+ ensemble?: string;
22
+ }
23
+ /**
24
+ * Public entry point — invoked by the CLI dispatcher. Launches the board in a
25
+ * real terminal (Pi only attaches its UI in a TTY) and returns; exits 1 on a
26
+ * fail-clean preflight error.
27
+ */
28
+ export declare function commandCenterCommand(args: CommandCenterArgs): Promise<void>;
@@ -0,0 +1,116 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.commandCenterCommand = commandCenterCommand;
37
+ /**
38
+ * `agent-tempo command-center` (#729) — launch an interactive Pi mission-control
39
+ * board for an ensemble.
40
+ *
41
+ * The board is the OPERATOR seat: it observes the ensemble (coarse SSE) and POSTs
42
+ * operator actions (cue/pause/restart/gate/…) to the daemon's write/gate surface.
43
+ * It is NOT a player — it never claims attachment or registers as an ensemble
44
+ * member.
45
+ *
46
+ * The deliberate launch path (#729 A2): this sets `AGENT_TEMPO_MISSION_CONTROL=1`
47
+ * so {@link resolvePiRole} resolves `'command-center'`, which activates the
48
+ * mission-control extension and keeps the player extension dormant — exactly one
49
+ * of the two auto-loaded Pi extensions registers tools, so their `cue`/`recruit`
50
+ * names can't collide. It MUST NOT set `PLAYER_NAME`/`CONDUCTOR` (those flip the
51
+ * role to player). A bare `pi` (neither signal) keeps BOTH dormant — plain coding
52
+ * sessions stay pristine.
53
+ */
54
+ const config_1 = require("../config");
55
+ const spawn_1 = require("../spawn");
56
+ const probe_1 = require("../pi/probe");
57
+ const out = __importStar(require("./output"));
58
+ /**
59
+ * Public entry point — invoked by the CLI dispatcher. Launches the board in a
60
+ * real terminal (Pi only attaches its UI in a TTY) and returns; exits 1 on a
61
+ * fail-clean preflight error.
62
+ */
63
+ async function commandCenterCommand(args) {
64
+ const config = (0, config_1.getConfig)(args);
65
+ const ensemble = config.ensemble;
66
+ // Preflight — fail BEFORE launching a terminal that would die. checkPiNodeFloor
67
+ // is a best-effort Node-version proxy; buildPiCommandCenterSpawn's default
68
+ // resolver throws fail-clean when the Pi CLI is missing.
69
+ const nodeFloor = (0, probe_1.checkPiNodeFloor)();
70
+ if (!nodeFloor.ok) {
71
+ out.error(`Cannot start command-center — ${nodeFloor.reason}`);
72
+ process.exit(1);
73
+ }
74
+ // Admin (T3) token — mission-control's operator write/gate surface reads it.
75
+ // #54: a LOCAL (loopback) daemon grants full trust tokenless, so a tokenless
76
+ // board is fully functional locally; only a REMOTE / 0.0.0.0 daemon requires the
77
+ // token. Informational (not a warning, not a block) — accurate to the daemon's
78
+ // own auth posture.
79
+ const adminToken = process.env[config_1.ENV.HTTP_ADMIN_TOKEN];
80
+ if (!adminToken) {
81
+ out.log(out.dim(` ${config_1.ENV.HTTP_ADMIN_TOKEN} not set — fine for a local (loopback) daemon (full trust). ` +
82
+ 'Set it only if this board drives a remote / 0.0.0.0 daemon.'));
83
+ }
84
+ if (!process.env.ANTHROPIC_API_KEY) {
85
+ out.warn('ANTHROPIC_API_KEY is not set — the Pi command-center will fall back to Pi\'s own auth/default model.');
86
+ }
87
+ const temporalEnvVars = {
88
+ [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
89
+ [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
90
+ };
91
+ if (config.temporalApiKey)
92
+ temporalEnvVars[config_1.ENV.TEMPORAL_API_KEY] = config.temporalApiKey;
93
+ if (config.temporalTlsCertPath)
94
+ temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
95
+ if (config.temporalTlsKeyPath)
96
+ temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
97
+ let spawn;
98
+ try {
99
+ spawn = (0, spawn_1.buildPiCommandCenterSpawn)({
100
+ ensemble,
101
+ temporalEnvVars,
102
+ taskQueue: config.taskQueue,
103
+ devMode: (0, config_1.isDevMode)(),
104
+ ...(adminToken ? { adminToken } : {}),
105
+ ...(process.env.ANTHROPIC_API_KEY ? { anthropicApiKey: process.env.ANTHROPIC_API_KEY } : {}),
106
+ });
107
+ }
108
+ catch (err) {
109
+ out.error(`Cannot start command-center — ${err instanceof Error ? err.message : String(err)}`);
110
+ process.exit(1);
111
+ }
112
+ const { pid } = (0, spawn_1.launchInTerminal)(spawn.cmd, spawn.args, process.cwd(), spawn.env);
113
+ out.success(`Command-center launched for ensemble ${out.cyan(ensemble)} (pid ${pid ?? 'unknown'})`);
114
+ out.log(` ${out.dim('Observer-only Pi board — the player extension stays dormant in this session.')}`);
115
+ out.log(` ${out.dim('Requires `agent-tempo install-pi` to have registered the extensions.')}`);
116
+ }
@@ -86,6 +86,7 @@ ${out.bold('Commands:')}
86
86
  ${out.cyan('agent-types')} <sub> Manage player type definitions (list/show/init)
87
87
  ${out.cyan('daemon')} <sub> Manage the worker daemon (start/stop/status/logs)
88
88
  ${out.cyan('dashboard')} Open the web dashboard (--no-open / --pair / --json)
89
+ ${out.cyan('command-center')} [ensemble] Launch the interactive Pi mission-control board (operator seat; alias: cc/board)
89
90
  ${out.cyan('upgrade')} [version] Upgrade agent-tempo to latest (or specific version)
90
91
  ${out.cyan('config')} Configure Temporal connection settings
91
92
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
package/dist/cli.js CHANGED
@@ -416,6 +416,18 @@ async function main() {
416
416
  });
417
417
  return;
418
418
  }
419
+ if (args.command === 'command-center' || args.command === 'cc' || args.command === 'board') {
420
+ // #729 — launch the interactive Pi mission-control board. Lightweight + does
421
+ // NOT touch Temporal (it spawns `pi` with the operator opt-in env and drives
422
+ // the daemon over HTTP), so it stays out of the Temporal-loading `./cli/commands`
423
+ // import — an operator can open the board even when the Temporal SDK is broken.
424
+ const { commandCenterCommand } = await Promise.resolve().then(() => __importStar(require('./cli/command-center-command')));
425
+ await commandCenterCommand({
426
+ ensemble: args.positional[1],
427
+ ...overrides,
428
+ });
429
+ return;
430
+ }
419
431
  // Dev-mode scriptable verbs (#432). Gated on `isDevMode()` AND an explicit
420
432
  // allowlist (`DEV_VERBS`); intercepts BEFORE the removed-verbs check so
421
433
  // verbs like `pause` (collapsed by #288) can act on the live ensemble in
@@ -626,12 +638,21 @@ async function main() {
626
638
  const { installPiExtensions } = await Promise.resolve().then(() => __importStar(require('./pi/install')));
627
639
  const result = installPiExtensions({ project: args.project });
628
640
  out.success(`Pi extensions installed → ${result.settingsPath}`);
641
+ // #52 — show pruned stale/old-version entries so an upgrade is legible.
642
+ for (const p of result.removed)
643
+ out.log(` ${out.yellow('-')} ${p} ${out.dim('(removed stale/old-version entry)')}`);
629
644
  for (const p of result.added)
630
645
  out.log(` ${out.green('+')} ${p}`);
631
646
  for (const p of result.alreadyPresent)
632
647
  out.log(out.dim(` · ${p} (already installed)`));
633
648
  out.log('');
634
- out.log(' Restart `pi` to load the agent-tempo extensions.');
649
+ // #729 the extensions are role-gated: exactly one activates per session.
650
+ // Point operators at the DELIBERATE launches, not a bare `pi` (which now
651
+ // intentionally keeps BOTH dormant so plain coding sessions stay pristine).
652
+ out.log(' Launch a session with one of:');
653
+ out.log(` ${out.cyan('agent-tempo up --agent pi')} a conductor/player in this ensemble`);
654
+ out.log(` ${out.cyan('agent-tempo command-center')} the operator mission-control board`);
655
+ out.log(out.dim(' A bare `pi` stays a plain coding session (neither extension activates).'));
635
656
  break;
636
657
  }
637
658
  case 'migrate-from-claude-tempo': {
package/dist/config.d.ts CHANGED
@@ -158,7 +158,54 @@ export declare const ENV: {
158
158
  * coordinate three or more parallel agent-tempo profiles on one box.
159
159
  */
160
160
  readonly DEV_HOME_OVERRIDE: "AGENT_TEMPO_HOME_OVERRIDE";
161
+ /**
162
+ * #729 — explicit Pi session-role override for {@link resolvePiRole}. Accepts
163
+ * `'player'` | `'command-center'`. A future escape hatch: the heuristic
164
+ * (PLAYER_NAME presence) already classifies every current launch correctly, so
165
+ * this is intentionally NOT wired into any spawn site — it exists so an operator
166
+ * can force a role if a new launch path ever defeats the heuristic.
167
+ */
168
+ readonly PI_ROLE: "AGENT_TEMPO_PI_ROLE";
169
+ /**
170
+ * #729 (A2) — affirmative opt-in for the mission-control command-center board.
171
+ * Set by the `agent-tempo command-center` launcher (NOT by bare `pi`), so a
172
+ * plain coding `pi` stays pristine (both extensions dormant). Lower precedence
173
+ * than {@link PLAYER_NAME} in {@link resolvePiRole} — a session that must CLAIM
174
+ * never silently degrades to a passive board.
175
+ */
176
+ readonly MISSION_CONTROL: "AGENT_TEMPO_MISSION_CONTROL";
161
177
  };
178
+ /**
179
+ * The role of a Pi session (#729). A session is at most ONE of these — the two
180
+ * auto-loaded Pi extensions gate on it so they're mutually exclusive:
181
+ * - `player` — the player extension's full MCP surface (cue/recruit/report/…).
182
+ * - `command-center` — mission-control's operator board + planner tools.
183
+ * - `none` — neither (a bare `pi` used for plain coding); both stay dormant.
184
+ *
185
+ * `player` and `command-center` both register `cue`/`recruit` by name, so loading
186
+ * both in one session collides and the command-center never starts (#729).
187
+ */
188
+ export type PiRole = 'player' | 'command-center' | 'none';
189
+ /**
190
+ * Resolve the role of a Pi session (#729) — the single discriminator both
191
+ * auto-loaded Pi extensions gate on.
192
+ *
193
+ * Precedence (deterministic, #729 A2):
194
+ * 1. Explicit {@link ENV.PI_ROLE} (`'player'` | `'command-center'`) wins — a future
195
+ * escape hatch, intentionally NOT wired into any spawn site.
196
+ * 2. {@link ENV.PLAYER_NAME} present (every `up`/`recruit` player spawn) → `'player'`.
197
+ * Checked BEFORE the board opt-in ON PURPOSE: a session that must CLAIM must
198
+ * never silently degrade to a passive board.
199
+ * 3. {@link ENV.MISSION_CONTROL} opt-in (set by `agent-tempo command-center`) →
200
+ * `'command-center'`.
201
+ * 4. Otherwise `'none'` — a bare `pi` for plain coding: BOTH extensions stay
202
+ * dormant (the A2 clean-pi guarantee).
203
+ *
204
+ * Pure (env injectable) so the dormancy matrix is unit-testable without spawning.
205
+ * NOTE: headless Pi is definitionally a recruited player and must NOT rely on this
206
+ * heuristic — its caller forces `'player'` directly (env-weirdness immune).
207
+ */
208
+ export declare function resolvePiRole(env?: NodeJS.ProcessEnv): PiRole;
162
209
  export interface Config {
163
210
  temporalAddress: string;
164
211
  temporalNamespace: string;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GLOBAL_MAESTRO_WORKFLOW_ID = exports.DaemonConfigSchema = exports.CleanupPolicySchema = exports.CONFIG_FILE_PATH = exports.AGENT_TEMPO_HOME = exports.PROD_DAEMON_PORT = exports.DEV_DAEMON_PORT = exports.PROD_TASK_QUEUE = exports.DEV_TASK_QUEUE = exports.PROD_TEMPORAL_NAMESPACE = exports.DEV_TEMPORAL_NAMESPACE = exports.PROD_HOME_DIR_NAME = exports.DEV_HOME_DIR_NAME = exports.ENV = void 0;
4
+ exports.resolvePiRole = resolvePiRole;
4
5
  exports.isDevMode = isDevMode;
5
6
  exports.resolveTempoHome = resolveTempoHome;
6
7
  exports.bridgeLogsRoot = bridgeLogsRoot;
@@ -186,7 +187,52 @@ exports.ENV = {
186
187
  * coordinate three or more parallel agent-tempo profiles on one box.
187
188
  */
188
189
  DEV_HOME_OVERRIDE: 'AGENT_TEMPO_HOME_OVERRIDE',
190
+ /**
191
+ * #729 — explicit Pi session-role override for {@link resolvePiRole}. Accepts
192
+ * `'player'` | `'command-center'`. A future escape hatch: the heuristic
193
+ * (PLAYER_NAME presence) already classifies every current launch correctly, so
194
+ * this is intentionally NOT wired into any spawn site — it exists so an operator
195
+ * can force a role if a new launch path ever defeats the heuristic.
196
+ */
197
+ PI_ROLE: 'AGENT_TEMPO_PI_ROLE',
198
+ /**
199
+ * #729 (A2) — affirmative opt-in for the mission-control command-center board.
200
+ * Set by the `agent-tempo command-center` launcher (NOT by bare `pi`), so a
201
+ * plain coding `pi` stays pristine (both extensions dormant). Lower precedence
202
+ * than {@link PLAYER_NAME} in {@link resolvePiRole} — a session that must CLAIM
203
+ * never silently degrades to a passive board.
204
+ */
205
+ MISSION_CONTROL: 'AGENT_TEMPO_MISSION_CONTROL',
189
206
  };
207
+ /**
208
+ * Resolve the role of a Pi session (#729) — the single discriminator both
209
+ * auto-loaded Pi extensions gate on.
210
+ *
211
+ * Precedence (deterministic, #729 A2):
212
+ * 1. Explicit {@link ENV.PI_ROLE} (`'player'` | `'command-center'`) wins — a future
213
+ * escape hatch, intentionally NOT wired into any spawn site.
214
+ * 2. {@link ENV.PLAYER_NAME} present (every `up`/`recruit` player spawn) → `'player'`.
215
+ * Checked BEFORE the board opt-in ON PURPOSE: a session that must CLAIM must
216
+ * never silently degrade to a passive board.
217
+ * 3. {@link ENV.MISSION_CONTROL} opt-in (set by `agent-tempo command-center`) →
218
+ * `'command-center'`.
219
+ * 4. Otherwise `'none'` — a bare `pi` for plain coding: BOTH extensions stay
220
+ * dormant (the A2 clean-pi guarantee).
221
+ *
222
+ * Pure (env injectable) so the dormancy matrix is unit-testable without spawning.
223
+ * NOTE: headless Pi is definitionally a recruited player and must NOT rely on this
224
+ * heuristic — its caller forces `'player'` directly (env-weirdness immune).
225
+ */
226
+ function resolvePiRole(env = process.env) {
227
+ const explicit = env[exports.ENV.PI_ROLE];
228
+ if (explicit === 'player' || explicit === 'command-center')
229
+ return explicit;
230
+ if (env[exports.ENV.PLAYER_NAME])
231
+ return 'player';
232
+ if (env[exports.ENV.MISSION_CONTROL])
233
+ return 'command-center';
234
+ return 'none';
235
+ }
190
236
  // ── Dev profile (ADR 0014 §5) ──
191
237
  /**
192
238
  * Dev profile defaults — one switch (`--dev` top-level flag, or
@@ -158,6 +158,27 @@ function createPiExtension(options = {}) {
158
158
  // #700 (P2 / G) — guardrail posture; absent ⇒ autonomous (no gate).
159
159
  const guardrailPolicy = options.guardrailPolicy ?? 'autonomous';
160
160
  return function piExtension(pi) {
161
+ // ── #729 role gate (MUST stay first — before probePi / getConfig /
162
+ // clientFactory / renderToPi / before_agent_start / any session_start claim) ──
163
+ //
164
+ // Headless Pi is definitionally a recruited player, so force 'player' on it —
165
+ // never trust the env heuristic there (env-weirdness immune). Every INTERACTIVE
166
+ // player spawn (conductor `up --agent pi`, `recruit`) sets PLAYER_NAME; a bare
167
+ // `pi` does not. When this session is NOT a player, the player extension goes
168
+ // FULLY DORMANT: it registers NO tools, opens NO Temporal connection (the
169
+ // `clientFactory(config)` kickoff below is skipped), and starts NO workflow.
170
+ //
171
+ // This is load-bearing for two things at once: (1) it resolves the cue/recruit
172
+ // tool-name COLLISION with mission-control (exactly one extension registers
173
+ // tools per session), and (2) it kills the phantom `pi-${process.pid}` orphan
174
+ // workflow a bare `pi` used to self-bootstrap (the `pi-${pid}` fallback id below
175
+ // is now never reached in a non-player session).
176
+ const role = mode === 'headless' ? 'player' : (0, config_1.resolvePiRole)();
177
+ if (role !== 'player') {
178
+ log(`dormant — session role is '${role}', not a player: registering no player ` +
179
+ 'surface (no tools, no Temporal connection, no workflow).');
180
+ return;
181
+ }
161
182
  const probe = (0, probe_1.probePi)();
162
183
  if (!probe.available)
163
184
  log('WARNING:', probe.reason);
@@ -26,9 +26,34 @@ export interface InstallPiResult {
26
26
  added: string[];
27
27
  /** Extension paths already present before this run. */
28
28
  alreadyPresent: string[];
29
+ /**
30
+ * #52 — STALE agent-tempo extension paths PRUNED by this run (old-version /
31
+ * moved-install entries that pointed at an agent-tempo extension but are no
32
+ * longer the current path). Empty on a clean re-run.
33
+ */
34
+ removed: string[];
29
35
  /** The final `extensions` array written to settings.json. */
30
36
  extensions: string[];
31
37
  }
38
+ /**
39
+ * #52 — does this settings `extensions` entry point at an agent-tempo Pi
40
+ * extension (player or command-center), of ANY version / install location?
41
+ *
42
+ * The motivating bug: a `pnpm` global install version-hashes the package dir
43
+ * (`.../.pnpm/agent-tempo@<version>_<hash>/node_modules/agent-tempo/...`), so on
44
+ * UPGRADE the recorded absolute path goes stale — and a naive add-only install
45
+ * leaves BOTH the old and new paths in `settings.json`, which makes `pi` fail on
46
+ * the now-missing stale entry. {@link installPiExtensions} prunes every match of
47
+ * this predicate (except the current paths) before re-adding, so a re-run
48
+ * REPLACES rather than duplicates.
49
+ *
50
+ * Match = an agent-tempo package marker (`/agent-tempo@…` version dir, or the
51
+ * `/node_modules/agent-tempo/` package dir) AND an agent-tempo extension suffix
52
+ * (`dist/pi/extension.js` or `dist/pi/mission-control/extension.js`). Both halves
53
+ * are required so a user's own unrelated extension is never pruned. Separators
54
+ * are normalised so the predicate holds on Windows paths too.
55
+ */
56
+ export declare function isAgentTempoExtensionPath(p: string): boolean;
32
57
  /** Resolve the Pi settings.json path for the chosen scope. */
33
58
  export declare function piSettingsPath(opts?: InstallPiOptions): string;
34
59
  /**
@@ -37,6 +62,13 @@ export declare function piSettingsPath(opts?: InstallPiOptions): string;
37
62
  * write when nothing changed). Never copies any extension file — install by
38
63
  * reference only (see file header).
39
64
  *
65
+ * #52 — REPLACE, don't accumulate: before adding the current paths, PRUNE any
66
+ * STALE agent-tempo extension entries ({@link isAgentTempoExtensionPath}, minus
67
+ * the current paths). On a `pnpm` upgrade the package dir is version-hashed, so
68
+ * the recorded absolute path changes — without pruning, `settings.json` would
69
+ * list both the old (now-missing) and new paths and `pi` would fail on the stale
70
+ * one. A user's own unrelated extensions and other settings keys are preserved.
71
+ *
40
72
  * Tolerates a missing / empty / corrupt settings file: a missing file is
41
73
  * created; an unparseable one is replaced with a fresh object carrying just the
42
74
  * extensions (we can only safely merge a valid object). Other recognised keys in
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.piExtensionPaths = piExtensionPaths;
4
+ exports.isAgentTempoExtensionPath = isAgentTempoExtensionPath;
4
5
  exports.piSettingsPath = piSettingsPath;
5
6
  exports.installPiExtensions = installPiExtensions;
6
7
  /**
@@ -45,6 +46,30 @@ function piExtensionPaths() {
45
46
  missionControl: (0, path_1.resolve)(__dirname, 'mission-control', 'extension.js'),
46
47
  };
47
48
  }
49
+ /**
50
+ * #52 — does this settings `extensions` entry point at an agent-tempo Pi
51
+ * extension (player or command-center), of ANY version / install location?
52
+ *
53
+ * The motivating bug: a `pnpm` global install version-hashes the package dir
54
+ * (`.../.pnpm/agent-tempo@<version>_<hash>/node_modules/agent-tempo/...`), so on
55
+ * UPGRADE the recorded absolute path goes stale — and a naive add-only install
56
+ * leaves BOTH the old and new paths in `settings.json`, which makes `pi` fail on
57
+ * the now-missing stale entry. {@link installPiExtensions} prunes every match of
58
+ * this predicate (except the current paths) before re-adding, so a re-run
59
+ * REPLACES rather than duplicates.
60
+ *
61
+ * Match = an agent-tempo package marker (`/agent-tempo@…` version dir, or the
62
+ * `/node_modules/agent-tempo/` package dir) AND an agent-tempo extension suffix
63
+ * (`dist/pi/extension.js` or `dist/pi/mission-control/extension.js`). Both halves
64
+ * are required so a user's own unrelated extension is never pruned. Separators
65
+ * are normalised so the predicate holds on Windows paths too.
66
+ */
67
+ function isAgentTempoExtensionPath(p) {
68
+ const n = p.replace(/\\/g, '/');
69
+ const fromAgentTempo = n.includes('/agent-tempo@') || n.includes('/node_modules/agent-tempo/');
70
+ const isExtensionEntry = n.endsWith('/dist/pi/extension.js') || n.endsWith('/dist/pi/mission-control/extension.js');
71
+ return fromAgentTempo && isExtensionEntry;
72
+ }
48
73
  /** Resolve the Pi settings.json path for the chosen scope. */
49
74
  function piSettingsPath(opts = {}) {
50
75
  if (opts.project)
@@ -57,6 +82,13 @@ function piSettingsPath(opts = {}) {
57
82
  * write when nothing changed). Never copies any extension file — install by
58
83
  * reference only (see file header).
59
84
  *
85
+ * #52 — REPLACE, don't accumulate: before adding the current paths, PRUNE any
86
+ * STALE agent-tempo extension entries ({@link isAgentTempoExtensionPath}, minus
87
+ * the current paths). On a `pnpm` upgrade the package dir is version-hashed, so
88
+ * the recorded absolute path changes — without pruning, `settings.json` would
89
+ * list both the old (now-missing) and new paths and `pi` would fail on the stale
90
+ * one. A user's own unrelated extensions and other settings keys are preserved.
91
+ *
60
92
  * Tolerates a missing / empty / corrupt settings file: a missing file is
61
93
  * created; an unparseable one is replaced with a fresh object carrying just the
62
94
  * extensions (we can only safely merge a valid object). Other recognised keys in
@@ -84,9 +116,15 @@ function installPiExtensions(opts = {}) {
84
116
  const current = Array.isArray(settings.extensions)
85
117
  ? settings.extensions.filter((x) => typeof x === 'string')
86
118
  : [];
119
+ // #52 — prune STALE agent-tempo extension entries (an agent-tempo extension
120
+ // path that is NOT one of the current `want` paths — e.g. an old version-hashed
121
+ // pnpm dir). The current paths and all non-agent-tempo entries keep their
122
+ // original positions.
123
+ const removed = current.filter((p) => !want.includes(p) && isAgentTempoExtensionPath(p));
124
+ const removedSet = new Set(removed);
87
125
  const added = [];
88
126
  const alreadyPresent = [];
89
- const merged = [...current];
127
+ const merged = current.filter((p) => !removedSet.has(p));
90
128
  for (const p of want) {
91
129
  if (merged.includes(p)) {
92
130
  alreadyPresent.push(p);
@@ -97,11 +135,11 @@ function installPiExtensions(opts = {}) {
97
135
  }
98
136
  }
99
137
  settings.extensions = merged;
100
- // Idempotent: only write when something actually changed (or the file is
101
- // absent and must be created). A clean repeat run touches nothing.
102
- if (added.length > 0 || !fileExists) {
138
+ // Idempotent: only write when something actually changed (added, pruned, or the
139
+ // file is absent and must be created). A clean repeat run touches nothing.
140
+ if (added.length > 0 || removed.length > 0 || !fileExists) {
103
141
  (0, fs_1.mkdirSync)((0, path_1.dirname)(settingsPath), { recursive: true });
104
142
  (0, fs_1.writeFileSync)(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
105
143
  }
106
- return { settingsPath, added, alreadyPresent, extensions: merged };
144
+ return { settingsPath, added, alreadyPresent, removed, extensions: merged };
107
145
  }
@@ -32,14 +32,35 @@ export declare class MissionControlActions {
32
32
  private readonly baseUrlOverride;
33
33
  private readonly fetchFn;
34
34
  constructor(opts: MissionControlActionsOptions);
35
- /** Whether the client is usable (token + transport present). */
35
+ /**
36
+ * Whether the client has a usable transport. (#54) NO LONGER gates on the admin
37
+ * token: a loopback daemon grants full trust tokenless, so token presence does
38
+ * NOT determine usability — the daemon decides per request. Token-required is
39
+ * enforced by the daemon (it 401s a remote/0.0.0.0 caller), not pre-empted here.
40
+ */
36
41
  get ready(): boolean;
37
42
  private baseUrl;
43
+ /**
44
+ * Request headers — include the admin bearer ONLY when a token is set (#54). A
45
+ * loopback daemon grants full trust tokenless (it short-circuits all tiers), so
46
+ * we attempt tokenless and never send a literal "Bearer undefined". Mirrors how
47
+ * `createSubscribe` already spreads its token only when present.
48
+ */
49
+ private authHeaders;
50
+ /**
51
+ * Map a non-2xx daemon response to an error string (#54). When NO token was sent
52
+ * and the daemon rejected on auth (401/403) or admin-unset (503), the cause is a
53
+ * remote / `0.0.0.0` daemon that requires the admin token — surface that
54
+ * actionably (a local loopback daemon needs none). Token-present failures keep
55
+ * the daemon's own body detail (it already returns good 403/503 hints).
56
+ */
57
+ private httpError;
38
58
  private post;
39
- /** POST and parse a JSON response body (bearer-authed). Used when the caller
40
- * needs the response payload, not just success — e.g. the coat-check ticket. */
59
+ /** POST and parse a JSON response body. Used when the caller needs the response
60
+ * payload, not just success — e.g. the coat-check ticket. Bearer iff token set (#54). */
41
61
  private postJson;
42
- /** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
62
+ /** GET a JSON body from the daemon. Used by the read surface (#700 readAnswer).
63
+ * Bearer iff token set (#54). */
43
64
  private getJson;
44
65
  private ens;
45
66
  private player;
@@ -29,9 +29,14 @@ class MissionControlActions {
29
29
  this.baseUrlOverride = opts.baseUrl;
30
30
  this.fetchFn = opts.fetchFn ?? resolveFetch();
31
31
  }
32
- /** Whether the client is usable (token + transport present). */
32
+ /**
33
+ * Whether the client has a usable transport. (#54) NO LONGER gates on the admin
34
+ * token: a loopback daemon grants full trust tokenless, so token presence does
35
+ * NOT determine usability — the daemon decides per request. Token-required is
36
+ * enforced by the daemon (it 401s a remote/0.0.0.0 caller), not pre-empted here.
37
+ */
33
38
  get ready() {
34
- return Boolean(this.adminToken) && this.fetchFn !== null;
39
+ return this.fetchFn !== null;
35
40
  }
36
41
  baseUrl() {
37
42
  if (this.baseUrlOverride)
@@ -39,9 +44,32 @@ class MissionControlActions {
39
44
  const port = (0, port_file_1.readPortFile)() ?? DEFAULT_PORT;
40
45
  return `http://127.0.0.1:${port}`;
41
46
  }
47
+ /**
48
+ * Request headers — include the admin bearer ONLY when a token is set (#54). A
49
+ * loopback daemon grants full trust tokenless (it short-circuits all tiers), so
50
+ * we attempt tokenless and never send a literal "Bearer undefined". Mirrors how
51
+ * `createSubscribe` already spreads its token only when present.
52
+ */
53
+ authHeaders(extra = {}) {
54
+ return { ...extra, ...(this.adminToken ? { Authorization: `Bearer ${this.adminToken}` } : {}) };
55
+ }
56
+ /**
57
+ * Map a non-2xx daemon response to an error string (#54). When NO token was sent
58
+ * and the daemon rejected on auth (401/403) or admin-unset (503), the cause is a
59
+ * remote / `0.0.0.0` daemon that requires the admin token — surface that
60
+ * actionably (a local loopback daemon needs none). Token-present failures keep
61
+ * the daemon's own body detail (it already returns good 403/503 hints).
62
+ */
63
+ httpError(status, detail) {
64
+ if (!this.adminToken && (status === 401 || status === 403 || status === 503)) {
65
+ return (`HTTP ${status}: operator actions need ${exports.ADMIN_TOKEN_ENV} for a remote / 0.0.0.0 daemon ` +
66
+ `(a local loopback daemon needs none)${detail ? ` — ${detail}` : ''}`);
67
+ }
68
+ return `HTTP ${status}${detail ? `: ${detail}` : ''}`;
69
+ }
42
70
  async post(pathSuffix, body) {
43
- if (!this.adminToken)
44
- return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
71
+ // #54 — do NOT pre-block on a missing token: attempt the request and let the
72
+ // daemon decide (loopback grants full trust tokenless; remote/0.0.0.0 401s).
45
73
  if (!this.fetchFn)
46
74
  return { ok: false, error: 'no fetch transport available' };
47
75
  const base = this.baseUrl();
@@ -50,23 +78,21 @@ class MissionControlActions {
50
78
  try {
51
79
  const res = await this.fetchFn(`${base}${pathSuffix}`, {
52
80
  method: 'POST',
53
- headers: { Authorization: `Bearer ${this.adminToken}`, 'Content-Type': 'application/json' },
81
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
54
82
  body: JSON.stringify(body ?? {}),
55
83
  });
56
84
  if (res.status >= 200 && res.status < 300)
57
85
  return { ok: true, status: res.status };
58
86
  const detail = (await res.text().catch(() => '')).slice(0, 200);
59
- return { ok: false, error: `HTTP ${res.status}${detail ? `: ${detail}` : ''}` };
87
+ return { ok: false, error: this.httpError(res.status, detail) };
60
88
  }
61
89
  catch (err) {
62
90
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
63
91
  }
64
92
  }
65
- /** POST and parse a JSON response body (bearer-authed). Used when the caller
66
- * needs the response payload, not just success — e.g. the coat-check ticket. */
93
+ /** POST and parse a JSON response body. Used when the caller needs the response
94
+ * payload, not just success — e.g. the coat-check ticket. Bearer iff token set (#54). */
67
95
  async postJson(pathSuffix, body) {
68
- if (!this.adminToken)
69
- return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
70
96
  if (!this.fetchFn)
71
97
  return { ok: false, error: 'no fetch transport available' };
72
98
  const base = this.baseUrl();
@@ -75,22 +101,21 @@ class MissionControlActions {
75
101
  try {
76
102
  const res = await this.fetchFn(`${base}${pathSuffix}`, {
77
103
  method: 'POST',
78
- headers: { Authorization: `Bearer ${this.adminToken}`, 'Content-Type': 'application/json' },
104
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
79
105
  body: JSON.stringify(body ?? {}),
80
106
  });
81
107
  const text = await res.text().catch(() => '');
82
108
  if (res.status < 200 || res.status >= 300)
83
- return { ok: false, error: `HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` };
109
+ return { ok: false, error: this.httpError(res.status, text.slice(0, 200)) };
84
110
  return { ok: true, data: JSON.parse(text) };
85
111
  }
86
112
  catch (err) {
87
113
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
88
114
  }
89
115
  }
90
- /** GET a JSON body from the daemon (bearer-authed). Used by the read surface (#700 readAnswer). */
116
+ /** GET a JSON body from the daemon. Used by the read surface (#700 readAnswer).
117
+ * Bearer iff token set (#54). */
91
118
  async getJson(pathSuffix) {
92
- if (!this.adminToken)
93
- return { ok: false, error: `no admin token (set ${exports.ADMIN_TOKEN_ENV})` };
94
119
  if (!this.fetchFn)
95
120
  return { ok: false, error: 'no fetch transport available' };
96
121
  const base = this.baseUrl();
@@ -99,11 +124,11 @@ class MissionControlActions {
99
124
  try {
100
125
  const res = await this.fetchFn(`${base}${pathSuffix}`, {
101
126
  method: 'GET',
102
- headers: { Authorization: `Bearer ${this.adminToken}` },
127
+ headers: this.authHeaders(),
103
128
  });
104
129
  const text = await res.text().catch(() => '');
105
130
  if (res.status < 200 || res.status >= 300)
106
- return { ok: false, error: `HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` };
131
+ return { ok: false, error: this.httpError(res.status, text.slice(0, 200)) };
107
132
  return { ok: true, data: JSON.parse(text) };
108
133
  }
109
134
  catch (err) {
@@ -1,3 +1,4 @@
1
+ import { type PiRole } from '../../config';
1
2
  import { type BoardModel } from './board';
2
3
  import { MissionControlActions, type ActionResult } from './actions';
3
4
  import { type InfraProgress } from '../../cli/ensure-infra';
@@ -25,6 +26,12 @@ export interface MissionControlDeps {
25
26
  renderThrottleMs?: number;
26
27
  /** Local daemon host for tailability (test override; defaults to `os.hostname()`). */
27
28
  localHost?: string;
29
+ /**
30
+ * #729 — session-role override for the dormancy gate (test seam). Defaults to
31
+ * {@link resolvePiRole}. The board activates ONLY for `'command-center'`;
32
+ * `'player'` and `'none'` both keep it dormant.
33
+ */
34
+ role?: PiRole;
28
35
  }
29
36
  /**
30
37
  * Infra-bootstrap seam (#700 P1). Defaults to the real {@link ensureInfra}; the
@@ -517,6 +517,21 @@ const log = (...args) => {
517
517
  */
518
518
  function createMissionControlExtension(deps = {}) {
519
519
  return (pi) => {
520
+ // ── #729 role gate (MUST stay first — before any registerCommand /
521
+ // registerTool / session_start SSE) ──
522
+ //
523
+ // The board activates ONLY in the command-center role (the operator's
524
+ // `agent-tempo command-center` seat, which sets AGENT_TEMPO_MISSION_CONTROL).
525
+ // A player session ('player') AND a bare coding `pi` ('none') both keep the
526
+ // board DORMANT — no commands/tools registered, no coarse SSE opened. This is
527
+ // the other half of #729 mutual exclusion: exactly one extension registers
528
+ // tools per session (no cue/recruit name collision with the player surface),
529
+ // and a plain `pi` stays pristine (A2 — affirmative opt-in only).
530
+ const role = deps.role ?? (0, config_1.resolvePiRole)();
531
+ if (role !== 'command-center') {
532
+ log(`dormant — session role is '${role}', not command-center: board not activated.`);
533
+ return;
534
+ }
520
535
  const ensemble = deps.ensemble ?? (0, config_1.getConfig)().ensemble;
521
536
  const adminToken = deps.adminToken ?? process.env[actions_1.ADMIN_TOKEN_ENV];
522
537
  const throttleMs = deps.renderThrottleMs ?? DEFAULT_RENDER_THROTTLE_MS;
@@ -549,8 +564,11 @@ function createMissionControlExtension(deps = {}) {
549
564
  activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model, ctrl.localHost), { placement: 'aboveEditor' });
550
565
  };
551
566
  const startCoarse = () => {
567
+ // #54 — accurate posture: a tokenless board is FULLY functional against a
568
+ // local (loopback) daemon, which grants full trust. Only a REMOTE / 0.0.0.0
569
+ // daemon requires the admin token (it 401s tokenless reads + actions).
552
570
  if (!adminToken) {
553
- log(`no admin token (${actions_1.ADMIN_TOKEN_ENV})board limited / disabled`);
571
+ log(`no ${actions_1.ADMIN_TOKEN_ENV} — OK for a local loopback daemon (full trust); a remote / 0.0.0.0 daemon will require it`);
554
572
  }
555
573
  // H5: capture the controller locally. teardown nulls the outer `coarseAbort`,
556
574
  // so the catch must check THIS signal — checking the nulled outer ref made an
@@ -589,7 +607,9 @@ function createMissionControlExtension(deps = {}) {
589
607
  const openTail = (playerId) => {
590
608
  tailAbort?.abort();
591
609
  tailAbort = null;
592
- if (playerId === null || !adminToken)
610
+ // #54 do NOT gate on the token: the loopback daemon serves /inner tokenless.
611
+ // openInnerTail sends the bearer iff present; a remote/0.0.0.0 daemon 401s → onError.
612
+ if (playerId === null)
593
613
  return;
594
614
  tailAbort = new AbortController();
595
615
  // H5: resolve the daemon base URL HERE (per /tail) so a port change is
@@ -32,7 +32,13 @@ export type TailFetch = (url: string, init: {
32
32
  }>;
33
33
  export interface OpenInnerTailOptions {
34
34
  baseUrl: string;
35
- adminToken: string;
35
+ /**
36
+ * Admin (T3) token. OPTIONAL (#54): a loopback daemon serves the `/inner`
37
+ * egress tokenless (full-trust short-circuit). When absent, no `Authorization`
38
+ * header is sent and the daemon decides — a remote / `0.0.0.0` daemon 401s
39
+ * (surfaced via `onError`).
40
+ */
41
+ adminToken?: string;
36
42
  ensemble: string;
37
43
  playerId: string;
38
44
  onFrame: (frame: InnerFrame) => void;
@@ -44,7 +44,11 @@ async function openInnerTail(opts) {
44
44
  try {
45
45
  res = await opts.fetchFn(url, {
46
46
  method: 'GET',
47
- headers: { Authorization: `Bearer ${opts.adminToken}`, Accept: 'text/event-stream' },
47
+ headers: {
48
+ Accept: 'text/event-stream',
49
+ // #54 — bearer ONLY when a token is set; loopback serves tokenless.
50
+ ...(opts.adminToken ? { Authorization: `Bearer ${opts.adminToken}` } : {}),
51
+ },
48
52
  signal: opts.signal,
49
53
  });
50
54
  }
@@ -54,7 +58,11 @@ async function openInnerTail(opts) {
54
58
  return;
55
59
  }
56
60
  if (res.status !== 200 || !res.body) {
57
- opts.onError?.(`inner tail HTTP ${res.status}`);
61
+ // #54 — a tokenless 401/403 means a remote / 0.0.0.0 daemon that needs the token.
62
+ const hint = !opts.adminToken && (res.status === 401 || res.status === 403)
63
+ ? ' (set AGENT_TEMPO_HTTP_ADMIN_TOKEN for a remote/0.0.0.0 daemon; loopback needs none)'
64
+ : '';
65
+ opts.onError?.(`inner tail HTTP ${res.status}${hint}`);
58
66
  return;
59
67
  }
60
68
  const decoder = new TextDecoder();
package/dist/spawn.d.ts CHANGED
@@ -174,6 +174,45 @@ export declare function buildPiConductorSpawn(opts: PiConductorSpawnOpts): {
174
174
  args: string[];
175
175
  env: Record<string, string>;
176
176
  };
177
+ /** Inputs for {@link buildPiCommandCenterSpawn} (pure — unit-tested without spawning). */
178
+ export interface PiCommandCenterSpawnOpts {
179
+ ensemble: string;
180
+ /** Temporal env (address/namespace/api-key/tls) built by the caller. */
181
+ temporalEnvVars: Record<string, string>;
182
+ /** Temporal task queue (forwarded for config parity; the board drives the daemon via HTTP). */
183
+ taskQueue: string;
184
+ devMode: boolean;
185
+ /** Daemon admin (T3) token → `AGENT_TEMPO_HTTP_ADMIN_TOKEN` (mission-control's write/gate surface). */
186
+ adminToken?: string;
187
+ /** Forwarded if set (Pi's own model auth). */
188
+ anthropicApiKey?: string;
189
+ /** Injectable resolver (defaults to the real one, which fails clean on miss). */
190
+ resolveBinary?: () => {
191
+ cmd: string;
192
+ args: string[];
193
+ };
194
+ }
195
+ /**
196
+ * Build the interactive Pi COMMAND-CENTER (mission-control) spawn spec —
197
+ * `{ cmd, args, env }` for {@link launchInTerminal} (#729). PURE + injectable.
198
+ *
199
+ * Unlike {@link buildPiConductorSpawn}, this passes NO `-e <ext>`: install-pi
200
+ * registers BOTH Pi extensions in `~/.pi/agent/settings.json`, so a plain `pi`
201
+ * auto-loads them and {@link resolvePiRole} (via the env below) picks exactly one.
202
+ * Passing `-e` here would DOUBLE-LOAD mission-control (settings.json + `-e`) → a
203
+ * command re-registration error. The env carries the OPERATOR subset only:
204
+ * - `AGENT_TEMPO_MISSION_CONTROL=1` → the role opt-in (resolver → `command-center`).
205
+ * - `AGENT_TEMPO_ENSEMBLE` → which ensemble the board observes.
206
+ * - `AGENT_TEMPO_HTTP_ADMIN_TOKEN` → the daemon write/gate surface the board POSTs to.
207
+ *
208
+ * ★ It MUST NOT set `PLAYER_NAME` or `CONDUCTOR` — either flips {@link resolvePiRole}
209
+ * to `'player'`, which would keep the board dormant (and self-bootstrap a player).
210
+ */
211
+ export declare function buildPiCommandCenterSpawn(opts: PiCommandCenterSpawnOpts): {
212
+ cmd: string;
213
+ args: string[];
214
+ env: Record<string, string>;
215
+ };
177
216
  export interface CopilotBridgeOpts {
178
217
  name: string;
179
218
  ensemble: string;
package/dist/spawn.js CHANGED
@@ -16,6 +16,7 @@ exports.spawnInTerminal = spawnInTerminal;
16
16
  exports.resolvePiInteractiveBinary = resolvePiInteractiveBinary;
17
17
  exports.resolvePiExtensionPath = resolvePiExtensionPath;
18
18
  exports.buildPiConductorSpawn = buildPiConductorSpawn;
19
+ exports.buildPiCommandCenterSpawn = buildPiCommandCenterSpawn;
19
20
  exports.spawnCopilotBridge = spawnCopilotBridge;
20
21
  exports.spawnMockAdapter = spawnMockAdapter;
21
22
  exports.spawnClaudeApiAdapter = spawnClaudeApiAdapter;
@@ -673,6 +674,37 @@ function buildPiConductorSpawn(opts) {
673
674
  };
674
675
  return { cmd, args, env };
675
676
  }
677
+ /**
678
+ * Build the interactive Pi COMMAND-CENTER (mission-control) spawn spec —
679
+ * `{ cmd, args, env }` for {@link launchInTerminal} (#729). PURE + injectable.
680
+ *
681
+ * Unlike {@link buildPiConductorSpawn}, this passes NO `-e <ext>`: install-pi
682
+ * registers BOTH Pi extensions in `~/.pi/agent/settings.json`, so a plain `pi`
683
+ * auto-loads them and {@link resolvePiRole} (via the env below) picks exactly one.
684
+ * Passing `-e` here would DOUBLE-LOAD mission-control (settings.json + `-e`) → a
685
+ * command re-registration error. The env carries the OPERATOR subset only:
686
+ * - `AGENT_TEMPO_MISSION_CONTROL=1` → the role opt-in (resolver → `command-center`).
687
+ * - `AGENT_TEMPO_ENSEMBLE` → which ensemble the board observes.
688
+ * - `AGENT_TEMPO_HTTP_ADMIN_TOKEN` → the daemon write/gate surface the board POSTs to.
689
+ *
690
+ * ★ It MUST NOT set `PLAYER_NAME` or `CONDUCTOR` — either flips {@link resolvePiRole}
691
+ * to `'player'`, which would keep the board dormant (and self-bootstrap a player).
692
+ */
693
+ function buildPiCommandCenterSpawn(opts) {
694
+ const { cmd, args } = (opts.resolveBinary ?? resolvePiInteractiveBinary)();
695
+ const env = {
696
+ ...opts.temporalEnvVars,
697
+ [config_1.ENV.TASK_QUEUE]: opts.taskQueue,
698
+ [config_1.ENV.ENSEMBLE]: opts.ensemble,
699
+ [config_1.ENV.MISSION_CONTROL]: '1', // #729 A2 role opt-in → resolver picks 'command-center'
700
+ [config_1.ENV.NO_PPID_WATCHDOG]: '1', // launched detached by the transient CLI (mirrors the conductor)
701
+ ...(opts.devMode ? { [config_1.ENV.DEV_MODE]: '1' } : {}),
702
+ ...(opts.adminToken ? { [config_1.ENV.HTTP_ADMIN_TOKEN]: opts.adminToken } : {}),
703
+ ...(opts.anthropicApiKey ? { ANTHROPIC_API_KEY: opts.anthropicApiKey } : {}),
704
+ };
705
+ // Deliberately NO ENV.PLAYER_NAME / ENV.CONDUCTOR — they'd flip the role to player.
706
+ return { cmd, args, env };
707
+ }
676
708
  /**
677
709
  * Resolve the path to the compiled copilot bridge adapter entry point.
678
710
  * In dev (ts-node), returns a ts-node command; in production, returns the dist path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.7.0-beta.5",
3
+ "version": "1.7.0-beta.7",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",