agent-tempo 1.6.2 → 1.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.6.2",
4
+ "version": "1.7.0-beta.1",
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": {
@@ -80,6 +80,8 @@ const agent_types_1 = require("../ensemble/agent-types");
80
80
  const validation_1 = require("../utils/validation");
81
81
  const search_attributes_1 = require("../utils/search-attributes");
82
82
  const daemon_1 = require("./daemon");
83
+ // #700 P1 — infra bootstrap moved to a shared helper (CLI `up` + `/ensemble-up`).
84
+ const ensure_infra_1 = require("./ensure-infra");
83
85
  const client_2 = require("../client");
84
86
  const constants_1 = require("../constants");
85
87
  const recall_format_1 = require("../utils/recall-format");
@@ -902,30 +904,9 @@ function initProject(dir) {
902
904
  out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
903
905
  }
904
906
  // --- Temporal server management ---
905
- const DEFAULT_DB_PATH = (0, path_1.join)(config_1.AGENT_TEMPO_HOME, 'temporal-data.db');
906
- // Source of truth lives in `sa-preflight.ts` (REQUIRED_SEARCH_ATTRIBUTES)
907
- // avoid drifting a second copy here.
908
- const sa_preflight_1 = require("./sa-preflight");
909
- async function isTemporalReachable(config) {
910
- try {
911
- const conn = await (0, connection_1.createTemporalConnection)(config);
912
- try {
913
- // Verify namespace is ready — a gRPC connection alone doesn't guarantee the server can serve requests
914
- const client = new client_1.Client({ connection: conn, namespace: config.temporalNamespace || 'default' });
915
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
916
- for await (const _ of client.workflow.list({ query: 'WorkflowId = "__readiness_probe__"' })) {
917
- break;
918
- }
919
- }
920
- finally {
921
- await conn.close();
922
- }
923
- return true;
924
- }
925
- catch {
926
- return false;
927
- }
928
- }
907
+ // #700 P1 — DEFAULT_DB_PATH, isTemporalReachable, and registerSearchAttributes
908
+ // MOVED to ./ensure-infra (the shared infra home; imported back below) so the CLI
909
+ // `up` path and the mission-control `/ensemble-up` extension call ONE bootstrap.
929
910
  function temporalCliExists() {
930
911
  const cmd = process.platform === 'win32' ? 'where' : 'which';
931
912
  try {
@@ -936,52 +917,6 @@ function temporalCliExists() {
936
917
  return false;
937
918
  }
938
919
  }
939
- function registerSearchAttributes(temporalAddress, namespace = 'default') {
940
- let failed = 0;
941
- let permissionBlocked = 0;
942
- for (const attr of sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES) {
943
- const r = (0, sa_preflight_1.registerSearchAttribute)(attr, temporalAddress, namespace);
944
- switch (r.status) {
945
- case 'created':
946
- out.success(`Registered search attribute: ${attr.name}`);
947
- break;
948
- case 'already-exists':
949
- out.dim(` ${attr.name} (already registered)`);
950
- break;
951
- case 'failed':
952
- // A PERMISSION error (Temporal Cloud namespace API keys can't reach the
953
- // operator service) means we can't tell whether the SA exists — NOT that
954
- // it's missing. Don't print a scary per-attr "Failed to register" or count
955
- // it as a failure; collapse to ONE soft line below and PROCEED. Reserve
956
- // the per-attr warning + hard "will fail" conclusion for DEFINITIVE
957
- // failures (e.g. the SQLite dev server's 10-Keyword-per-namespace cap).
958
- if ((0, sa_preflight_1.isPermissionError)(r.detail)) {
959
- permissionBlocked++;
960
- }
961
- else {
962
- failed++;
963
- out.warn(`Failed to register ${attr.name}: ${r.detail}`);
964
- }
965
- break;
966
- }
967
- }
968
- // Permission-blocked (normal on Temporal Cloud): one accurate, non-alarming
969
- // line — we couldn't manage the SAs, but that doesn't mean they're missing.
970
- if (permissionBlocked > 0) {
971
- const saList = sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.map((a) => `${a.name}:${a.type}`).join(', ');
972
- out.warn(`Couldn't verify search attributes — this credential lacks permission to manage them ` +
973
- `(normal on Temporal Cloud, where search attributes are managed via the Cloud UI or tcld). ` +
974
- `If workflow starts fail with "search attribute ... is not defined", create these ` +
975
- `${sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.length} via the Cloud UI / tcld: ${saList}. ` +
976
- `Otherwise this is safe to ignore.`);
977
- }
978
- // DEFINITIVE failures genuinely block — keep the hard, actionable conclusion.
979
- if (failed > 0) {
980
- out.warn(`${failed} search attribute${failed === 1 ? '' : 's'} not registered — ` +
981
- `workflow starts will fail. Resolve the errors above before continuing.`);
982
- }
983
- return { failed };
984
- }
985
920
  async function server(opts) {
986
921
  const config = (0, config_1.getConfig)(opts);
987
922
  if (!temporalCliExists()) {
@@ -990,11 +925,11 @@ async function server(opts) {
990
925
  process.exit(1);
991
926
  }
992
927
  // Check if already running
993
- const alreadyRunning = await isTemporalReachable(config);
928
+ const alreadyRunning = await (0, ensure_infra_1.isTemporalReachable)(config);
994
929
  if (alreadyRunning) {
995
930
  out.success(`Temporal already running at ${config.temporalAddress}`);
996
931
  out.log(' Registering search attributes...');
997
- registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
932
+ (0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
998
933
  return;
999
934
  }
1000
935
  // Ensure data directory exists
@@ -1003,10 +938,10 @@ async function server(opts) {
1003
938
  const args = [
1004
939
  'server', 'start-dev',
1005
940
  '--port', port,
1006
- '--db-filename', DEFAULT_DB_PATH,
941
+ '--db-filename', ensure_infra_1.DEFAULT_DB_PATH,
1007
942
  ];
1008
943
  out.log(`Starting Temporal dev server on port ${port}...`);
1009
- out.log(` Data: ${out.dim(DEFAULT_DB_PATH)}`);
944
+ out.log(` Data: ${out.dim(ensure_infra_1.DEFAULT_DB_PATH)}`);
1010
945
  if (opts.background) {
1011
946
  const child = (0, child_process_1.spawn)('temporal', args, {
1012
947
  detached: true,
@@ -1017,7 +952,7 @@ async function server(opts) {
1017
952
  // Wait for it to be ready
1018
953
  for (let i = 0; i < 20; i++) {
1019
954
  await new Promise(r => setTimeout(r, 500));
1020
- if (await isTemporalReachable(config))
955
+ if (await (0, ensure_infra_1.isTemporalReachable)(config))
1021
956
  break;
1022
957
  }
1023
958
  }
@@ -1030,10 +965,10 @@ async function server(opts) {
1030
965
  const waitForReady = async () => {
1031
966
  for (let i = 0; i < 20; i++) {
1032
967
  await new Promise(r => setTimeout(r, 500));
1033
- if (await isTemporalReachable(config)) {
968
+ if (await (0, ensure_infra_1.isTemporalReachable)(config)) {
1034
969
  out.success(`Temporal running at ${config.temporalAddress}`);
1035
970
  out.log(' Registering search attributes...');
1036
- registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
971
+ (0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
1037
972
  out.log(`\n ${out.dim('Press Ctrl+C to stop')}\n`);
1038
973
  return;
1039
974
  }
@@ -1059,7 +994,7 @@ async function server(opts) {
1059
994
  // Register search attributes (for background mode — foreground does it inline)
1060
995
  if (opts.background) {
1061
996
  out.log(' Registering search attributes...');
1062
- registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
997
+ (0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
1063
998
  out.success('Temporal ready');
1064
999
  }
1065
1000
  }
@@ -1077,75 +1012,35 @@ async function up(opts) {
1077
1012
  process.exit(1);
1078
1013
  }
1079
1014
  out.check('temporal CLI installed', true);
1080
- // Step 2: Start Temporal if needed
1081
- const temporalUp = await isTemporalReachable(config);
1082
- if (temporalUp) {
1083
- out.check('Temporal running', true, config.temporalAddress);
1084
- }
1085
- else {
1086
- out.log(` ${out.dim('...')} Starting Temporal dev server...`);
1087
- (0, fs_1.mkdirSync)(config_1.AGENT_TEMPO_HOME, { recursive: true });
1088
- const port = config.temporalAddress.split(':')[1] || '7233';
1089
- const child = (0, child_process_1.spawn)('temporal', [
1090
- 'server', 'start-dev',
1091
- '--port', port,
1092
- '--db-filename', DEFAULT_DB_PATH,
1093
- ], { detached: true, stdio: 'ignore' });
1094
- child.unref();
1095
- // Wait for ready
1096
- let ready = false;
1097
- for (let i = 0; i < 20; i++) {
1098
- await new Promise(r => setTimeout(r, 500));
1099
- if (await isTemporalReachable(config)) {
1100
- ready = true;
1101
- break;
1102
- }
1103
- }
1104
- if (!ready) {
1105
- out.error('Temporal did not start within 10 seconds');
1106
- process.exit(1);
1107
- }
1108
- out.check('Temporal started', true, `pid ${child.pid}, data in ~/.agent-tempo/`);
1109
- }
1110
- // Step 3: Register search attributes
1111
- registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
1112
- // Step 3.5: Install shipped agent types to ~/.claude/agents/ (if not already there)
1113
- const userAgentsDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'agents');
1114
- const shippedAgentsPath = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'agents');
1115
- if ((0, fs_1.existsSync)(shippedAgentsPath)) {
1116
- (0, fs_1.mkdirSync)(userAgentsDir, { recursive: true });
1117
- const shipped = (0, fs_1.readdirSync)(shippedAgentsPath).filter(f => f.endsWith('.md'));
1118
- let installed = 0;
1119
- for (const file of shipped) {
1120
- const dest = (0, path_1.join)(userAgentsDir, file);
1121
- if (!(0, fs_1.existsSync)(dest)) {
1122
- (0, fs_1.copyFileSync)((0, path_1.join)(shippedAgentsPath, file), dest);
1123
- installed++;
1124
- }
1125
- }
1126
- if (installed > 0) {
1127
- out.success(`Installed ${installed} agent type${installed !== 1 ? 's' : ''} to ~/.claude/agents/`);
1128
- }
1129
- else {
1130
- out.dim(` Agent types already installed (${shipped.length} in ~/.claude/agents/)`);
1131
- }
1132
- }
1133
- // Step 3.7: Start worker daemon if not already running
1134
- if ((0, daemon_1.isDaemonRunning)()) {
1135
- const daemonStatus = (0, daemon_1.getDaemonStatus)();
1136
- out.check('Worker daemon running', true, `pid ${daemonStatus.pid}`);
1015
+ // Steps 2–3.7: bring infra up via the SHARED ensureInfra — the same path
1016
+ // `/ensemble-up` uses, so CLI + extension can't drift (#700 P1). Order:
1017
+ // Temporal → search attributes → agent types → daemon (SA BEFORE daemon — the
1018
+ // daemon refuses to boot without them). `onStep` renders the same out.check
1019
+ // sequence `up()` showed before the extraction.
1020
+ try {
1021
+ await (0, ensure_infra_1.ensureInfra)({
1022
+ config,
1023
+ onStep: (p) => {
1024
+ if (p.step === 'temporal') {
1025
+ out.check(p.status === 'ok' ? 'Temporal running' : 'Temporal started', true, p.detail);
1026
+ }
1027
+ else if (p.step === 'agent-types') {
1028
+ if (p.detail?.startsWith('installed'))
1029
+ out.success(`Agent types: ${p.detail} → ~/.claude/agents/`);
1030
+ else if (p.detail)
1031
+ out.dim(` Agent types already installed (${p.detail})`);
1032
+ }
1033
+ else if (p.step === 'daemon') {
1034
+ out.check(p.status === 'ok' ? 'Worker daemon running' : 'Worker daemon started', true, p.detail);
1035
+ }
1036
+ // 'search-attributes' logs per-attribute internally via registerSearchAttributes.
1037
+ },
1038
+ });
1137
1039
  }
1138
- else {
1139
- out.log(` ${out.dim('...')} Starting worker daemon...`);
1140
- try {
1141
- const daemonPid = await (0, daemon_1.startDaemon)(config);
1142
- out.check('Worker daemon started', true, `pid ${daemonPid}`);
1143
- }
1144
- catch (err) {
1145
- out.error(`Failed to start worker daemon: ${err.message || err}`);
1146
- out.log(` ${out.dim('You can start it manually: agent-tempo daemon start')}`);
1147
- process.exit(1);
1148
- }
1040
+ catch (err) {
1041
+ out.error(`Infra startup failed: ${err?.message || err}`);
1042
+ out.log(` ${out.dim('You can start it manually: agent-tempo daemon start')}`);
1043
+ process.exit(1);
1149
1044
  }
1150
1045
  // Step 4: Register MCP server if needed
1151
1046
  if ((0, mcp_1.isMcpConfigured)(process.cwd())) {
@@ -1628,14 +1523,14 @@ function stopTemporalServer(opts) {
1628
1523
  async function startTemporalForDestroy(config, deps = {}) {
1629
1524
  const attempts = deps.attempts ?? 20;
1630
1525
  const pollDelayMs = deps.pollDelayMs ?? 500;
1631
- const isReachable = deps.isReachable ?? (() => isTemporalReachable(config));
1526
+ const isReachable = deps.isReachable ?? (() => (0, ensure_infra_1.isTemporalReachable)(config));
1632
1527
  const spawn = deps.spawn ?? (() => {
1633
1528
  (0, fs_1.mkdirSync)(config_1.AGENT_TEMPO_HOME, { recursive: true });
1634
1529
  const port = config.temporalAddress.split(':')[1] || '7233';
1635
1530
  return (0, child_process_1.spawn)('temporal', [
1636
1531
  'server', 'start-dev',
1637
1532
  '--port', port,
1638
- '--db-filename', DEFAULT_DB_PATH,
1533
+ '--db-filename', ensure_infra_1.DEFAULT_DB_PATH,
1639
1534
  ], { detached: true, stdio: 'ignore' });
1640
1535
  });
1641
1536
  const child = spawn();
@@ -1662,7 +1557,7 @@ async function down(opts) {
1662
1557
  : ` Stopping daemon + Temporal. Workflows stay parked for the next ${out.dim('agent-tempo up')}.`);
1663
1558
  // Step 1 (destroy mode only): enumerate + terminate workflows across every
1664
1559
  // ensemble, after a typed confirmation showing the user what's at stake.
1665
- let temporalUp = await isTemporalReachable(config);
1560
+ let temporalUp = await (0, ensure_infra_1.isTemporalReachable)(config);
1666
1561
  // `--destroy` can only terminate workflows while Temporal is reachable.
1667
1562
  // Workflow state lives durably on disk in ~/.agent-tempo/, so if Temporal
1668
1563
  // happens to be down when the user runs `down --destroy`, skipping the
@@ -0,0 +1,80 @@
1
+ import { Config } from '../config';
2
+ /** SQLite db file for the bundled Temporal dev server (same path `up()` uses). */
3
+ export declare const DEFAULT_DB_PATH: string;
4
+ /**
5
+ * Probe whether Temporal is reachable AND its namespace can serve requests (a
6
+ * gRPC connect alone doesn't prove readiness — issue a cheap visibility query).
7
+ * MOVED from commands.ts; the CLI's other temporal-start sites import it back.
8
+ */
9
+ export declare function isTemporalReachable(config: {
10
+ temporalAddress: string;
11
+ temporalNamespace?: string;
12
+ temporalApiKey?: string;
13
+ temporalTlsCertPath?: string;
14
+ temporalTlsKeyPath?: string;
15
+ }): Promise<boolean>;
16
+ /**
17
+ * Register all {@link REQUIRED_SEARCH_ATTRIBUTES}. Permission errors (Temporal
18
+ * Cloud namespace keys can't reach the operator service) are NOT counted as
19
+ * failures — collapsed to one soft line (#686). Definitive failures (e.g. the
20
+ * SQLite 10-Keyword cap) keep the hard "will fail" conclusion. MOVED from
21
+ * commands.ts; re-imported there.
22
+ */
23
+ export declare function registerSearchAttributes(temporalAddress: string, namespace?: string): {
24
+ failed: number;
25
+ };
26
+ /**
27
+ * Copy the shipped agent-type definitions into `~/.claude/agents/` if absent, so
28
+ * recruiting TYPED lineup players resolves. Idempotent (skips existing). MOVED
29
+ * from `up()` step 3.5 — folded into ensureInfra so `/ensemble-up` gets it too.
30
+ */
31
+ export declare function installAgentTypes(): {
32
+ installed: number;
33
+ total: number;
34
+ };
35
+ /** A bootstrap-progress event, surfaced to the caller's UI (out.check / ctx.ui). */
36
+ export interface InfraProgress {
37
+ step: 'temporal' | 'search-attributes' | 'agent-types' | 'daemon';
38
+ status: 'ok' | 'started' | 'done';
39
+ detail?: string;
40
+ }
41
+ export interface EnsureInfraResult {
42
+ config: Config;
43
+ temporal: 'up' | 'started';
44
+ daemon: 'up' | 'started';
45
+ }
46
+ /**
47
+ * Injectable seam (tests only). Defaults to the real functions; a test overrides
48
+ * to assert ordering (SA-before-daemon), connect-only, and explicit-config
49
+ * propagation without spawning anything.
50
+ */
51
+ export interface EnsureInfraDeps {
52
+ isTemporalReachable: (config: Config) => Promise<boolean>;
53
+ startTemporalDevServer: (config: Config) => Promise<{
54
+ pid?: number;
55
+ }>;
56
+ registerSearchAttributes: (addr: string, ns: string) => {
57
+ failed: number;
58
+ };
59
+ installAgentTypes: () => {
60
+ installed: number;
61
+ total: number;
62
+ };
63
+ isDaemonRunning: () => boolean;
64
+ startDaemon: (config: Config) => Promise<number>;
65
+ getDaemonStatus: () => {
66
+ pid?: number;
67
+ };
68
+ }
69
+ /**
70
+ * Bring local infra up (CONNECT-ONLY — never registers MCP). Order: Temporal →
71
+ * search attributes → agent types → daemon (SA MUST precede the daemon, which
72
+ * refuses to boot without them). `onStep` lets the caller render progress
73
+ * (`up()` → out.check; the Pi extension → ctx.ui.notify). Throws if Temporal
74
+ * can't be reached/started; the caller decides how to surface that.
75
+ */
76
+ export declare function ensureInfra(opts?: {
77
+ config?: Config;
78
+ onStep?: (p: InfraProgress) => void;
79
+ deps?: Partial<EnsureInfraDeps>;
80
+ }): Promise<EnsureInfraResult>;
@@ -0,0 +1,234 @@
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.DEFAULT_DB_PATH = void 0;
37
+ exports.isTemporalReachable = isTemporalReachable;
38
+ exports.registerSearchAttributes = registerSearchAttributes;
39
+ exports.installAgentTypes = installAgentTypes;
40
+ exports.ensureInfra = ensureInfra;
41
+ /**
42
+ * Shared infrastructure bootstrap (#700 P1 / command-center).
43
+ *
44
+ * `ensureInfra()` is the ONE path that brings the local agent-tempo infra up —
45
+ * Temporal dev server, the AgentTempo* search attributes, the shipped agent
46
+ * types, and the worker daemon — so BOTH the CLI (`agent-tempo up`) and the
47
+ * mission-control Pi extension (`/ensemble-up`) call identical logic and can't
48
+ * drift. Extracted from `up()` (the Temporal-start + SA + agent-types + daemon
49
+ * steps) per the command-center design §2/§7A.
50
+ *
51
+ * CONNECT-ONLY: this never registers the MCP server (`init()`) — that's a
52
+ * CLI-only step and is explicitly cut from the bootstrap path (the Pi extension
53
+ * registers tools natively, no MCP).
54
+ *
55
+ * ORDERING IS LOAD-BEARING: search attributes MUST be registered BEFORE the
56
+ * daemon starts — the daemon refuses to boot (process.exit) if the SAs are
57
+ * missing (see `sa-preflight` + `daemon.ts` boot gate). `up()` did SA at step 3,
58
+ * daemon at step 3.7; ensureInfra preserves that order.
59
+ *
60
+ * `isTemporalReachable` + `registerSearchAttributes` MOVED here from
61
+ * `commands.ts` (their natural infra home); `commands.ts` re-imports them. This
62
+ * keeps ensure-infra a LEAF (imports only connection / sa-preflight / daemon /
63
+ * config / output) — no `commands.ts ↔ ensure-infra` cycle.
64
+ */
65
+ const child_process_1 = require("child_process");
66
+ const fs_1 = require("fs");
67
+ const path_1 = require("path");
68
+ const os_1 = require("os");
69
+ const client_1 = require("@temporalio/client");
70
+ const config_1 = require("../config");
71
+ const connection_1 = require("../connection");
72
+ const sa_preflight_1 = require("./sa-preflight");
73
+ const daemon_1 = require("./daemon");
74
+ const out = __importStar(require("./output"));
75
+ /** SQLite db file for the bundled Temporal dev server (same path `up()` uses). */
76
+ exports.DEFAULT_DB_PATH = (0, path_1.join)(config_1.AGENT_TEMPO_HOME, 'temporal-data.db');
77
+ /** Package root (dist/cli/ensure-infra.js → dist → pkgRoot) — holds examples/agents. */
78
+ const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
79
+ /**
80
+ * Probe whether Temporal is reachable AND its namespace can serve requests (a
81
+ * gRPC connect alone doesn't prove readiness — issue a cheap visibility query).
82
+ * MOVED from commands.ts; the CLI's other temporal-start sites import it back.
83
+ */
84
+ async function isTemporalReachable(config) {
85
+ try {
86
+ const conn = await (0, connection_1.createTemporalConnection)(config);
87
+ try {
88
+ const client = new client_1.Client({ connection: conn, namespace: config.temporalNamespace || 'default' });
89
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
90
+ for await (const _ of client.workflow.list({ query: 'WorkflowId = "__readiness_probe__"' })) {
91
+ break;
92
+ }
93
+ }
94
+ finally {
95
+ await conn.close();
96
+ }
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Register all {@link REQUIRED_SEARCH_ATTRIBUTES}. Permission errors (Temporal
105
+ * Cloud namespace keys can't reach the operator service) are NOT counted as
106
+ * failures — collapsed to one soft line (#686). Definitive failures (e.g. the
107
+ * SQLite 10-Keyword cap) keep the hard "will fail" conclusion. MOVED from
108
+ * commands.ts; re-imported there.
109
+ */
110
+ function registerSearchAttributes(temporalAddress, namespace = 'default') {
111
+ let failed = 0;
112
+ let permissionBlocked = 0;
113
+ for (const attr of sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES) {
114
+ const r = (0, sa_preflight_1.registerSearchAttribute)(attr, temporalAddress, namespace);
115
+ switch (r.status) {
116
+ case 'created':
117
+ out.success(`Registered search attribute: ${attr.name}`);
118
+ break;
119
+ case 'already-exists':
120
+ out.dim(` ${attr.name} (already registered)`);
121
+ break;
122
+ case 'failed':
123
+ if ((0, sa_preflight_1.isPermissionError)(r.detail)) {
124
+ permissionBlocked++;
125
+ }
126
+ else {
127
+ failed++;
128
+ out.warn(`Failed to register ${attr.name}: ${r.detail}`);
129
+ }
130
+ break;
131
+ }
132
+ }
133
+ if (permissionBlocked > 0) {
134
+ const saList = sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.map((a) => `${a.name}:${a.type}`).join(', ');
135
+ out.warn(`Couldn't verify search attributes — this credential lacks permission to manage them ` +
136
+ `(normal on Temporal Cloud, where search attributes are managed via the Cloud UI or tcld). ` +
137
+ `If workflow starts fail with "search attribute ... is not defined", create these ` +
138
+ `${sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.length} via the Cloud UI / tcld: ${saList}. ` +
139
+ `Otherwise this is safe to ignore.`);
140
+ }
141
+ if (failed > 0) {
142
+ out.warn(`${failed} search attribute${failed === 1 ? '' : 's'} not registered — ` +
143
+ `workflow starts will fail. Resolve the errors above before continuing.`);
144
+ }
145
+ return { failed };
146
+ }
147
+ /**
148
+ * Copy the shipped agent-type definitions into `~/.claude/agents/` if absent, so
149
+ * recruiting TYPED lineup players resolves. Idempotent (skips existing). MOVED
150
+ * from `up()` step 3.5 — folded into ensureInfra so `/ensemble-up` gets it too.
151
+ */
152
+ function installAgentTypes() {
153
+ const userAgentsDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'agents');
154
+ const shippedAgentsPath = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'agents');
155
+ if (!(0, fs_1.existsSync)(shippedAgentsPath))
156
+ return { installed: 0, total: 0 };
157
+ (0, fs_1.mkdirSync)(userAgentsDir, { recursive: true });
158
+ const shipped = (0, fs_1.readdirSync)(shippedAgentsPath).filter((f) => f.endsWith('.md'));
159
+ let installed = 0;
160
+ for (const file of shipped) {
161
+ const dest = (0, path_1.join)(userAgentsDir, file);
162
+ if (!(0, fs_1.existsSync)(dest)) {
163
+ (0, fs_1.copyFileSync)((0, path_1.join)(shippedAgentsPath, file), dest);
164
+ installed++;
165
+ }
166
+ }
167
+ return { installed, total: shipped.length };
168
+ }
169
+ /** Default Temporal dev-server start (detached spawn + readiness poll). Throws on timeout. */
170
+ async function startTemporalDevServer(config) {
171
+ (0, fs_1.mkdirSync)(config_1.AGENT_TEMPO_HOME, { recursive: true });
172
+ const port = config.temporalAddress.split(':')[1] || '7233';
173
+ const child = (0, child_process_1.spawn)('temporal', ['server', 'start-dev', '--port', port, '--db-filename', exports.DEFAULT_DB_PATH], {
174
+ detached: true,
175
+ stdio: 'ignore',
176
+ });
177
+ child.unref();
178
+ for (let i = 0; i < 20; i++) {
179
+ await new Promise((r) => setTimeout(r, 500));
180
+ if (await isTemporalReachable(config))
181
+ return { pid: child.pid };
182
+ }
183
+ throw new Error('Temporal did not start within 10 seconds');
184
+ }
185
+ const defaultDeps = {
186
+ isTemporalReachable,
187
+ startTemporalDevServer,
188
+ registerSearchAttributes,
189
+ installAgentTypes,
190
+ isDaemonRunning: daemon_1.isDaemonRunning,
191
+ startDaemon: daemon_1.startDaemon,
192
+ getDaemonStatus: daemon_1.getDaemonStatus,
193
+ };
194
+ /**
195
+ * Bring local infra up (CONNECT-ONLY — never registers MCP). Order: Temporal →
196
+ * search attributes → agent types → daemon (SA MUST precede the daemon, which
197
+ * refuses to boot without them). `onStep` lets the caller render progress
198
+ * (`up()` → out.check; the Pi extension → ctx.ui.notify). Throws if Temporal
199
+ * can't be reached/started; the caller decides how to surface that.
200
+ */
201
+ async function ensureInfra(opts = {}) {
202
+ const config = opts.config ?? (0, config_1.getConfig)();
203
+ const onStep = opts.onStep ?? (() => { });
204
+ const d = { ...defaultDeps, ...opts.deps };
205
+ // 1. Temporal — reachable, or auto-start the dev server.
206
+ let temporal;
207
+ if (await d.isTemporalReachable(config)) {
208
+ temporal = 'up';
209
+ onStep({ step: 'temporal', status: 'ok', detail: config.temporalAddress });
210
+ }
211
+ else {
212
+ const { pid } = await d.startTemporalDevServer(config);
213
+ temporal = 'started';
214
+ onStep({ step: 'temporal', status: 'started', detail: pid != null ? `pid ${pid}` : undefined });
215
+ }
216
+ // 2. Search attributes — BEFORE the daemon (it refuses to boot without them).
217
+ const sa = d.registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
218
+ onStep({ step: 'search-attributes', status: 'done', detail: sa.failed > 0 ? `${sa.failed} failed` : undefined });
219
+ // 3. Agent types — so recruiting typed lineup players resolves.
220
+ const at = d.installAgentTypes();
221
+ onStep({ step: 'agent-types', status: 'done', detail: at.installed > 0 ? `installed ${at.installed}` : `${at.total} present` });
222
+ // 4. Worker daemon (detached; reused verbatim from the CLI path).
223
+ let daemon;
224
+ if (d.isDaemonRunning()) {
225
+ daemon = 'up';
226
+ onStep({ step: 'daemon', status: 'ok', detail: d.getDaemonStatus().pid != null ? `pid ${d.getDaemonStatus().pid}` : undefined });
227
+ }
228
+ else {
229
+ const pid = await d.startDaemon(config);
230
+ daemon = 'started';
231
+ onStep({ step: 'daemon', status: 'started', detail: `pid ${pid}` });
232
+ }
233
+ return { config, temporal, daemon };
234
+ }
@@ -89,6 +89,7 @@ ${out.bold('Commands:')}
89
89
  ${out.cyan('upgrade')} [version] Upgrade agent-tempo to latest (or specific version)
90
90
  ${out.cyan('config')} Configure Temporal connection settings
91
91
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
92
+ ${out.cyan('install-pi')} Install the Pi extensions into Pi settings (or --project for .pi/settings.json)
92
93
  ${out.cyan('preflight')} Run preflight checks only
93
94
  ${out.cyan('help')} Show this help message
94
95
 
package/dist/cli.js CHANGED
@@ -616,6 +616,24 @@ async function main() {
616
616
  case 'init':
617
617
  await init({ dir: args.dir, project: args.project });
618
618
  break;
619
+ case 'install-pi': {
620
+ // #700 P1 — install agent-tempo's two Pi extensions (player +
621
+ // command-center) the normal `.pi` way: an idempotent, install-by-reference
622
+ // merge of their absolute dist paths into Pi's settings.json. Pure fs
623
+ // (no Temporal) — dynamic-imported so it doesn't bloat the crash-proof
624
+ // top-level module graph. `--project` writes `.pi/settings.json` instead
625
+ // of the global `~/.pi/agent/settings.json`.
626
+ const { installPiExtensions } = await Promise.resolve().then(() => __importStar(require('./pi/install')));
627
+ const result = installPiExtensions({ project: args.project });
628
+ out.success(`Pi extensions installed → ${result.settingsPath}`);
629
+ for (const p of result.added)
630
+ out.log(` ${out.green('+')} ${p}`);
631
+ for (const p of result.alreadyPresent)
632
+ out.log(out.dim(` · ${p} (already installed)`));
633
+ out.log('');
634
+ out.log(' Restart `pi` to load the agent-tempo extensions.');
635
+ break;
636
+ }
619
637
  case 'migrate-from-claude-tempo': {
620
638
  // PR-2 of the v1.0 rebrand — one-shot copy of `~/.agent-tempo/` →
621
639
  // `~/.agent-tempo/`. Crash-proof (no Temporal deps). The `--dev`
@@ -77,7 +77,7 @@ async function handleCreateEnsemble(req, res, client) {
77
77
  const parsed = parseBody(body);
78
78
  if ('error' in parsed)
79
79
  return (0, responses_1.errorResponse)(res, 400, parsed.error);
80
- const { name, lineup, host, startMode, conductorInstructions } = parsed.body;
80
+ const { name, lineup, host, startMode, conductorInstructions, conductorAgent: conductorAgentOverride } = parsed.body;
81
81
  // 409 — ensemble already exists. `listEnsembles()` returns live
82
82
  // ensembles by Temporal workflow; matches what the dashboard's
83
83
  // `/v1/ensembles` GET sees. Note: TOCTOU between this read and the
@@ -127,7 +127,11 @@ async function handleCreateEnsemble(req, res, client) {
127
127
  // spawn target either way.
128
128
  const conductorBlock = resolved?.conductor;
129
129
  const conductorName = conductorBlock?.name ?? 'conductor';
130
- const conductorAgent = pickAgent(conductorBlock?.agent, allowed);
130
+ // #700 P1: a body-supplied `conductorAgent` wins; fall back to the lineup's
131
+ // conductor agent. Each candidate is run through `pickAgent` (allowed-only),
132
+ // so an invalid override is dropped and we still fall back to the lineup —
133
+ // and OMITTING `conductorAgent` reproduces today's behavior exactly.
134
+ const conductorAgent = pickAgent(conductorAgentOverride, allowed) ?? pickAgent(conductorBlock?.agent, allowed);
131
135
  try {
132
136
  await client.recruit(name, {
133
137
  name: conductorName,
@@ -198,6 +202,9 @@ function parseBody(body) {
198
202
  return { error: { error: 'invalid-start-mode', allowed: ALLOWED_START_MODES } };
199
203
  }
200
204
  const conductorInstructions = (0, body_1.stringField)(body, 'conductorInstructions', { requireNonEmpty: true });
205
+ // #700 P1 — additive optional override (validated against allowed agents at
206
+ // recruit time via pickAgent, like the lineup's conductor agent).
207
+ const conductorAgent = (0, body_1.stringField)(body, 'conductorAgent', { requireNonEmpty: true });
201
208
  // Lightweight host-name shape check (matches the daemon's existing
202
209
  // hostname rules used by recruit). Skips when omitted.
203
210
  if (host !== undefined && (0, validation_1.validatePlayerName)(host) !== null) {
@@ -210,6 +217,7 @@ function parseBody(body) {
210
217
  ...(host !== undefined && { host }),
211
218
  ...(startMode !== undefined && { startMode: startMode }),
212
219
  ...(conductorInstructions !== undefined && { conductorInstructions }),
220
+ ...(conductorAgent !== undefined && { conductorAgent }),
213
221
  },
214
222
  };
215
223
  }
@@ -42,7 +42,7 @@ export { WRITE_BODY_MAX };
42
42
  * mutations. Bodies are uniform `{ playerId, reason? }` (plus per-action
43
43
  * extras); the ensemble lives in the URL.
44
44
  */
45
- export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "reset", "destroy", "detach", "recall"];
45
+ export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "reset", "destroy", "detach", "recall", "shutdown"];
46
46
  export type WriteAction = (typeof WRITE_ACTIONS)[number];
47
47
  /** Type guard — narrows an arbitrary string to a known `WriteAction`. */
48
48
  export declare function isWriteAction(s: string): s is WriteAction;
@@ -30,6 +30,7 @@ exports.WRITE_ACTIONS = [
30
30
  'destroy',
31
31
  'detach',
32
32
  'recall',
33
+ 'shutdown',
33
34
  ];
34
35
  /** Type guard — narrows an arbitrary string to a known `WriteAction`. */
35
36
  function isWriteAction(s) {
@@ -67,6 +68,7 @@ async function handleWriteRoute(req, res, client, ensemble, action) {
67
68
  case 'destroy': return await handleDestroy(res, client, ensemble, body);
68
69
  case 'detach': return await handleDetach(res, client, ensemble, body);
69
70
  case 'recall': return await handleRecall(res, client, ensemble, body);
71
+ case 'shutdown': return await handleShutdown(res, client, ensemble, body);
70
72
  }
71
73
  }
72
74
  catch (err) {
@@ -235,6 +237,22 @@ async function handleRecall(res, client, ensemble, body) {
235
237
  const messages = result.received.length + result.sent.length;
236
238
  (0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, playerId, messages });
237
239
  }
240
+ /**
241
+ * Ensemble teardown (#700 P1) — the HTTP verb the command-center `/ensemble-down`
242
+ * needs (the CLI `down` + the `shutdown` MCP tool cover this, but neither was
243
+ * HTTP). Graceful by default: `client.shutdown(ensemble)` fans out detach + pauses
244
+ * the scheduler + maestro; workflows survive in `detached` (pair with `restore`).
245
+ * `{ destroy: true }` (`/ensemble-down --destroy`) escalates to the ensemble-scope
246
+ * `client.destroy(ensemble)` (no `playerId`) — terminate, not graceful.
247
+ */
248
+ async function handleShutdown(res, client, ensemble, body) {
249
+ if (body.destroy === true) {
250
+ const summary = await client.destroy(ensemble);
251
+ return (0, responses_1.jsonResponse)(res, 202, { ok: true, ensemble, mode: 'destroy', summary: summary ?? null });
252
+ }
253
+ const summary = await client.shutdown(ensemble);
254
+ (0, responses_1.jsonResponse)(res, 202, { ok: true, ensemble, mode: 'shutdown', summary });
255
+ }
238
256
  // ── Helpers ──────────────────────────────────────────────────────────────
239
257
  /**
240
258
  * Map a thrown error from a TempoClient call to an HTTP response.
@@ -188,6 +188,34 @@ function createPiExtension(options = {}) {
188
188
  };
189
189
  (0, render_tools_1.renderToPi)(pi, (0, server_tools_1.buildAllTempoTools)(toolOpts));
190
190
  log(`registered tools (player=${currentPlayerId}, conductor=${isConductor}, mode=${mode})`);
191
+ // ── #698 — FULL server-instruction preamble into the Pi system prompt ──
192
+ // The Pi equivalent of MCP players' `buildServerInstructions` (#695 S2 seeded
193
+ // this hook with just the 4 yield-norms; #698 widens it to the WHOLE preamble —
194
+ // identity, cue/report/recruit guidance, Communication-discipline, and the
195
+ // conductor/player operational rules). SINGLE-SOURCED off `buildServerInstructions`
196
+ // so MCP + Pi guidance can't drift; the yield-norms now arrive FOLDED IN (they
197
+ // live inside that builder since #695 S1), so there is no separate const.
198
+ // `before_agent_start` exposes the fully-assembled systemPrompt and accepts a
199
+ // replacement (chained across extensions), so we APPEND — injected into the
200
+ // model's system prompt every turn, invisibly (NOT a sendMessage, which would
201
+ // spam the transcript). Applies to ALL Pi players (interactive + headless).
202
+ //
203
+ // Opts are built PER FIRE (before_agent_start fires every turn): playerId and
204
+ // hasRequestedName must reflect CURRENT state, so a `set_name`-renamed player
205
+ // gets the right identity rather than the boot-time one.
206
+ const piInstructionOpts = () => ({
207
+ ensemble: config.ensemble,
208
+ playerId: toolOpts.getPlayerId(),
209
+ isConductor,
210
+ playerType: process.env[config_1.ENV.PLAYER_TYPE] || undefined,
211
+ // playerTypeDescription omitted (MVP — not threaded into the Pi runtime).
212
+ hasRequestedName: Boolean(process.env[config_1.ENV.PLAYER_NAME]),
213
+ });
214
+ // Cast mirrors the tool_call returning-handler pattern (the shim's `on` is
215
+ // void-typed; the real ExtensionAPI before_agent_start handler returns a result).
216
+ pi.on('before_agent_start', (ev) => ({
217
+ systemPrompt: `${ev.systemPrompt ?? ''}\n\n${(0, server_tools_1.buildServerInstructions)(piInstructionOpts())}`,
218
+ }));
191
219
  // ── #677 PART B — interactive-only `/tempo-reset` command ──
192
220
  // Pi's `newSession` (clean-wipe) is ExtensionCommandContext-ONLY (not on the
193
221
  // SDK session), so an interactive Pi conductor can ONLY be reset by the operator
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Absolute paths to the two shipped Pi extension entry points.
3
+ *
4
+ * `__dirname` is `<pkg>/dist/pi` (this module compiles to `dist/pi/install.js`),
5
+ * so both entry points are resolved relative to it directly (MUST-FIX 1).
6
+ */
7
+ export declare function piExtensionPaths(): {
8
+ player: string;
9
+ missionControl: string;
10
+ };
11
+ export interface InstallPiOptions {
12
+ /**
13
+ * Install into the per-project `.pi/settings.json` (under `cwd`) instead of
14
+ * the global `~/.pi/agent/settings.json`.
15
+ */
16
+ project?: boolean;
17
+ /** Base dir for project-scope install. Defaults to `process.cwd()`. */
18
+ cwd?: string;
19
+ /** Override the home dir (tests). Defaults to `os.homedir()`. */
20
+ home?: string;
21
+ }
22
+ export interface InstallPiResult {
23
+ /** The settings.json file that was read/written. */
24
+ settingsPath: string;
25
+ /** Extension paths added by THIS run (empty on a repeat run — idempotent). */
26
+ added: string[];
27
+ /** Extension paths already present before this run. */
28
+ alreadyPresent: string[];
29
+ /** The final `extensions` array written to settings.json. */
30
+ extensions: string[];
31
+ }
32
+ /** Resolve the Pi settings.json path for the chosen scope. */
33
+ export declare function piSettingsPath(opts?: InstallPiOptions): string;
34
+ /**
35
+ * Idempotently merge the two agent-tempo Pi extension absolute paths into Pi's
36
+ * `settings.json` `"extensions"` array. Re-running is a no-op (no duplicates, no
37
+ * write when nothing changed). Never copies any extension file — install by
38
+ * reference only (see file header).
39
+ *
40
+ * Tolerates a missing / empty / corrupt settings file: a missing file is
41
+ * created; an unparseable one is replaced with a fresh object carrying just the
42
+ * extensions (we can only safely merge a valid object). Other recognised keys in
43
+ * a valid settings object are preserved.
44
+ */
45
+ export declare function installPiExtensions(opts?: InstallPiOptions): InstallPiResult;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.piExtensionPaths = piExtensionPaths;
4
+ exports.piSettingsPath = piSettingsPath;
5
+ exports.installPiExtensions = installPiExtensions;
6
+ /**
7
+ * Install the agent-tempo Pi extensions the normal `.pi` way (#700 P1, design §8).
8
+ *
9
+ * One agent-tempo install ships TWO Pi extension entry points — the player
10
+ * extension (`dist/pi/extension.js`, claims attachment / registers as a player)
11
+ * and the command-center extension (`dist/pi/mission-control/extension.js`,
12
+ * observer-only board + operator controller). `installPiExtensions()`
13
+ * idempotently merges their ABSOLUTE dist paths into Pi's `settings.json`
14
+ * `"extensions"` array.
15
+ *
16
+ * ── INSTALL-BY-REFERENCE, never a loose copy (design §8, load-bearing) ──
17
+ * We point Pi's settings at the extension files *where they already live inside
18
+ * the installed agent-tempo package* — we never copy a loose `.js` into
19
+ * `~/.pi/agent/extensions/`. The reason is dependency resolution: our extension
20
+ * imports `@temporalio/*`, `croner`, etc., which resolve via Node's upward
21
+ * `node_modules` walk. That walk only finds our deps when the extension file
22
+ * sits inside the agent-tempo package tree. A loose copy has no `node_modules`
23
+ * beside it, so those bare imports would fail. Reference-install keeps Node's
24
+ * resolution intact with zero copying.
25
+ *
26
+ * ── MUST-FIX 1: resolve the two paths DIRECTLY from `__dirname` ──
27
+ * This file compiles to `<pkg>/dist/pi/install.js`, co-located with
28
+ * `dist/pi/extension.js` and `dist/pi/mission-control/extension.js`. So the
29
+ * paths are `join(__dirname, 'extension.js')` and
30
+ * `join(__dirname, 'mission-control', 'extension.js')` — NOT
31
+ * `resolve(__dirname, '..', …)` (that would yield `dist/dist/…`, the bug).
32
+ */
33
+ const fs_1 = require("fs");
34
+ const path_1 = require("path");
35
+ const os_1 = require("os");
36
+ /**
37
+ * Absolute paths to the two shipped Pi extension entry points.
38
+ *
39
+ * `__dirname` is `<pkg>/dist/pi` (this module compiles to `dist/pi/install.js`),
40
+ * so both entry points are resolved relative to it directly (MUST-FIX 1).
41
+ */
42
+ function piExtensionPaths() {
43
+ return {
44
+ player: (0, path_1.resolve)(__dirname, 'extension.js'),
45
+ missionControl: (0, path_1.resolve)(__dirname, 'mission-control', 'extension.js'),
46
+ };
47
+ }
48
+ /** Resolve the Pi settings.json path for the chosen scope. */
49
+ function piSettingsPath(opts = {}) {
50
+ if (opts.project)
51
+ return (0, path_1.join)(opts.cwd ?? process.cwd(), '.pi', 'settings.json');
52
+ return (0, path_1.join)(opts.home ?? (0, os_1.homedir)(), '.pi', 'agent', 'settings.json');
53
+ }
54
+ /**
55
+ * Idempotently merge the two agent-tempo Pi extension absolute paths into Pi's
56
+ * `settings.json` `"extensions"` array. Re-running is a no-op (no duplicates, no
57
+ * write when nothing changed). Never copies any extension file — install by
58
+ * reference only (see file header).
59
+ *
60
+ * Tolerates a missing / empty / corrupt settings file: a missing file is
61
+ * created; an unparseable one is replaced with a fresh object carrying just the
62
+ * extensions (we can only safely merge a valid object). Other recognised keys in
63
+ * a valid settings object are preserved.
64
+ */
65
+ function installPiExtensions(opts = {}) {
66
+ const settingsPath = piSettingsPath(opts);
67
+ const { player, missionControl } = piExtensionPaths();
68
+ const want = [player, missionControl];
69
+ let settings = {};
70
+ const fileExists = (0, fs_1.existsSync)(settingsPath);
71
+ if (fileExists) {
72
+ try {
73
+ const parsed = JSON.parse((0, fs_1.readFileSync)(settingsPath, 'utf8'));
74
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
75
+ settings = parsed;
76
+ }
77
+ }
78
+ catch {
79
+ // Corrupt JSON — fall back to a clean object (can't safely merge into
80
+ // something we can't parse). Other keys are unrecoverable in that case.
81
+ settings = {};
82
+ }
83
+ }
84
+ const current = Array.isArray(settings.extensions)
85
+ ? settings.extensions.filter((x) => typeof x === 'string')
86
+ : [];
87
+ const added = [];
88
+ const alreadyPresent = [];
89
+ const merged = [...current];
90
+ for (const p of want) {
91
+ if (merged.includes(p)) {
92
+ alreadyPresent.push(p);
93
+ }
94
+ else {
95
+ merged.push(p);
96
+ added.push(p);
97
+ }
98
+ }
99
+ settings.extensions = merged;
100
+ // Idempotent: only write when something actually changed (or the file is
101
+ // absent and must be created). A clean repeat run touches nothing.
102
+ if (added.length > 0 || !fileExists) {
103
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(settingsPath), { recursive: true });
104
+ (0, fs_1.writeFileSync)(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
105
+ }
106
+ return { settingsPath, added, alreadyPresent, extensions: merged };
107
+ }
@@ -43,6 +43,34 @@ export declare class MissionControlActions {
43
43
  restart(playerId: string, reason?: string): Promise<ActionResult>;
44
44
  destroy(playerId: string, reason?: string): Promise<ActionResult>;
45
45
  reset(playerId: string, reason?: string): Promise<ActionResult>;
46
+ /**
47
+ * Create a fresh ensemble via `POST /v1/ensembles` (the catalog route, NOT
48
+ * ensemble-scoped). Defaults `name` to this client's bound ensemble — the one
49
+ * the command-center board observes. `conductorAgent` lets `/ensemble-up`
50
+ * default the conductor to a headless Pi (design §3).
51
+ */
52
+ createEnsemble(opts?: {
53
+ name?: string;
54
+ lineup?: string;
55
+ host?: string;
56
+ startMode?: 'hold' | 'release';
57
+ conductorInstructions?: string;
58
+ conductorAgent?: string;
59
+ }): Promise<ActionResult>;
60
+ /** Recruit a player into the bound ensemble (`POST /v1/ensembles/:e/recruit`). */
61
+ recruit(opts: {
62
+ name: string;
63
+ workDir: string;
64
+ playerType?: string;
65
+ host?: string;
66
+ agent?: string;
67
+ }): Promise<ActionResult>;
68
+ /**
69
+ * Tear down the bound ensemble (`POST /v1/ensembles/:e/shutdown`). Graceful by
70
+ * default (detach + pause, survives in `detached`); `destroy: true` escalates
71
+ * to ensemble-scope destroy (terminate).
72
+ */
73
+ shutdownEnsemble(destroy?: boolean): Promise<ActionResult>;
46
74
  gateArm(playerId: string): Promise<ActionResult>;
47
75
  gateDisarm(playerId: string): Promise<ActionResult>;
48
76
  gateDecide(playerId: string, requestId: string, decision: 'allow' | 'deny'): Promise<ActionResult>;
@@ -87,6 +87,29 @@ class MissionControlActions {
87
87
  reset(playerId, reason) {
88
88
  return this.post(`/v1/ensembles/${this.ens()}/reset`, { playerId, ...(reason ? { reason } : {}) });
89
89
  }
90
+ // ── Bootstrap surface (#700 P1) ──
91
+ /**
92
+ * Create a fresh ensemble via `POST /v1/ensembles` (the catalog route, NOT
93
+ * ensemble-scoped). Defaults `name` to this client's bound ensemble — the one
94
+ * the command-center board observes. `conductorAgent` lets `/ensemble-up`
95
+ * default the conductor to a headless Pi (design §3).
96
+ */
97
+ createEnsemble(opts = {}) {
98
+ const { name, ...rest } = opts;
99
+ return this.post('/v1/ensembles', { name: name ?? this.ensemble, ...rest });
100
+ }
101
+ /** Recruit a player into the bound ensemble (`POST /v1/ensembles/:e/recruit`). */
102
+ recruit(opts) {
103
+ return this.post(`/v1/ensembles/${this.ens()}/recruit`, opts);
104
+ }
105
+ /**
106
+ * Tear down the bound ensemble (`POST /v1/ensembles/:e/shutdown`). Graceful by
107
+ * default (detach + pause, survives in `detached`); `destroy: true` escalates
108
+ * to ensemble-scope destroy (terminate).
109
+ */
110
+ shutdownEnsemble(destroy) {
111
+ return this.post(`/v1/ensembles/${this.ens()}/shutdown`, destroy ? { destroy: true } : {});
112
+ }
90
113
  // ── Operator gate plane (T3) ──
91
114
  gateArm(playerId) {
92
115
  return this.post(`/v1/players/${this.player(playerId)}/gate-arm`, {});
@@ -1,5 +1,6 @@
1
1
  import { type BoardModel } from './board';
2
2
  import { MissionControlActions } from './actions';
3
+ import { type InfraProgress } from '../../cli/ensure-infra';
3
4
  import type { McExtensionAPI, McExtensionContext } from './pi-ui';
4
5
  /** Injectable seams (production defaults; tests override). */
5
6
  export interface MissionControlDeps {
@@ -10,6 +11,27 @@ export interface MissionControlDeps {
10
11
  /** Local daemon host for tailability (test override; defaults to `os.hostname()`). */
11
12
  localHost?: string;
12
13
  }
14
+ /**
15
+ * Infra-bootstrap seam (#700 P1). Defaults to the real {@link ensureInfra}; the
16
+ * extension command tests inject a fake so `/ensemble-up` etc. don't spawn
17
+ * Temporal / the daemon. Accepts only the `onStep` opt the controller passes.
18
+ */
19
+ export type EnsureInfraFn = (opts?: {
20
+ onStep?: (p: InfraProgress) => void;
21
+ }) => Promise<unknown>;
22
+ /** Parsed `/ensemble-up [name] [--lineup X] [--hold]` args. */
23
+ export declare function parseEnsembleUpArgs(args: string): {
24
+ name?: string;
25
+ lineup?: string;
26
+ hold: boolean;
27
+ };
28
+ /** Parsed `/recruit <name> [--type T] [--host H] [--agent A]` args. */
29
+ export declare function parseRecruitArgs(args: string): {
30
+ name?: string;
31
+ type?: string;
32
+ host?: string;
33
+ agent?: string;
34
+ };
13
35
  /**
14
36
  * The operator-command + board controller. Holds the model + the action client;
15
37
  * command methods are independently unit-testable with a fake actions + ctx.
@@ -25,7 +47,9 @@ export declare class Controller {
25
47
  readonly localHost: string;
26
48
  /** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
27
49
  onTailRequest: ((playerId: string | null) => void) | null;
28
- constructor(ensemble: string, actions: MissionControlActions, localHost?: string);
50
+ /** Infra bootstrap fn (#700 P1); injectable for tests. */
51
+ private readonly ensureInfraFn;
52
+ constructor(ensemble: string, actions: MissionControlActions, localHost?: string, ensureInfraFn?: EnsureInfraFn);
29
53
  private notify;
30
54
  private report;
31
55
  /** First whitespace-delimited token + the remainder. */
@@ -40,6 +64,16 @@ export declare class Controller {
40
64
  cmdReset(args: string, ctx: McExtensionContext): Promise<void>;
41
65
  cmdArm(args: string, ctx: McExtensionContext): Promise<void>;
42
66
  cmdGate(args: string, ctx: McExtensionContext): Promise<void>;
67
+ /**
68
+ * Ensure local infra is up (Temporal + SAs + agent types + daemon), streaming
69
+ * each step to the UI. Returns false (and notifies) if bootstrap fails, so the
70
+ * caller can bail before the HTTP action. Idempotent — a no-op when infra is
71
+ * already live.
72
+ */
73
+ private ensureInfraReady;
74
+ cmdEnsembleUp(args: string, ctx: McExtensionContext): Promise<void>;
75
+ cmdRecruit(args: string, ctx: McExtensionContext): Promise<void>;
76
+ cmdEnsembleDown(args: string, ctx: McExtensionContext): Promise<void>;
43
77
  }
44
78
  /**
45
79
  * Build the mission-control extension (default-export shape). The operator's Pi
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Controller = void 0;
37
+ exports.parseEnsembleUpArgs = parseEnsembleUpArgs;
38
+ exports.parseRecruitArgs = parseRecruitArgs;
37
39
  exports.createMissionControlExtension = createMissionControlExtension;
38
40
  /**
39
41
  * Mission-control Pi extension (3f) — turns ONE interactive Pi TUI into an
@@ -58,9 +60,52 @@ const board_1 = require("./board");
58
60
  const render_1 = require("./render");
59
61
  const actions_1 = require("./actions");
60
62
  const inner_tail_1 = require("./inner-tail");
63
+ const ensure_infra_1 = require("../../cli/ensure-infra");
61
64
  const WIDGET_KEY = 'mission-control';
62
65
  const DEFAULT_RENDER_THROTTLE_MS = 200;
63
66
  const DEFAULT_PORT = 8473;
67
+ /** Parsed `/ensemble-up [name] [--lineup X] [--hold]` args. */
68
+ function parseEnsembleUpArgs(args) {
69
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
70
+ let name;
71
+ let lineup;
72
+ let hold = false;
73
+ for (let i = 0; i < tokens.length; i++) {
74
+ const t = tokens[i];
75
+ if (t === '--hold')
76
+ hold = true;
77
+ else if (t === '--lineup')
78
+ lineup = tokens[++i];
79
+ else if (t.startsWith('--lineup='))
80
+ lineup = t.slice('--lineup='.length);
81
+ else if (!t.startsWith('--') && name === undefined)
82
+ name = t;
83
+ }
84
+ return { ...(name !== undefined ? { name } : {}), ...(lineup !== undefined ? { lineup } : {}), hold };
85
+ }
86
+ /** Parsed `/recruit <name> [--type T] [--host H] [--agent A]` args. */
87
+ function parseRecruitArgs(args) {
88
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
89
+ const out = {};
90
+ for (let i = 0; i < tokens.length; i++) {
91
+ const t = tokens[i];
92
+ if (t === '--type')
93
+ out.type = tokens[++i];
94
+ else if (t.startsWith('--type='))
95
+ out.type = t.slice('--type='.length);
96
+ else if (t === '--host')
97
+ out.host = tokens[++i];
98
+ else if (t.startsWith('--host='))
99
+ out.host = t.slice('--host='.length);
100
+ else if (t === '--agent')
101
+ out.agent = tokens[++i];
102
+ else if (t.startsWith('--agent='))
103
+ out.agent = t.slice('--agent='.length);
104
+ else if (!t.startsWith('--') && out.name === undefined)
105
+ out.name = t;
106
+ }
107
+ return out;
108
+ }
64
109
  /**
65
110
  * The operator-command + board controller. Holds the model + the action client;
66
111
  * command methods are independently unit-testable with a fake actions + ctx.
@@ -76,10 +121,13 @@ class Controller {
76
121
  localHost;
77
122
  /** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
78
123
  onTailRequest = null;
79
- constructor(ensemble, actions, localHost = os.hostname()) {
124
+ /** Infra bootstrap fn (#700 P1); injectable for tests. */
125
+ ensureInfraFn;
126
+ constructor(ensemble, actions, localHost = os.hostname(), ensureInfraFn = ensure_infra_1.ensureInfra) {
80
127
  this.model = (0, board_1.initBoard)(ensemble);
81
128
  this.actions = actions;
82
129
  this.localHost = localHost;
130
+ this.ensureInfraFn = ensureInfraFn;
83
131
  }
84
132
  notify(ctx, msg) {
85
133
  if (ctx.hasUI)
@@ -191,6 +239,65 @@ class Controller {
191
239
  }
192
240
  this.report(ctx, `gate ${reqId} ${decision}`, await this.actions.gateDecide(this.model.selected, reqId, decision));
193
241
  }
242
+ // ── Bootstrap commands (#700 P1) ──
243
+ /**
244
+ * Ensure local infra is up (Temporal + SAs + agent types + daemon), streaming
245
+ * each step to the UI. Returns false (and notifies) if bootstrap fails, so the
246
+ * caller can bail before the HTTP action. Idempotent — a no-op when infra is
247
+ * already live.
248
+ */
249
+ async ensureInfraReady(ctx) {
250
+ try {
251
+ await this.ensureInfraFn({
252
+ onStep: (p) => this.notify(ctx, `infra: ${p.step} ${p.status}${p.detail ? ` (${p.detail})` : ''}`),
253
+ });
254
+ return true;
255
+ }
256
+ catch (err) {
257
+ this.notify(ctx, `infra failed: ${err instanceof Error ? err.message : String(err)}`);
258
+ return false;
259
+ }
260
+ }
261
+ async cmdEnsembleUp(args, ctx) {
262
+ const { name, lineup, hold } = parseEnsembleUpArgs(args);
263
+ if (!(await this.ensureInfraReady(ctx)))
264
+ return;
265
+ // Conductor defaults to a HEADLESS Pi (design §3) — the human's seat is this
266
+ // command-center planner, so a second interactive conductor window is
267
+ // redundant. `conductorAgent: 'pi'` overrides any lineup conductor agent.
268
+ const r = await this.actions.createEnsemble({
269
+ ...(name !== undefined ? { name } : {}),
270
+ ...(lineup !== undefined ? { lineup } : {}),
271
+ startMode: hold ? 'hold' : 'release',
272
+ conductorAgent: 'pi',
273
+ });
274
+ this.report(ctx, `ensemble-up${name ? ` ${name}` : ''}`, r);
275
+ }
276
+ async cmdRecruit(args, ctx) {
277
+ const { name, type, host, agent } = parseRecruitArgs(args);
278
+ if (!name) {
279
+ this.notify(ctx, 'Usage: /recruit <name> [--type T] [--host H] [--agent A]');
280
+ return;
281
+ }
282
+ if (!(await this.ensureInfraReady(ctx)))
283
+ return;
284
+ const r = await this.actions.recruit({
285
+ name,
286
+ // The extension runs in the Pi process; its cwd is the project dir.
287
+ workDir: process.cwd(),
288
+ ...(type !== undefined ? { playerType: type } : {}),
289
+ ...(host !== undefined ? { host } : {}),
290
+ ...(agent !== undefined ? { agent } : {}),
291
+ });
292
+ this.report(ctx, `recruit ${name}`, r);
293
+ }
294
+ async cmdEnsembleDown(args, ctx) {
295
+ const destroy = args.trim().split(/\s+/).includes('--destroy');
296
+ if (!(await this.ensureInfraReady(ctx)))
297
+ return;
298
+ const r = await this.actions.shutdownEnsemble(destroy);
299
+ this.report(ctx, `ensemble-down${destroy ? ' --destroy' : ''}`, r);
300
+ }
194
301
  }
195
302
  exports.Controller = Controller;
196
303
  function resolveBaseUrl(override) {
@@ -321,6 +428,10 @@ function createMissionControlExtension(deps = {}) {
321
428
  pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player> [reason])', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
322
429
  pi.registerCommand('arm', { description: 'Arm/disarm the operator gate for a player (/arm <player> [off])', handler: (a, ctx) => ctrl.cmdArm(a, ctx) });
323
430
  pi.registerCommand('gate', { description: 'Decide a gate request for the tailed player (/gate <reqId> allow|deny)', handler: (a, ctx) => ctrl.cmdGate(a, ctx) });
431
+ // #700 P1 — bootstrap commands (ensureInfra → daemon HTTP action).
432
+ pi.registerCommand('ensemble-up', { description: 'Bootstrap the ensemble (/ensemble-up [name] [--lineup X] [--hold])', handler: (a, ctx) => ctrl.cmdEnsembleUp(a, ctx) });
433
+ pi.registerCommand('recruit', { description: 'Recruit a player (/recruit <name> [--type T] [--host H] [--agent A])', handler: (a, ctx) => ctrl.cmdRecruit(a, ctx) });
434
+ pi.registerCommand('ensemble-down', { description: 'Tear down the ensemble (/ensemble-down [--destroy])', handler: (a, ctx) => ctrl.cmdEnsembleDown(a, ctx) });
324
435
  };
325
436
  }
326
437
  /** Default export — the loadable Pi extension. */
@@ -191,6 +191,24 @@ export interface PiToolCallResult {
191
191
  block?: boolean;
192
192
  reason?: string;
193
193
  }
194
+ /**
195
+ * `before_agent_start` payload (#695). Pi fires this after the user prompt is
196
+ * assembled but before the agent loop; it carries the fully-built `systemPrompt`.
197
+ * A handler returns {@link PiBeforeAgentStartResult} to replace it. We read only
198
+ * `systemPrompt` (to append the yield norms); kept open for forward-compat.
199
+ */
200
+ export interface PiBeforeAgentStartEvent {
201
+ /** The fully-assembled system prompt string for this turn. */
202
+ systemPrompt?: string;
203
+ }
204
+ /**
205
+ * Result of a `before_agent_start` handler (#695). Returning `systemPrompt`
206
+ * REPLACES the system prompt for the turn ("If multiple extensions return this,
207
+ * they are chained" — Pi 0.78). We append the yield norms and return it.
208
+ */
209
+ export interface PiBeforeAgentStartResult {
210
+ systemPrompt?: string;
211
+ }
194
212
  /**
195
213
  * Pi tool result — a Pi-free structural mirror of the real `AgentToolResult`
196
214
  * (#653, 1.4.2). The Phase-0 `{ output, isError }` guess was WRONG: Pi's real
@@ -138,6 +138,11 @@ function buildServerInstructions(opts) {
138
138
  `Use \`report\` to notify the conductor of task completion, blockers, or questions — always report when you finish a recruited task.` +
139
139
  `\n\nCommunication discipline:\n` +
140
140
  `- Drafting a response in your turn is not the same as sending one. The conductor and other players cannot read your reasoning — only your \`cue\` and \`report\` tool calls cross the channel boundary. If you reach a decision, ruling, or status update, fire the appropriate tool before moving on. If you find yourself thinking "I already answered that," verify the tool was actually invoked.` +
141
+ // #695 — yield-don't-poll norms (apply to all MCP players).
142
+ `\n- Yield after dispatch — after cueing a player and expecting a reply, end your turn. Inbound cues wake you automatically at the next turn boundary; there is nothing to poll.` +
143
+ `\n- \`listen\` is a one-shot inbox drain, not a wait primitive — it reads whatever is already queued at call time. A \`sleep\`+\`listen\` loop does not work as a wait and is an anti-pattern that burns tokens across the ensemble. If you're waiting for a reply, end your turn.` +
144
+ `\n- Don't reply to ack/FYI cues — if a player sends a status update or acknowledgment without asking a question or requesting action, do not respond. Responding starts a ping-pong that wastes turns on both sides.` +
145
+ `\n- Cues queue, they don't interrupt — a cue sent to you while you're processing arrives at your next turn boundary, not mid-turn. A burst from multiple players arrives together; process the batch in one turn.` +
141
146
  (isConductor
142
147
  ? `\n\nOperational rules:\n` +
143
148
  `- Before assigning parallel work on different branches, provision git worktrees via the \`worktree\` tool so each player has an isolated checkout.\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.6.2",
3
+ "version": "1.7.0-beta.1",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -54,6 +54,12 @@
54
54
  "agent-tempo": "dist/cli.js",
55
55
  "agent-tempo-server": "dist/server.js"
56
56
  },
57
+ "pi": {
58
+ "extensions": [
59
+ "./dist/pi/extension.js",
60
+ "./dist/pi/mission-control/extension.js"
61
+ ]
62
+ },
57
63
  "scripts": {
58
64
  "build": "tsc && npm run build:scripts && npm run build:dashboard && node -e \"const{bundleWorkflowCode}=require('@temporalio/worker');const path=require('path');const fs=require('fs');bundleWorkflowCode({workflowsPath:path.resolve('dist/workflows/index.js')}).then(b=>{fs.writeFileSync('workflow-bundle.js',b.code);console.log('Workflow bundle created')})\"",
59
65
  "build:scripts": "tsc -p scripts/tsconfig.json",