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 +2 -1
- package/dashboard/package.json +1 -1
- package/dist/cli/command-center-command.d.ts +28 -0
- package/dist/cli/command-center-command.js +116 -0
- package/dist/cli/help-text.js +1 -0
- package/dist/cli.js +22 -1
- package/dist/config.d.ts +47 -0
- package/dist/config.js +46 -0
- package/dist/pi/extension.js +21 -0
- package/dist/pi/install.d.ts +32 -0
- package/dist/pi/install.js +43 -5
- package/dist/pi/mission-control/actions.d.ts +25 -4
- package/dist/pi/mission-control/actions.js +42 -17
- package/dist/pi/mission-control/extension.d.ts +7 -0
- package/dist/pi/mission-control/extension.js +22 -2
- package/dist/pi/mission-control/inner-tail.d.ts +7 -1
- package/dist/pi/mission-control/inner-tail.js +10 -2
- package/dist/spawn.d.ts +39 -0
- package/dist/spawn.js +32 -0
- package/package.json +1 -1
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
|
|
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/`.
|
package/dashboard/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo-dashboard",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.7.0-beta.
|
|
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
|
+
}
|
package/dist/cli/help-text.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/pi/extension.js
CHANGED
|
@@ -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);
|
package/dist/pi/install.d.ts
CHANGED
|
@@ -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
|
package/dist/pi/install.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
40
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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:
|
|
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:
|
|
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
|
|
66
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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.
|