agent-tempo 1.7.0-beta.4 → 1.7.0-beta.6

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
@@ -211,7 +211,7 @@ daemon worker notes, `npx ts-node` dev runner).
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
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
- - **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/`.
214
+ - **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
215
  - **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
216
  - **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
217
  - **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.4",
4
+ "version": "1.7.0-beta.6",
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,114 @@
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
+ // Without it the board still OBSERVES (coarse SSE), but operator actions return
76
+ // 401; warn rather than block so a read-only board is still useful.
77
+ const adminToken = process.env[config_1.ENV.HTTP_ADMIN_TOKEN];
78
+ if (!adminToken) {
79
+ out.warn(`${config_1.ENV.HTTP_ADMIN_TOKEN} is not set — the board will observe read-only; operator ` +
80
+ 'actions (cue/pause/restart/gate) need the admin token. Export it before launching for full control.');
81
+ }
82
+ if (!process.env.ANTHROPIC_API_KEY) {
83
+ out.warn('ANTHROPIC_API_KEY is not set — the Pi command-center will fall back to Pi\'s own auth/default model.');
84
+ }
85
+ const temporalEnvVars = {
86
+ [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
87
+ [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
88
+ };
89
+ if (config.temporalApiKey)
90
+ temporalEnvVars[config_1.ENV.TEMPORAL_API_KEY] = config.temporalApiKey;
91
+ if (config.temporalTlsCertPath)
92
+ temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
93
+ if (config.temporalTlsKeyPath)
94
+ temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
95
+ let spawn;
96
+ try {
97
+ spawn = (0, spawn_1.buildPiCommandCenterSpawn)({
98
+ ensemble,
99
+ temporalEnvVars,
100
+ taskQueue: config.taskQueue,
101
+ devMode: (0, config_1.isDevMode)(),
102
+ ...(adminToken ? { adminToken } : {}),
103
+ ...(process.env.ANTHROPIC_API_KEY ? { anthropicApiKey: process.env.ANTHROPIC_API_KEY } : {}),
104
+ });
105
+ }
106
+ catch (err) {
107
+ out.error(`Cannot start command-center — ${err instanceof Error ? err.message : String(err)}`);
108
+ process.exit(1);
109
+ }
110
+ const { pid } = (0, spawn_1.launchInTerminal)(spawn.cmd, spawn.args, process.cwd(), spawn.env);
111
+ out.success(`Command-center launched for ensemble ${out.cyan(ensemble)} (pid ${pid ?? 'unknown'})`);
112
+ out.log(` ${out.dim('Observer-only Pi board — the player extension stays dormant in this session.')}`);
113
+ out.log(` ${out.dim('Requires `agent-tempo install-pi` to have registered the extensions.')}`);
114
+ }
@@ -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
@@ -631,7 +643,13 @@ async function main() {
631
643
  for (const p of result.alreadyPresent)
632
644
  out.log(out.dim(` · ${p} (already installed)`));
633
645
  out.log('');
634
- out.log(' Restart `pi` to load the agent-tempo extensions.');
646
+ // #729 the extensions are role-gated: exactly one activates per session.
647
+ // Point operators at the DELIBERATE launches, not a bare `pi` (which now
648
+ // intentionally keeps BOTH dormant so plain coding sessions stay pristine).
649
+ out.log(' Launch a session with one of:');
650
+ out.log(` ${out.cyan('agent-tempo up --agent pi')} a conductor/player in this ensemble`);
651
+ out.log(` ${out.cyan('agent-tempo command-center')} the operator mission-control board`);
652
+ out.log(out.dim(' A bare `pi` stays a plain coding session (neither extension activates).'));
635
653
  break;
636
654
  }
637
655
  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);
@@ -38,42 +38,37 @@ export interface RunHeadlessPiOptions {
38
38
  continueSessionId?: string;
39
39
  }
40
40
  /**
41
- * Build the `DefaultResourceLoader` options for a headless Pi player.
41
+ * #715 — compute the registration-level `excludeTools` denylist for
42
+ * `createAgentSession`. Excluded tools are never registered → ABSENT from the
43
+ * model's toolset AND system prompt: the LLM cannot request what it never sees.
42
44
  *
43
- * SECURITY S2 (MD-C deny-list soundness). The `restricted` tool gate is a
44
- * DENY-LIST over shell/exec tool *names* (tool-capability.ts EXEC_TOOLS, via
45
- * `classify(name) === 'exec'` F1 replaced extension.ts's former local set). That
46
- * guarantee "restricted = no host execution" holds ONLY IF no third-party
47
- * extension can register an un-blacklisted execution tool (e.g. a custom
48
- * `python` / `npm` / `run` tool). It therefore depends on a hard structural
49
- * fact: which extensions Pi loads.
45
+ * This is a registration-level FLOOR beneath the call-time MD-C handler + #712
46
+ * gate. It is tamper-RESISTANT, NOT tamper-PROOF: it defends a PROMPT-INJECTED
47
+ * agent (and holds even if the call-time gate had a bug — the tool simply isn't
48
+ * there), but it does NOT defend against PROCESS COMPROMISE a tampered /
49
+ * modified extension can re-register or un-exclude tools (this is OUR code
50
+ * passing a denylist; an attacker who modifies the code/process bypasses it).
51
+ * That residual is OS-sandbox + supply-chain integrity, tracked as #724.
50
52
  *
51
- * Verified against the installed Pi SDK 0.78 source (NOT assumed):
52
- * - `DefaultResourceLoader.reload()` (resource-loader.js:271-276) builds
53
- * `extensionPaths = noExtensions ? cliEnabledExtensions
54
- * : merge(cliEnabledExtensions, enabledExtensions)`
55
- * where `enabledExtensions` (line 229) are the DISK/package extensions from
56
- * `packageManager.resolve()` (`~/.pi/agent/extensions/`, `<cwd>/.pi/extensions/`,
57
- * installed packages). `loadExtensions(extensionPaths)` then loads them and
58
- * MERGES with our inline factories (lines 274-276).
59
- * - `noExtensions` defaults to `false` (constructor, line 132) so the naive
60
- * loader DOES load disk extensions. That is the S2 gap.
53
+ * `excludeTools` is matched by NAME against BOTH Pi built-ins AND
54
+ * extension-registered tools (incl. agent-tempo's MCP tools via `renderToPi`), so
55
+ * this list contains ONLY Pi-built-in / exec names — never agent-tempo tool names
56
+ * (`cue`/`report`/`recruit`/…). Posture:
57
+ * - `toolAccess === 'restricted'` exclude {@link EXEC_TOOLS} (exec/bash
58
+ * registration-absent; a strict upgrade of the prior call-time block, and the
59
+ * headless default the model never even sees exec).
60
+ * - `guardrailPolicy === 'observe-only'` also exclude the Pi built-in act
61
+ * tools ({@link PI_BUILTIN_ACT_TOOLS}); read/grep/glob stay. The agent-tempo
62
+ * MCP act tools (recruit/destroy/…) stay covered by the client-side no-act
63
+ * handler (commit 5) — excludeTools handles the Pi built-ins only.
64
+ * - `monitored` / `supervised` / `autonomous` → NO exec exclusion: those tools
65
+ * stay REGISTERED so they can be gated/approved per-use (#712). (`supervised`
66
+ * = approve-and-run, NOT exec-absent.)
61
67
  *
62
- * Fix (= security's "exclude the extensions dir", done structurally):
63
- * - `noExtensions: true` `extensionPaths` collapses to `cliEnabledExtensions`,
64
- * which is empty because we pass NO `additionalExtensionPaths`. So
65
- * `loadExtensions([])` registers nothing from disk/packages.
66
- * - Inline `extensionFactories` load UNCONDITIONALLY (reload() line 275 is not
67
- * gated by `noExtensions`), so our agent-tempo extension still attaches.
68
- * Net: the ONLY tools present are Pi's built-ins (bash/read/edit/write/grep —
69
- * all covered by the deny-list) + our agent-tempo MCP tools (no exec). No
70
- * third-party tool can slip past the deny-list. Skills/prompts/themes cannot
71
- * register tools, so they are not a vector and are left at defaults.
72
- *
73
- * Kept as a pure, exported helper so the `noExtensions: true` invariant has a
74
- * unit regression test (test/pi-headless-loader.test.ts) without needing the Pi
75
- * SDK installed.
68
+ * Pure + exported so the registration-absence invariant has a unit regression
69
+ * test without the Pi SDK (mirrors {@link buildPiResourceLoaderOptions}).
76
70
  */
71
+ export declare function computeExcludeTools(toolAccess: PiToolAccess, guardrailPolicy: GuardrailPolicy | undefined): string[];
77
72
  export declare function buildPiResourceLoaderOptions(params: {
78
73
  cwd: string;
79
74
  agentDir: string;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeExcludeTools = computeExcludeTools;
3
4
  exports.buildPiResourceLoaderOptions = buildPiResourceLoaderOptions;
4
5
  exports.runHeadlessPi = runHeadlessPi;
5
6
  /**
@@ -31,6 +32,7 @@ exports.runHeadlessPi = runHeadlessPi;
31
32
  const config_1 = require("../config");
32
33
  const sdk_probe_1 = require("../utils/sdk-probe");
33
34
  const extension_1 = require("./extension");
35
+ const tool_capability_1 = require("../security/tool-capability");
34
36
  const probe_1 = require("./probe");
35
37
  const session_seed_1 = require("./session-seed");
36
38
  const log = (...args) => {
@@ -114,6 +116,61 @@ async function resolveModel(modelStr) {
114
116
  * unit regression test (test/pi-headless-loader.test.ts) without needing the Pi
115
117
  * SDK installed.
116
118
  */
119
+ /**
120
+ * Pi BUILT-IN mutating ("act") tool names, excluded at registration for the
121
+ * `observe-only` no-act posture (#715). Pi's default built-ins are
122
+ * read/bash/edit/write; `multiedit` is listed defensively (a no-op if Pi doesn't
123
+ * register it). `bash`/exec are covered by {@link EXEC_TOOLS}. We keep the READ
124
+ * built-ins (read/grep/glob/ls). These are Pi BUILT-IN names — deliberately NOT
125
+ * agent-tempo MCP tool names, so excluding them never removes an agent-tempo
126
+ * coordination tool (those stay handler-gated, commit 5).
127
+ */
128
+ const PI_BUILTIN_ACT_TOOLS = ['write', 'edit', 'multiedit'];
129
+ /**
130
+ * #715 — compute the registration-level `excludeTools` denylist for
131
+ * `createAgentSession`. Excluded tools are never registered → ABSENT from the
132
+ * model's toolset AND system prompt: the LLM cannot request what it never sees.
133
+ *
134
+ * This is a registration-level FLOOR beneath the call-time MD-C handler + #712
135
+ * gate. It is tamper-RESISTANT, NOT tamper-PROOF: it defends a PROMPT-INJECTED
136
+ * agent (and holds even if the call-time gate had a bug — the tool simply isn't
137
+ * there), but it does NOT defend against PROCESS COMPROMISE — a tampered /
138
+ * modified extension can re-register or un-exclude tools (this is OUR code
139
+ * passing a denylist; an attacker who modifies the code/process bypasses it).
140
+ * That residual is OS-sandbox + supply-chain integrity, tracked as #724.
141
+ *
142
+ * `excludeTools` is matched by NAME against BOTH Pi built-ins AND
143
+ * extension-registered tools (incl. agent-tempo's MCP tools via `renderToPi`), so
144
+ * this list contains ONLY Pi-built-in / exec names — never agent-tempo tool names
145
+ * (`cue`/`report`/`recruit`/…). Posture:
146
+ * - `toolAccess === 'restricted'` → exclude {@link EXEC_TOOLS} (exec/bash
147
+ * registration-absent; a strict upgrade of the prior call-time block, and the
148
+ * headless default → the model never even sees exec).
149
+ * - `guardrailPolicy === 'observe-only'` → also exclude the Pi built-in act
150
+ * tools ({@link PI_BUILTIN_ACT_TOOLS}); read/grep/glob stay. The agent-tempo
151
+ * MCP act tools (recruit/destroy/…) stay covered by the client-side no-act
152
+ * handler (commit 5) — excludeTools handles the Pi built-ins only.
153
+ * - `monitored` / `supervised` / `autonomous` → NO exec exclusion: those tools
154
+ * stay REGISTERED so they can be gated/approved per-use (#712). (`supervised`
155
+ * = approve-and-run, NOT exec-absent.)
156
+ *
157
+ * Pure + exported so the registration-absence invariant has a unit regression
158
+ * test without the Pi SDK (mirrors {@link buildPiResourceLoaderOptions}).
159
+ */
160
+ function computeExcludeTools(toolAccess, guardrailPolicy) {
161
+ const exclude = new Set();
162
+ if (toolAccess === 'restricted') {
163
+ for (const t of tool_capability_1.EXEC_TOOLS)
164
+ exclude.add(t);
165
+ }
166
+ if (guardrailPolicy === 'observe-only') {
167
+ for (const t of tool_capability_1.EXEC_TOOLS)
168
+ exclude.add(t); // no-act ⊇ no-exec
169
+ for (const t of PI_BUILTIN_ACT_TOOLS)
170
+ exclude.add(t);
171
+ }
172
+ return [...exclude];
173
+ }
117
174
  function buildPiResourceLoaderOptions(params) {
118
175
  return {
119
176
  cwd: params.cwd,
@@ -192,10 +249,20 @@ async function runHeadlessPi(opts = {}) {
192
249
  // heartbeat). The SDK's own doc comment (sdk.js:74-83) prescribes this exact
193
250
  // construct → reload() → pass-as-resourceLoader sequence.
194
251
  await resourceLoader.reload();
252
+ // #715 — registration-level exec/act exclusion (the true "agent physically
253
+ // lacks the tools" boundary; see computeExcludeTools). Excluded tools are never
254
+ // registered, so they're absent from the model's toolset + system prompt — a
255
+ // hard layer beyond the call-time MD-C handler + #712 gate (kept as
256
+ // belt-and-suspenders). Empty for monitored/supervised/autonomous+standard.
257
+ const excludeTools = computeExcludeTools(toolAccess, opts.guardrailPolicy);
258
+ if (excludeTools.length > 0) {
259
+ log(`#715: excluding ${excludeTools.length} tool(s) at registration (toolAccess=${toolAccess}, guardrailPolicy=${opts.guardrailPolicy ?? 'autonomous'}): ${excludeTools.join(', ')}`);
260
+ }
195
261
  const { session } = await createAgentSession({
196
262
  cwd: process.cwd(),
197
263
  agentDir,
198
264
  ...(model ? { model } : {}),
265
+ ...(excludeTools.length > 0 ? { excludeTools } : {}),
199
266
  resourceLoader,
200
267
  // H1 (#645): in-memory session (seeded above via the session-seed chokepoint).
201
268
  // H2 will seed it from agent-tempo durable state (ENV.PI_CONTINUE_SESSION
@@ -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;
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/dist/types.d.ts CHANGED
@@ -238,22 +238,33 @@ export interface SessionMetadata {
238
238
  * the real posture on EVERY attach (across restart / migrate / re-attach), so
239
239
  * a previously-`supervised` agent stays supervised. (tempo-architect ruling.)
240
240
  *
241
- * **★ Enforcement scope (#715).** `supervised` is the daemon-enforced approval
242
- * boundary for the realistic threat: a prompt-injected agent. A manipulated LLM
243
- * can only *emit* tool-call requests — Pi routes every one to agent-tempo's
244
- * `tool_call` handler, which engages the gate (non-`low-risk`) or hard-blocks
245
- * (exec tools at `toolAccess: 'restricted'`). The agent **cannot** skip the gate
246
- * or run a dangerous tool directly it doesn't control the hook. The daemon also
247
- * derives `failMode` from this durable policy (populated at spawn, falling
248
- * `closed` on any lookup failure — no-fail-open), so an engaging agent can't
249
- * self-downgrade a supervised player out of fail-closed.
241
+ * **★ Enforcement scope (#712/#715).** `supervised` is the daemon-enforced
242
+ * approval boundary for the realistic threat: a prompt-injected agent. A
243
+ * manipulated LLM can only *emit* tool-call requests — Pi routes every one to
244
+ * agent-tempo's `tool_call` handler, which engages the gate (non-`low-risk`;
245
+ * #712 daemon-computes `failMode` from this durable policy, falling `closed` on
246
+ * any lookup failure no-fail-open, so an engaging agent can't self-downgrade).
247
+ * The agent **cannot** skip the gate it doesn't control the hook.
250
248
  *
251
- * The **residual** is *process compromise*: code execution **inside** the Pi
252
- * process (host RCE bypassing the handler entirely). No client-side gate defends
253
- * that it requires OS-level process sandboxing, tracked as a separate future
254
- * `'sandboxed'` posture (#724). That is **not a gap in `supervised`'s scope**:
255
- * supervised targets prompt-injection, and against that threat it **is** a real
256
- * enforcement boundary.
249
+ * **#715 adds a registration-level floor.** For `toolAccess: 'restricted'` (and
250
+ * `observe-only`'s act tools) the exec/act tools are EXCLUDED at
251
+ * `createAgentSession` (`excludeTools`) **absent** from the model's toolset and
252
+ * system prompt entirely; the LLM cannot request what it never sees. That is
253
+ * stronger than a call-time block it holds even if the call-time gate had a bug
254
+ * (the tool simply isn't there). `supervised` with exec present keeps exec
255
+ * **present + gated** (approve-per-use), so this floor applies to the exec/no-act
256
+ * postures, not to a `supervised`+`standard` player.
257
+ *
258
+ * **Residual (all postures): process compromise** — code execution *inside* the
259
+ * Pi process (in-process syscalls; host RCE bypassing the handler), OR a
260
+ * tampered / modified extension that un-excludes or re-registers tools.
261
+ * `excludeTools` is OUR code passing a denylist; an attacker who modifies that
262
+ * code or the process bypasses it. The only defense is OS-level sandboxing +
263
+ * supply-chain integrity, a separate future `'sandboxed'` posture (#724). So:
264
+ * **tamper-RESISTANT** vs prompt-injection + an honest gate bug; **NOT
265
+ * tamper-PROOF** vs a compromised process. Against prompt-injection — the
266
+ * realistic threat — it **is** a real enforcement boundary; #724 is not a gap in
267
+ * that scope.
257
268
  *
258
269
  * **Post-restart window:** on daemon restart the in-memory ingest tokens are
259
270
  * invalidated, so existing players' gate engagements are rejected (403) until a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.7.0-beta.4",
3
+ "version": "1.7.0-beta.6",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",