agent-tempo 1.6.1 → 1.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.6.1",
4
+ "version": "1.7.0-beta.0",
5
5
  "type": "module",
6
6
  "description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
7
7
  "scripts": {
@@ -30,6 +30,7 @@ exports.hardTerminateAttachment = hardTerminateAttachment;
30
30
  const child_process_1 = require("child_process");
31
31
  const fs_1 = require("fs");
32
32
  const path_1 = require("path");
33
+ const config_1 = require("../config");
33
34
  const constants_1 = require("../constants");
34
35
  const log = (...args) => console.error('[agent-tempo:hard-terminate]', ...args);
35
36
  /**
@@ -43,8 +44,13 @@ async function hardTerminateAttachment(input) {
43
44
  log(`hardTerminate start — ensemble=${ensemble} player=${playerName} agent=${agent}`);
44
45
  // ── Copilot bridge: PID file is authoritative ──
45
46
  if (agent === 'copilot') {
46
- const pidDir = logDir || (0, path_1.join)(workDir, 'logs');
47
- const pidPath = (0, path_1.join)(pidDir, `${playerName}.pid`);
47
+ // #690 pid lives at the CENTRAL ~/.agent-tempo/logs/<ensemble>/ path now.
48
+ // Transitional (one version, v1.6.2) READ-ONLY fallback to the legacy per-cwd
49
+ // <workDir>/logs so we can still find+kill an orphan from a pre-upgrade spawn.
50
+ // TODO(v1.7): drop the legacy fallback.
51
+ const centralPid = (0, config_1.bridgeLogPaths)(ensemble, playerName, logDir).pidPath;
52
+ const legacyPid = (0, path_1.join)(logDir || (0, path_1.join)(workDir, 'logs'), `${playerName}.pid`);
53
+ const pidPath = (0, fs_1.existsSync)(centralPid) ? centralPid : legacyPid;
48
54
  if ((0, fs_1.existsSync)(pidPath)) {
49
55
  try {
50
56
  const pidStr = (0, fs_1.readFileSync)(pidPath, 'utf8').trim();
@@ -332,10 +332,11 @@ class DirectApiAttachment extends base_1.SdkAttachment {
332
332
  process.exit(1);
333
333
  }
334
334
  // PID file so callers can find / kill orphaned adapter processes.
335
- const pidDir = path.join(workDir, 'logs');
336
- const pidFile = path.join(pidDir, `${playerIdForWorkflow}.pid`);
335
+ // #690 write/unlink the EXACT path the spawner computed (ENV.PID_FILE) so the
336
+ // adapter pid can't diverge from the spawner's; helper fallback for a manual launch.
337
+ const pidFile = (0, config_1.resolveAdapterPidFile)(config.ensemble, playerIdForWorkflow);
337
338
  try {
338
- fs.mkdirSync(pidDir, { recursive: true });
339
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
339
340
  fs.writeFileSync(pidFile, String(process.pid));
340
341
  }
341
342
  catch (err) {
@@ -353,10 +353,11 @@ class ClaudeCodeHeadlessAttachment extends base_1.SdkAttachment {
353
353
  process.exit(1);
354
354
  }
355
355
  // PID file so callers can find / kill orphaned adapter processes.
356
- const pidDir = path.join(workDir, 'logs');
357
- const pidFile = path.join(pidDir, `${playerIdForWorkflow}.pid`);
356
+ // #690 write/unlink the EXACT path the spawner computed (ENV.PID_FILE) so the
357
+ // adapter pid can't diverge from the spawner's; helper fallback for a manual launch.
358
+ const pidFile = (0, config_1.resolveAdapterPidFile)(config.ensemble, playerIdForWorkflow);
358
359
  try {
359
- fs.mkdirSync(pidDir, { recursive: true });
360
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
360
361
  fs.writeFileSync(pidFile, String(process.pid));
361
362
  }
362
363
  catch (err) {
@@ -386,8 +386,13 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
386
386
  log(`Initial prompt error after ${Date.now()}ms:`, err?.message, err?.stack?.substring(0, 300));
387
387
  }
388
388
  // PID file paths — computed early so early-exit paths can clean up
389
- const pidDir = path.join(workDir, 'logs');
390
- const pidFile = path.join(pidDir, `${playerName || playerIdForWorkflow}.pid`);
389
+ // #690 write/unlink the EXACT path the spawner computed (ENV.PID_FILE), so this
390
+ // adapter's pid file can't diverge from the spawner's. The copilot split-brain was
391
+ // here: spawnCopilotBridge sets PLAYER_NAME='' + BRIDGE_NAME=name, so this
392
+ // re-derivation fell to `playerIdForWorkflow` (→ `copilot-${Date.now()}`) ≠ the
393
+ // spawner's logName. Consuming the passed path removes the re-derivation entirely.
394
+ // Helper fallback only for a manual launch with no env.
395
+ const pidFile = (0, config_1.resolveAdapterPidFile)(config.ensemble, playerName || playerIdForWorkflow);
391
396
  // Wait for the MCP server's workflow to register in Temporal.
392
397
  // We know the exact workflow ID because we pass AGENT_TEMPO_PLAYER_NAME to the
393
398
  // MCP server — no need for a time-window heuristic that could misidentify workflows.
@@ -485,7 +490,7 @@ class CopilotSdkAttachment extends base_1.SdkAttachment {
485
490
  // Imported at the top of this module.
486
491
  // Write PID file so callers can find/kill orphaned bridge processes
487
492
  try {
488
- fs.mkdirSync(pidDir, { recursive: true });
493
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
489
494
  fs.writeFileSync(pidFile, String(process.pid));
490
495
  log(`PID file written: ${pidFile}`);
491
496
  }
@@ -239,7 +239,9 @@ class OpenCodeAttachment extends base_1.SdkAttachment {
239
239
  log(`Synthesized OPENCODE_CONFIG_CONTENT: ${(0, helpers_1.redactSecrets)(configContent)}`);
240
240
  // (3) Spawn opencode serve. Stdio redirected to a per-player log file
241
241
  // so terminal noise from opencode doesn't clutter the adapter's log.
242
- const logDir = path.join(workDir, 'logs');
242
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (the opencode serve subprocess
243
+ // log sits beside the adapter's own log, not in a per-cwd ./logs).
244
+ const logDir = (0, config_1.bridgeLogPaths)(config.ensemble, playerIdForWorkflow).dir;
243
245
  fs.mkdirSync(logDir, { recursive: true });
244
246
  const opencodeLogFile = path.join(logDir, `opencode-${playerIdForWorkflow}.log`);
245
247
  const logFd = fs.openSync(opencodeLogFile, 'a');
@@ -805,7 +805,7 @@ async function status(opts) {
805
805
  const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
806
806
  const statusLabel = phaseLabel(s.phase);
807
807
  // Show PID info for copilot bridge sessions
808
- const pidInfo = s.agentType === 'copilot' ? getBridgePidInfo(s.name) : '';
808
+ const pidInfo = s.agentType === 'copilot' ? getBridgePidInfo(ensemble, s.name) : '';
809
809
  const name = out.bold(s.name);
810
810
  out.log(` ${name}${role}${statusLabel}${agent}${pidInfo}`);
811
811
  if (s.part)
@@ -1065,6 +1065,9 @@ async function server(opts) {
1065
1065
  }
1066
1066
  async function up(opts) {
1067
1067
  const config = (0, config_1.getConfig)(opts);
1068
+ // #689 — best-effort sweep of stale 0600 secret env files (residual from a shell
1069
+ // that died between `source` and `rm`). Owner-only, swallows errors.
1070
+ (0, spawn_1.sweepStaleSecretEnvFiles)();
1068
1071
  out.heading('agent-tempo setup');
1069
1072
  // Step 1: Check temporal CLI
1070
1073
  if (!temporalCliExists()) {
@@ -1910,8 +1913,13 @@ async function down(opts) {
1910
1913
  * Read PID info for a copilot bridge session from its PID file.
1911
1914
  * Returns a formatted string like " (pid 12345)" or "" if no PID file found.
1912
1915
  */
1913
- function getBridgePidInfo(name) {
1914
- const pidPath = (0, path_1.join)(process.cwd(), 'logs', `${name}.pid`);
1916
+ function getBridgePidInfo(ensemble, name) {
1917
+ // #690 pid lives at the CENTRAL ~/.agent-tempo/logs/<ensemble>/ path; transitional
1918
+ // READ-ONLY fallback to the legacy per-cwd ./logs for a pre-upgrade bridge.
1919
+ // TODO(v1.7): drop the legacy fallback.
1920
+ const centralPid = (0, config_1.bridgeLogPaths)(ensemble, name).pidPath;
1921
+ const legacyPid = (0, path_1.join)(process.cwd(), 'logs', `${name}.pid`);
1922
+ const pidPath = (0, fs_1.existsSync)(centralPid) ? centralPid : legacyPid;
1915
1923
  if (!(0, fs_1.existsSync)(pidPath))
1916
1924
  return '';
1917
1925
  try {
@@ -1932,36 +1940,63 @@ function getBridgePidInfo(name) {
1932
1940
  }
1933
1941
  }
1934
1942
  /**
1935
- * Kill all bridge processes found in logs/*.pid and clean up PID files.
1943
+ * Kill all bridge processes found in `*.pid` files and clean up the pid files.
1944
+ *
1945
+ * #690 — bridge pid files moved to the CENTRAL `~/.agent-tempo/logs/<ensemble>/`
1946
+ * dirs. `down` is a GLOBAL teardown (it stops the daemon + Temporal for EVERY
1947
+ * ensemble), so this scans ALL central ensemble subdirs — NOT a single ensemble.
1948
+ *
1949
+ * ⚠️ GLOBAL-TEARDOWN ONLY. The sole caller is `down()`, which has no ensemble (it
1950
+ * stops everything), so scanning all ensembles is correct HERE. A FUTURE
1951
+ * ensemble-scoped teardown (e.g. `down --ensemble X` / a per-ensemble `destroy`)
1952
+ * MUST add an `ensemble` param and scope this to `bridgeLogPaths(ensemble, '').dir`
1953
+ * — do NOT reuse this global scan-all from a scoped op: it would kill OTHER live
1954
+ * ensembles' bridges. The param is intentionally NOT added now (no caller needs it
1955
+ * = speculative; backlogged per the architect's deviation ruling).
1956
+ *
1957
+ * Plus a transitional READ of the legacy per-cwd `./logs` for a pre-upgrade
1958
+ * bridge. TODO(v1.7): drop the legacy `./logs` dir.
1936
1959
  */
1937
1960
  function killBridgeProcesses() {
1938
- const logsDir = (0, path_1.join)(process.cwd(), 'logs');
1939
- if (!(0, fs_1.existsSync)(logsDir))
1940
- return;
1961
+ const centralRoot = (0, config_1.bridgeLogsRoot)();
1962
+ const dirs = [(0, path_1.join)(process.cwd(), 'logs')]; // legacy (transitional)
1941
1963
  try {
1942
- const pidFiles = (0, fs_1.readdirSync)(logsDir).filter(f => f.endsWith('.pid'));
1943
- for (const pidFile of pidFiles) {
1944
- const pidPath = (0, path_1.join)(logsDir, pidFile);
1945
- try {
1946
- const pid = parseInt((0, fs_1.readFileSync)(pidPath, 'utf8').trim(), 10);
1947
- if (!isNaN(pid)) {
1948
- try {
1949
- process.kill(pid);
1950
- out.log(` ${out.dim(`Killed bridge process ${pidFile.replace('.pid', '')} (pid ${pid})`)}`);
1951
- }
1952
- catch {
1953
- // already dead
1964
+ for (const ent of (0, fs_1.readdirSync)(centralRoot, { withFileTypes: true })) {
1965
+ if (ent.isDirectory())
1966
+ dirs.push((0, path_1.join)(centralRoot, ent.name));
1967
+ }
1968
+ }
1969
+ catch {
1970
+ // no central logs root yet — nothing central to scan
1971
+ }
1972
+ for (const logsDir of dirs) {
1973
+ if (!(0, fs_1.existsSync)(logsDir))
1974
+ continue;
1975
+ try {
1976
+ const pidFiles = (0, fs_1.readdirSync)(logsDir).filter(f => f.endsWith('.pid'));
1977
+ for (const pidFile of pidFiles) {
1978
+ const pidPath = (0, path_1.join)(logsDir, pidFile);
1979
+ try {
1980
+ const pid = parseInt((0, fs_1.readFileSync)(pidPath, 'utf8').trim(), 10);
1981
+ if (!isNaN(pid)) {
1982
+ try {
1983
+ process.kill(pid);
1984
+ out.log(` ${out.dim(`Killed bridge process ${pidFile.replace('.pid', '')} (pid ${pid})`)}`);
1985
+ }
1986
+ catch {
1987
+ // already dead
1988
+ }
1954
1989
  }
1990
+ (0, fs_1.unlinkSync)(pidPath);
1991
+ }
1992
+ catch {
1993
+ // unreadable — skip
1955
1994
  }
1956
- (0, fs_1.unlinkSync)(pidPath);
1957
- }
1958
- catch {
1959
- // unreadable — skip
1960
1995
  }
1961
1996
  }
1962
- }
1963
- catch {
1964
- // logs dir unreadable
1997
+ catch {
1998
+ // logs dir unreadable
1999
+ }
1965
2000
  }
1966
2001
  }
1967
2002
  async function agentTypesCommand(opts) {
@@ -19,8 +19,6 @@ import type { AgentType } from '../types';
19
19
  * stale subset; this one is intentionally narrow and must stay that way.
20
20
  */
21
21
  export declare const VALID_DEFAULT_AGENTS: readonly AgentType[];
22
- /** True when a config key holds a credential value that must be masked on display. */
23
- export declare function isSecretKey(key: string): boolean;
24
22
  /**
25
23
  * Render a secret for display: a short non-sensitive prefix (when the value is
26
24
  * long enough that the prefix reveals only a small fraction) + a masked tail +
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.VALID_DEFAULT_AGENTS = void 0;
37
- exports.isSecretKey = isSecretKey;
38
37
  exports.maskSecret = maskSecret;
39
38
  exports.configInteractive = configInteractive;
40
39
  exports.configSet = configSet;
@@ -43,6 +42,7 @@ exports.configCommand = configCommand;
43
42
  const readline = __importStar(require("readline"));
44
43
  const config_1 = require("../config");
45
44
  const config_2 = require("../config");
45
+ const secrets_1 = require("../utils/secrets");
46
46
  const out = __importStar(require("./output"));
47
47
  /**
48
48
  * Agents valid as a persistent `defaultAgent` — the conductor-capable PRODUCTION
@@ -71,18 +71,9 @@ exports.VALID_DEFAULT_AGENTS = ['claude', 'copilot', 'pi'];
71
71
  // under a broken Temporal SDK install.
72
72
  // #684 — secret-masking. Any config field whose name looks like a credential is
73
73
  // masked in EVERY display path (show / interactive default / set echo) so a key is
74
- // never printed raw (terminal scrollback, screen-share, logs). Generalized on
75
- // purpose: a future secret added to the config is masked BY DEFAULT, not leaked.
76
- const SECRET_KEYS = new Set(['temporalApiKey', 'httpToken', 'readToken', 'adminToken']);
77
- // Matches *_API_KEY / *ApiKey / *Token / *Secret / *Password but NOT path fields
78
- // (e.g. temporalTlsKeyPath is a file path, not the key — it must stay visible).
79
- const SECRET_KEY_PATTERN = /(api[_-]?key|token|secret|password)/i;
80
- /** True when a config key holds a credential value that must be masked on display. */
81
- function isSecretKey(key) {
82
- if (/path$/i.test(key))
83
- return false; // *Path fields are file locations, not secrets
84
- return SECRET_KEYS.has(key) || SECRET_KEY_PATTERN.test(key);
85
- }
74
+ // never printed raw (terminal scrollback, screen-share, logs). The classifier
75
+ // (`isSecretKey`) was extracted to `utils/secrets.ts` in #689 so `spawn.ts` shares
76
+ // it a future secret masks AND stays off the command line everywhere at once.
86
77
  /**
87
78
  * Render a secret for display: a short non-sensitive prefix (when the value is
88
79
  * long enough that the prefix reveals only a small fraction) + a masked tail +
@@ -257,7 +248,7 @@ function configSet(key, value) {
257
248
  (0, config_1.saveConfigFile)(config);
258
249
  // #684 — echo through the same secret-masking path so `config set temporalApiKey …`
259
250
  // never prints the value back raw (and a *Path field still shows its location).
260
- out.success(`Set ${configKey} = ${isSecretKey(configKey) ? maskSecret(value) : value}`);
251
+ out.success(`Set ${configKey} = ${(0, secrets_1.isSecretKey)(configKey) ? maskSecret(value) : value}`);
261
252
  }
262
253
  /** Show current config: `agent-tempo config show` */
263
254
  function configShow() {
@@ -281,7 +272,7 @@ function configShow() {
281
272
  const source = sources[configKey];
282
273
  // #684 — secret-like fields go through maskSecret (prefix + masked tail + char
283
274
  // count); everything else shows its value or "(not set)".
284
- const display = isSecretKey(key) ? maskSecret(value) : (!value ? '(not set)' : value);
275
+ const display = (0, secrets_1.isSecretKey)(key) ? maskSecret(value) : (!value ? '(not set)' : value);
285
276
  out.log(` ${key.padEnd(22)} ${display.padEnd(30)} ${out.dim(source)}`);
286
277
  }
287
278
  console.log();
package/dist/config.d.ts CHANGED
@@ -130,6 +130,15 @@ export declare const ENV: {
130
130
  * spawns do NOT set it, so recruited adapters keep the #604 anti-leak ppid-poll.
131
131
  */
132
132
  readonly NO_PPID_WATCHDOG: "AGENT_TEMPO_NO_PPID_WATCHDOG";
133
+ /**
134
+ * #690 — absolute path to the bridge pid file, computed ONCE by the spawn
135
+ * helper (`bridgeLogPaths(ensemble, name).pidPath`) and passed to the adapter
136
+ * child. The adapter writes/unlinks THIS path rather than re-deriving its own
137
+ * (which diverged from the spawner's when PLAYER_NAME was empty — the
138
+ * split-brain orphan). PLAIN (non-secret): it's a file location, not a
139
+ * credential — must stay inline under #689's `partitionEnv`.
140
+ */
141
+ readonly PID_FILE: "AGENT_TEMPO_PID_FILE";
133
142
  /**
134
143
  * Escape hatch for triple-isolated environments (ADR 0014 §5.3). When
135
144
  * set, `resolveTempoHome()` returns this path verbatim — bypassing both
@@ -226,6 +235,48 @@ export declare function isDevMode(): boolean;
226
235
  export declare function resolveTempoHome(): string;
227
236
  export declare const AGENT_TEMPO_HOME: string;
228
237
  export declare const CONFIG_FILE_PATH: string;
238
+ /** Resolved log + pid paths for a recruited bridge/adapter player (#690). */
239
+ export interface BridgeLogPaths {
240
+ /** The directory holding the player's log + pid files. */
241
+ dir: string;
242
+ /** `<dir>/<player>.log`. */
243
+ logPath: string;
244
+ /** `<dir>/<player>.pid`. */
245
+ pidPath: string;
246
+ }
247
+ /**
248
+ * SINGLE source of truth for where a recruited bridge/adapter's `.log` + `.pid`
249
+ * live (#690). Default is CENTRAL — `~/.agent-tempo/logs/<ensemble>/<player>.*` —
250
+ * NOT the old per-cwd `<workDir>/logs` (which scattered pid files across every
251
+ * recruit directory and orphaned them on `down`). No call site should construct
252
+ * its own `join(..., 'logs', ...)`; route everything through here so the writer
253
+ * (spawn helper) and the readers (status / down / hard-terminate) compute the
254
+ * SAME path and can't split-brain.
255
+ *
256
+ * `overrideDir` is the existing per-spawn `opts.logDir` escape hatch (rarely set);
257
+ * when present it wins over the central default. `ensemble`/`player` are
258
+ * regex-validated upstream (ENSEMBLE_NAME_REGEX / PLAYER_NAME_REGEX — no slashes),
259
+ * but a defensive guard rejects path-traversal as insurance.
260
+ */
261
+ /**
262
+ * Root of the central bridge-log tree: `~/.agent-tempo/logs`. Per-ensemble dirs
263
+ * live under it. Exposed so a cluster-wide reader (e.g. `down`'s
264
+ * killBridgeProcesses) can enumerate every ensemble's dir without re-constructing
265
+ * the `'logs'` segment itself — {@link bridgeLogPaths} is the only other place
266
+ * that names it.
267
+ */
268
+ export declare function bridgeLogsRoot(): string;
269
+ export declare function bridgeLogPaths(ensemble: string, player: string, overrideDir?: string): BridgeLogPaths;
270
+ /**
271
+ * The pid path an ADAPTER subprocess should write/unlink (#690). The SPAWNER
272
+ * computes the path once via {@link bridgeLogPaths} and passes it as
273
+ * `ENV.PID_FILE`; the adapter consumes THAT — it does NOT re-derive its own from
274
+ * a (possibly divergent) player identifier. The `bridgeLogPaths` fallback is used
275
+ * ONLY when the env is absent (a manual adapter launch outside the spawner). This
276
+ * is the by-construction fix for the copilot split-brain (PLAYER_NAME='' →
277
+ * `copilot-${Date.now()}` ≠ the spawner's logName).
278
+ */
279
+ export declare function resolveAdapterPidFile(ensemble: string, fallbackPlayer: string): string;
229
280
  /**
230
281
  * Daemon-level configuration persisted in `~/.agent-tempo/config.json`
231
282
  * alongside the existing `PersistedConfig` fields.
package/dist/config.js CHANGED
@@ -3,6 +3,9 @@ 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
4
  exports.isDevMode = isDevMode;
5
5
  exports.resolveTempoHome = resolveTempoHome;
6
+ exports.bridgeLogsRoot = bridgeLogsRoot;
7
+ exports.bridgeLogPaths = bridgeLogPaths;
8
+ exports.resolveAdapterPidFile = resolveAdapterPidFile;
6
9
  exports.loadDaemonConfig = loadDaemonConfig;
7
10
  exports.matchEnsembleGlob = matchEnsembleGlob;
8
11
  exports.isEnsembleAllowed = isEnsembleAllowed;
@@ -155,6 +158,15 @@ exports.ENV = {
155
158
  * spawns do NOT set it, so recruited adapters keep the #604 anti-leak ppid-poll.
156
159
  */
157
160
  NO_PPID_WATCHDOG: 'AGENT_TEMPO_NO_PPID_WATCHDOG',
161
+ /**
162
+ * #690 — absolute path to the bridge pid file, computed ONCE by the spawn
163
+ * helper (`bridgeLogPaths(ensemble, name).pidPath`) and passed to the adapter
164
+ * child. The adapter writes/unlinks THIS path rather than re-deriving its own
165
+ * (which diverged from the spawner's when PLAYER_NAME was empty — the
166
+ * split-brain orphan). PLAIN (non-secret): it's a file location, not a
167
+ * credential — must stay inline under #689's `partitionEnv`.
168
+ */
169
+ PID_FILE: 'AGENT_TEMPO_PID_FILE',
158
170
  /**
159
171
  * Escape hatch for triple-isolated environments (ADR 0014 §5.3). When
160
172
  * set, `resolveTempoHome()` returns this path verbatim — bypassing both
@@ -223,6 +235,51 @@ function resolveTempoHome() {
223
235
  }
224
236
  exports.AGENT_TEMPO_HOME = resolveTempoHome();
225
237
  exports.CONFIG_FILE_PATH = (0, path_1.join)(exports.AGENT_TEMPO_HOME, 'config.json');
238
+ /**
239
+ * SINGLE source of truth for where a recruited bridge/adapter's `.log` + `.pid`
240
+ * live (#690). Default is CENTRAL — `~/.agent-tempo/logs/<ensemble>/<player>.*` —
241
+ * NOT the old per-cwd `<workDir>/logs` (which scattered pid files across every
242
+ * recruit directory and orphaned them on `down`). No call site should construct
243
+ * its own `join(..., 'logs', ...)`; route everything through here so the writer
244
+ * (spawn helper) and the readers (status / down / hard-terminate) compute the
245
+ * SAME path and can't split-brain.
246
+ *
247
+ * `overrideDir` is the existing per-spawn `opts.logDir` escape hatch (rarely set);
248
+ * when present it wins over the central default. `ensemble`/`player` are
249
+ * regex-validated upstream (ENSEMBLE_NAME_REGEX / PLAYER_NAME_REGEX — no slashes),
250
+ * but a defensive guard rejects path-traversal as insurance.
251
+ */
252
+ /**
253
+ * Root of the central bridge-log tree: `~/.agent-tempo/logs`. Per-ensemble dirs
254
+ * live under it. Exposed so a cluster-wide reader (e.g. `down`'s
255
+ * killBridgeProcesses) can enumerate every ensemble's dir without re-constructing
256
+ * the `'logs'` segment itself — {@link bridgeLogPaths} is the only other place
257
+ * that names it.
258
+ */
259
+ function bridgeLogsRoot() {
260
+ return (0, path_1.join)(exports.AGENT_TEMPO_HOME, 'logs');
261
+ }
262
+ function bridgeLogPaths(ensemble, player, overrideDir) {
263
+ for (const [label, seg] of [['ensemble', ensemble], ['player', player]]) {
264
+ if (/[/\\]|\.\./.test(seg)) {
265
+ throw new Error(`bridgeLogPaths: ${label} "${seg}" must not contain path separators or "..".`);
266
+ }
267
+ }
268
+ const dir = overrideDir ?? (0, path_1.join)(bridgeLogsRoot(), ensemble);
269
+ return { dir, logPath: (0, path_1.join)(dir, `${player}.log`), pidPath: (0, path_1.join)(dir, `${player}.pid`) };
270
+ }
271
+ /**
272
+ * The pid path an ADAPTER subprocess should write/unlink (#690). The SPAWNER
273
+ * computes the path once via {@link bridgeLogPaths} and passes it as
274
+ * `ENV.PID_FILE`; the adapter consumes THAT — it does NOT re-derive its own from
275
+ * a (possibly divergent) player identifier. The `bridgeLogPaths` fallback is used
276
+ * ONLY when the env is absent (a manual adapter launch outside the spawner). This
277
+ * is the by-construction fix for the copilot split-brain (PLAYER_NAME='' →
278
+ * `copilot-${Date.now()}` ≠ the spawner's logName).
279
+ */
280
+ function resolveAdapterPidFile(ensemble, fallbackPlayer) {
281
+ return process.env[exports.ENV.PID_FILE] || bridgeLogPaths(ensemble, fallbackPlayer).pidPath;
282
+ }
226
283
  // ── Daemon config (PR-E design §10.2) ──
227
284
  /**
228
285
  * Daemon-level configuration persisted in `~/.agent-tempo/config.json`
@@ -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
@@ -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/dist/spawn.d.ts CHANGED
@@ -35,10 +35,56 @@ export declare function detectMacTerminal(): 'ghostty' | 'iterm2' | 'terminal';
35
35
  /** Find the first available terminal emulator on Linux */
36
36
  export declare function findLinuxTerminal(): string | null;
37
37
  /**
38
- * Build a shell command string that sets env vars and runs claude.
39
- * Uses inline `KEY=val` syntax which works in bash, zsh, AND fish.
38
+ * fish single-quote escaping. Inside fish `'...'` only `\` and `'` are special
39
+ * (`\\` and `\'`) — POSIX `shellQuote`'s `'\''` trick is WRONG in fish, so the
40
+ * secret file's `set -gx` lines need this. Plain inline env keeps `shellQuote`
41
+ * (safe there: plain values are regex-validated names with no embedded quotes).
40
42
  */
41
- export declare function buildTerminalCommand(bin: string, binArgs: string[], envVars: Record<string, string>): string;
43
+ export declare function fishQuote(s: string): string;
44
+ /** Split env into non-secret (inline-able) and secret (file-only) by key name. */
45
+ export declare function partitionEnv(env: Record<string, string>): {
46
+ plainEnv: Record<string, string>;
47
+ secretEnv: Record<string, string>;
48
+ };
49
+ export interface SecretEnvFile {
50
+ /** Absolute path to the 0600 env file, or '' when there were no secrets. */
51
+ path: string;
52
+ /** Chain prefix that sources THEN deletes the file before the bin runs (or ''). */
53
+ sourcePrefix: string;
54
+ /** Standalone delete command for the file (or ''). */
55
+ cleanup: string;
56
+ }
57
+ /**
58
+ * Write secret env to a 0600 file in the 0700 {@link SECRET_ENV_DIR} and return a
59
+ * `sourcePrefix` that sources + deletes it before exec. Empty `secretEnv` → no
60
+ * file, empty strings (no behavior change when there are no secrets, e.g. local
61
+ * dev with no Cloud key).
62
+ *
63
+ * Security: `openSync(path, 'wx', 0o600)` (O_EXCL → fails if the path exists,
64
+ * defeating symlink pre-creation in a shared tmp), a `crypto.randomBytes` name
65
+ * (NOT a predictable `Date.now()`), and the 0700 dir + 0600 file = owner-only
66
+ * twice over. The LAUNCHER self-deletes (sourcePrefix `… && rm -f <f> && …`) — the
67
+ * spawner does NOT, so there's no race: Node writes synchronously before the
68
+ * terminal launches, only the shell reads the file, and after `rm` the value
69
+ * lives only in the process env.
70
+ */
71
+ export declare function writeSecretEnvFile(secretEnv: Record<string, string>, opts: {
72
+ syntax: 'posix' | 'fish' | 'cmd';
73
+ }): SecretEnvFile;
74
+ /**
75
+ * Best-effort sweep of secret env files older than `maxAgeMs` (default 5 min) —
76
+ * a backstop for the accepted residual when a shell dies between `source` and
77
+ * `rm`. Owner-only files in our 0700 dir; swallow all errors. Call at `up` start.
78
+ */
79
+ export declare function sweepStaleSecretEnvFiles(maxAgeMs?: number, now?: number): void;
80
+ /**
81
+ * Build a shell command string that sets env vars and runs `bin` (#689).
82
+ * Plain env keeps the inline `KEY=val` form (works in bash/zsh/fish); SECRET env
83
+ * is routed to a sourced 0600 file via {@link writeSecretEnvFile}, so secret
84
+ * VALUES never appear in the returned command string. `syntax` picks the secret
85
+ * file's dialect (the inline plain form is identical across posix/fish).
86
+ */
87
+ export declare function buildTerminalCommand(bin: string, binArgs: string[], envVars: Record<string, string>, syntax?: 'posix' | 'fish'): string;
42
88
  /**
43
89
  * Launch ANY binary in a visible terminal window (the cross-platform core
44
90
  * extracted from `spawnInTerminal`, #666 C1). Generic over `bin`/`args` so it
package/dist/spawn.js CHANGED
@@ -6,6 +6,10 @@ exports.shellQuote = shellQuote;
6
6
  exports.resolveClaudePath = resolveClaudePath;
7
7
  exports.detectMacTerminal = detectMacTerminal;
8
8
  exports.findLinuxTerminal = findLinuxTerminal;
9
+ exports.fishQuote = fishQuote;
10
+ exports.partitionEnv = partitionEnv;
11
+ exports.writeSecretEnvFile = writeSecretEnvFile;
12
+ exports.sweepStaleSecretEnvFiles = sweepStaleSecretEnvFiles;
9
13
  exports.buildTerminalCommand = buildTerminalCommand;
10
14
  exports.launchInTerminal = launchInTerminal;
11
15
  exports.spawnInTerminal = spawnInTerminal;
@@ -22,7 +26,9 @@ const child_process_1 = require("child_process");
22
26
  const fs_1 = require("fs");
23
27
  const path_1 = require("path");
24
28
  const os_1 = require("os");
29
+ const crypto_1 = require("crypto");
25
30
  const config_1 = require("./config");
31
+ const secrets_1 = require("./utils/secrets");
26
32
  const log = (...args) => console.error('[agent-tempo:spawn]', ...args);
27
33
  /** Stable GUID for the agent-tempo Windows Terminal profile. */
28
34
  const WT_PROFILE_GUID = '{c1a0d300-0e30-4000-a000-c1a0de00e300}';
@@ -234,18 +240,128 @@ function findLinuxTerminal() {
234
240
  }
235
241
  return null;
236
242
  }
243
+ // ── #689 no-echo spawn: keep secret env values OUT of the echoed command ──────
244
+ //
245
+ // Terminal launches (claude conductor/recruit via spawnInTerminal, pi conductor
246
+ // via buildPiConductorSpawn) INLINE env into the command string that gets typed/
247
+ // echoed into the terminal — so `TEMPORAL_API_KEY='<JWT>' … pi …` lands in
248
+ // scrollback + shell history. Fix: partition env by name; SECRET values are
249
+ // written to a 0600 file in a 0700 owner-only dir and `source`d (then the
250
+ // launcher self-`rm`s it) so the value never appears on the command line. Plain
251
+ // (non-secret) env keeps the existing inline form. Headless adapters
252
+ // (copilot/claude-api/opencode/*-headless) pass env via child_process `env:{}`
253
+ // inheritance — no terminal, no inline — so they're unaffected.
254
+ /** Owner-only (0700) dir holding short-lived 0600 secret env files. */
255
+ const SECRET_ENV_DIR = (0, path_1.join)((0, os_1.tmpdir)(), 'agent-tempo-spawn');
256
+ /** Escape a value for `cmd.exe` (wrap-in-quotes callers add the quotes). */
257
+ function cmdEscape(s) {
258
+ return s.replace(/([&|<>^"%])/g, '^$1');
259
+ }
237
260
  /**
238
- * Build a shell command string that sets env vars and runs claude.
239
- * Uses inline `KEY=val` syntax which works in bash, zsh, AND fish.
261
+ * fish single-quote escaping. Inside fish `'...'` only `\` and `'` are special
262
+ * (`\\` and `\'`) — POSIX `shellQuote`'s `'\''` trick is WRONG in fish, so the
263
+ * secret file's `set -gx` lines need this. Plain inline env keeps `shellQuote`
264
+ * (safe there: plain values are regex-validated names with no embedded quotes).
265
+ */
266
+ function fishQuote(s) {
267
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
268
+ }
269
+ /** Split env into non-secret (inline-able) and secret (file-only) by key name. */
270
+ function partitionEnv(env) {
271
+ const plainEnv = {};
272
+ const secretEnv = {};
273
+ for (const [k, v] of Object.entries(env)) {
274
+ if ((0, secrets_1.isSecretKey)(k))
275
+ secretEnv[k] = v;
276
+ else
277
+ plainEnv[k] = v;
278
+ }
279
+ return { plainEnv, secretEnv };
280
+ }
281
+ /**
282
+ * Write secret env to a 0600 file in the 0700 {@link SECRET_ENV_DIR} and return a
283
+ * `sourcePrefix` that sources + deletes it before exec. Empty `secretEnv` → no
284
+ * file, empty strings (no behavior change when there are no secrets, e.g. local
285
+ * dev with no Cloud key).
286
+ *
287
+ * Security: `openSync(path, 'wx', 0o600)` (O_EXCL → fails if the path exists,
288
+ * defeating symlink pre-creation in a shared tmp), a `crypto.randomBytes` name
289
+ * (NOT a predictable `Date.now()`), and the 0700 dir + 0600 file = owner-only
290
+ * twice over. The LAUNCHER self-deletes (sourcePrefix `… && rm -f <f> && …`) — the
291
+ * spawner does NOT, so there's no race: Node writes synchronously before the
292
+ * terminal launches, only the shell reads the file, and after `rm` the value
293
+ * lives only in the process env.
240
294
  */
241
- function buildTerminalCommand(bin, binArgs, envVars) {
242
- const envInline = Object.entries(envVars)
295
+ function writeSecretEnvFile(secretEnv, opts) {
296
+ const keys = Object.keys(secretEnv);
297
+ if (keys.length === 0)
298
+ return { path: '', sourcePrefix: '', cleanup: '' };
299
+ (0, fs_1.mkdirSync)(SECRET_ENV_DIR, { recursive: true, mode: 0o700 });
300
+ const ext = opts.syntax === 'cmd' ? 'cmd' : 'sh';
301
+ const path = (0, path_1.join)(SECRET_ENV_DIR, `env-${(0, crypto_1.randomBytes)(9).toString('hex')}.${ext}`);
302
+ let content;
303
+ if (opts.syntax === 'fish') {
304
+ content = keys.map((k) => `set -gx ${k} ${fishQuote(secretEnv[k])}`).join('\n') + '\n';
305
+ }
306
+ else if (opts.syntax === 'cmd') {
307
+ content = keys.map((k) => `set "${k}=${cmdEscape(secretEnv[k])}"`).join('\r\n') + '\r\n';
308
+ }
309
+ else {
310
+ content = keys.map((k) => `export ${k}=${shellQuote(secretEnv[k])}`).join('\n') + '\n';
311
+ }
312
+ // O_EXCL create with 0600 — fails (no follow) if a symlink/file pre-exists.
313
+ const fd = (0, fs_1.openSync)(path, 'wx', 0o600);
314
+ try {
315
+ (0, fs_1.writeSync)(fd, content);
316
+ }
317
+ finally {
318
+ (0, fs_1.closeSync)(fd);
319
+ }
320
+ if (opts.syntax === 'cmd') {
321
+ const q = `"${cmdEscape(path)}"`;
322
+ return { path, sourcePrefix: `call ${q} && del ${q} && `, cleanup: `del ${q}` };
323
+ }
324
+ const q = shellQuote(path);
325
+ return { path, sourcePrefix: `source ${q} && rm -f ${q} && `, cleanup: `rm -f ${q}` };
326
+ }
327
+ /**
328
+ * Best-effort sweep of secret env files older than `maxAgeMs` (default 5 min) —
329
+ * a backstop for the accepted residual when a shell dies between `source` and
330
+ * `rm`. Owner-only files in our 0700 dir; swallow all errors. Call at `up` start.
331
+ */
332
+ function sweepStaleSecretEnvFiles(maxAgeMs = 5 * 60_000, now = Date.now()) {
333
+ try {
334
+ for (const name of (0, fs_1.readdirSync)(SECRET_ENV_DIR)) {
335
+ if (!name.startsWith('env-'))
336
+ continue;
337
+ const p = (0, path_1.join)(SECRET_ENV_DIR, name);
338
+ try {
339
+ if (now - (0, fs_1.statSync)(p).mtimeMs > maxAgeMs)
340
+ (0, fs_1.rmSync)(p, { force: true });
341
+ }
342
+ catch { /* per-file best-effort */ }
343
+ }
344
+ }
345
+ catch { /* dir absent / unreadable — nothing to sweep */ }
346
+ }
347
+ /**
348
+ * Build a shell command string that sets env vars and runs `bin` (#689).
349
+ * Plain env keeps the inline `KEY=val` form (works in bash/zsh/fish); SECRET env
350
+ * is routed to a sourced 0600 file via {@link writeSecretEnvFile}, so secret
351
+ * VALUES never appear in the returned command string. `syntax` picks the secret
352
+ * file's dialect (the inline plain form is identical across posix/fish).
353
+ */
354
+ function buildTerminalCommand(bin, binArgs, envVars, syntax = 'posix') {
355
+ const { plainEnv, secretEnv } = partitionEnv(envVars);
356
+ const { sourcePrefix } = writeSecretEnvFile(secretEnv, { syntax });
357
+ const envInline = Object.entries(plainEnv)
243
358
  .map(([k, v]) => `${k}=${shellQuote(v)}`)
244
359
  .join(' ');
245
360
  // Quote the binary path if it contains spaces (e.g., "C:\Program Files\...")
246
361
  const quotedBin = bin.includes(' ') ? shellQuote(bin) : bin;
247
362
  const args = binArgs.map(a => shellQuote(a)).join(' ');
248
- return envInline ? `${envInline} ${quotedBin} ${args}` : `${quotedBin} ${args}`;
363
+ const invocation = envInline ? `${envInline} ${quotedBin} ${args}` : `${quotedBin} ${args}`;
364
+ return `${sourcePrefix}${invocation}`;
249
365
  }
250
366
  /**
251
367
  * Launch ANY binary in a visible terminal window (the cross-platform core
@@ -267,11 +383,16 @@ function launchInTerminal(bin, args, workDir, envVars) {
267
383
  // is bin/args-agnostic; the `claude*` names are historical.
268
384
  const claudeBin = bin;
269
385
  const claudeArgs = args;
270
- const claudeInvocation = buildTerminalCommand(claudeBin, claudeArgs, envVars);
271
386
  if (process.platform === 'darwin') {
272
387
  const detected = detectMacTerminal();
273
388
  log(`Terminal detection: TERM_PROGRAM=${JSON.stringify(process.env.TERM_PROGRAM)}, detected=${detected}`);
389
+ // #689 — secret env routes through a sourced 0600 file (buildTerminalCommand /
390
+ // the .command body); pick the file dialect from the user's shell since
391
+ // Ghostty/iTerm2 type the command into it. Computed per-branch below so only
392
+ // the branch that runs writes a secret file (no orphan).
393
+ const macSyntax = (process.env.SHELL || '').endsWith('/fish') ? 'fish' : 'posix';
274
394
  if (detected === 'ghostty') {
395
+ const claudeInvocation = buildTerminalCommand(claudeBin, claudeArgs, envVars, macSyntax);
275
396
  // Append `; exit` so the wrapping shell exits when claude does (clean or killed).
276
397
  // Without it, claude exit returns control to the shell prompt and the tab lingers —
277
398
  // parity with the Windows WT `closeOnExit: 'always'` + parent-walk fix from #166.
@@ -292,6 +413,7 @@ function launchInTerminal(bin, args, workDir, envVars) {
292
413
  return { pid: child.pid };
293
414
  }
294
415
  if (detected === 'iterm2') {
416
+ const claudeInvocation = buildTerminalCommand(claudeBin, claudeArgs, envVars, macSyntax);
295
417
  // Append `; exit` so the wrapping shell exits when claude does. `;` rather than
296
418
  // `&&` so exit runs regardless of claude's exit code (force-kill returns non-zero).
297
419
  // JSON.stringify embeds the full shell command as a properly-escaped string literal
@@ -313,38 +435,50 @@ function launchInTerminal(bin, args, workDir, envVars) {
313
435
  child.unref();
314
436
  return { pid: child.pid };
315
437
  }
316
- // Terminal.app: .command file with shell profile sourcing
438
+ // Terminal.app: .command file with shell profile sourcing.
317
439
  const userShell = process.env.SHELL || '/bin/zsh';
318
- const scriptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-tempo-recruit-${Date.now()}.command`);
319
- let profileSource;
440
+ // #689 the .command file is mode 0700 (was 0755 — it should never have been
441
+ // world/group-readable). The secret env lives in a SEPARATE sourced 0600 file,
442
+ // never inlined into this .command body.
443
+ const scriptPath = (0, path_1.join)(SECRET_ENV_DIR, `recruit-${(0, crypto_1.randomBytes)(9).toString('hex')}.command`);
444
+ (0, fs_1.mkdirSync)(SECRET_ENV_DIR, { recursive: true, mode: 0o700 });
445
+ let lines;
320
446
  if (userShell.endsWith('/fish')) {
321
- profileSource = `exec fish -c "cd ${shellQuote(workDir)} && ${claudeInvocation}"`;
447
+ // claudeInvocation (fish) already carries the plain inline env + the fish
448
+ // secret-file source+rm — just exec fish with it.
449
+ const claudeInvocation = buildTerminalCommand(claudeBin, claudeArgs, envVars, 'fish');
450
+ lines = ['#!/bin/bash', `exec fish -c "cd ${shellQuote(workDir)} && ${claudeInvocation}"`];
322
451
  }
323
452
  else {
324
- profileSource = [
453
+ const { plainEnv, secretEnv } = partitionEnv(envVars);
454
+ const secretFile = writeSecretEnvFile(secretEnv, { syntax: 'posix' });
455
+ const plainExports = Object.entries(plainEnv)
456
+ .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
457
+ .join('\n');
458
+ const profileSource = [
325
459
  `[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null`,
326
460
  `[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" 2>/dev/null`,
327
461
  `[ -f "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh" 2>/dev/null`,
328
462
  `command -v fnm >/dev/null && eval "$(fnm env)" 2>/dev/null`,
329
463
  ].join('\n');
464
+ lines = [
465
+ '#!/bin/bash',
466
+ // Secret env from the sourced 0600 file (self-deleted), BEFORE profile
467
+ // sourcing — same ordering rationale as the plain exports (#98).
468
+ ...(secretFile.path ? [`source ${shellQuote(secretFile.path)} && rm -f ${shellQuote(secretFile.path)}`] : []),
469
+ // Plain env vars BEFORE profile sourcing — profiles that call `exec` (e.g.
470
+ // oh-my-zsh) would otherwise lose the exports and the claude command (#98)
471
+ plainExports,
472
+ profileSource,
473
+ `cd ${shellQuote(workDir)}`,
474
+ // `exec` so the shell is replaced by claude — when claude exits (clean or killed),
475
+ // the script process ends and Terminal.app closes the window per its settings.
476
+ // Without `exec`, bash waits for claude and then returns to prompt, leaving the
477
+ // window open. Parity with the WT `closeOnExit: 'always'` fix from #166.
478
+ `exec ${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`,
479
+ ];
330
480
  }
331
- const envExports = Object.entries(envVars)
332
- .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
333
- .join('\n');
334
- const lines = [
335
- '#!/bin/bash',
336
- // Env vars BEFORE profile sourcing — profiles that call `exec` (e.g. oh-my-zsh)
337
- // would otherwise lose the exports and the claude command (#98)
338
- envExports,
339
- profileSource,
340
- `cd ${shellQuote(workDir)}`,
341
- // `exec` so the shell is replaced by claude — when claude exits (clean or killed),
342
- // the script process ends and Terminal.app closes the window per its settings.
343
- // Without `exec`, bash waits for claude and then returns to prompt, leaving the
344
- // window open. Parity with the WT `closeOnExit: 'always'` fix from #166.
345
- `exec ${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`,
346
- ];
347
- (0, fs_1.writeFileSync)(scriptPath, lines.join('\n') + '\n', { mode: 0o755 });
481
+ (0, fs_1.writeFileSync)(scriptPath, lines.join('\n') + '\n', { mode: 0o700 });
348
482
  log('Using Terminal.app .command path:', scriptPath);
349
483
  const child = (0, child_process_1.spawn)('open', [scriptPath], { detached: true, stdio: 'ignore' });
350
484
  child.unref();
@@ -364,18 +498,20 @@ function launchInTerminal(bin, args, workDir, envVars) {
364
498
  // Ensure our profile with icon exists in Windows Terminal settings
365
499
  const hasProfile = ensureWindowsTerminalProfile();
366
500
  // Build inline env var assignments for cmd /c since wt.exe spawns
367
- // a new process that won't inherit our env.
368
- // Escape values for cmd.exe: wrap in quotes and escape inner special chars.
369
- const cmdEscape = (s) => s.replace(/([&|<>^"%])/g, '^$1');
370
- const setCmds = Object.entries(envVars)
501
+ // a new process that won't inherit our env. (cmdEscape is module-level.)
502
+ // #689 SECRET env goes to a sourced 0600 .cmd file (call + del before the
503
+ // bin runs) so JWTs never land in the wt.exe command / cmd history; PLAIN env
504
+ // stays inline as `set "K=v"`.
505
+ const { plainEnv, secretEnv } = partitionEnv(envVars);
506
+ const secretFile = writeSecretEnvFile(secretEnv, { syntax: 'cmd' });
507
+ const setCmds = Object.entries(plainEnv)
371
508
  .map(([k, v]) => `set "${k}=${cmdEscape(v)}"`)
372
509
  .join(' && ');
373
510
  // Quote the binary path if it contains spaces (e.g., "C:\Program Files\...")
374
511
  const quotedWinBin = claudeBin.includes(' ') ? `"${cmdEscape(claudeBin)}"` : cmdEscape(claudeBin);
375
512
  const claudeCmd = `${quotedWinBin} ${claudeArgs.map(a => `"${cmdEscape(a)}"`).join(' ')}`;
376
- const innerCmd = setCmds
377
- ? `${setCmds} && ${claudeCmd}`
378
- : claudeCmd;
513
+ const inlinePart = setCmds ? `${setCmds} && ${claudeCmd}` : claudeCmd;
514
+ const innerCmd = `${secretFile.sourcePrefix}${inlinePart}`;
379
515
  // Use `cmd.exe /c start "" wt.exe ...` to resolve the UWP app alias
380
516
  // When our profile exists, use --profile to get the tab icon
381
517
  const wtArgs = [
@@ -404,11 +540,19 @@ function launchInTerminal(bin, args, workDir, envVars) {
404
540
  child.unref();
405
541
  return { pid: child.pid };
406
542
  }
407
- // Linux
408
- const envExports = Object.entries(envVars)
543
+ // Linux — #689: SECRET env → sourced 0600 file (source+rm before the bin); PLAIN
544
+ // env stays inline `export`. One fullCmd covers both the terminal `-e` path and
545
+ // the headless `bash -c` fallback below (also closes the gnome-terminal-server
546
+ // env-inheritance gap for free).
547
+ const { plainEnv, secretEnv } = partitionEnv(envVars);
548
+ const secretFile = writeSecretEnvFile(secretEnv, { syntax: 'posix' });
549
+ const secretSource = secretFile.path
550
+ ? `source ${shellQuote(secretFile.path)}; rm -f ${shellQuote(secretFile.path)}; `
551
+ : '';
552
+ const plainExports = Object.entries(plainEnv)
409
553
  .map(([k, v]) => `export ${k}=${shellQuote(v)}`)
410
554
  .join('; ');
411
- const fullCmd = `${envExports}; cd ${shellQuote(workDir)} && ${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`;
555
+ const fullCmd = `${secretSource}${plainExports ? `${plainExports}; ` : ''}cd ${shellQuote(workDir)} && ${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`;
412
556
  const terminal = findLinuxTerminal();
413
557
  if (!terminal) {
414
558
  log('No terminal emulator found on Linux, falling back to headless spawn');
@@ -549,10 +693,9 @@ function resolveBridgePath() {
549
693
  */
550
694
  function spawnCopilotBridge(opts) {
551
695
  const { cmd, args } = resolveBridgePath();
552
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
553
696
  const logName = opts.name || `copilot-${Date.now()}`;
554
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
555
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
697
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
698
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
556
699
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
557
700
  const logFd = (0, fs_1.openSync)(logPath, 'a');
558
701
  let child;
@@ -564,6 +707,7 @@ function spawnCopilotBridge(opts) {
564
707
  env: {
565
708
  ...process.env,
566
709
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
710
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
567
711
  [config_1.ENV.BRIDGE_NAME]: opts.name,
568
712
  [config_1.ENV.PLAYER_NAME]: '', // Clear parent's player name so child uses BRIDGE_NAME
569
713
  [config_1.ENV.BRIDGE_MODE]: '', // Clear parent's bridge mode
@@ -614,10 +758,9 @@ function resolveMockAdapterPath() {
614
758
  */
615
759
  function spawnMockAdapter(opts) {
616
760
  const { cmd, args } = resolveMockAdapterPath();
617
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
618
761
  const logName = opts.name || `mock-${Date.now()}`;
619
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
620
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
762
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
763
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
621
764
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
622
765
  const logFd = (0, fs_1.openSync)(logPath, 'a');
623
766
  let child;
@@ -629,6 +772,7 @@ function spawnMockAdapter(opts) {
629
772
  env: {
630
773
  ...process.env,
631
774
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
775
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
632
776
  [config_1.ENV.PLAYER_NAME]: opts.name,
633
777
  [config_1.ENV.CONDUCTOR]: opts.isConductor ? 'true' : '',
634
778
  [config_1.ENV.TEMPORAL_ADDRESS]: opts.temporalAddress,
@@ -682,10 +826,9 @@ function resolveClaudeApiPath() {
682
826
  */
683
827
  function spawnClaudeApiAdapter(opts) {
684
828
  const { cmd, args } = resolveClaudeApiPath();
685
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
686
829
  const logName = opts.name || `claude-api-${Date.now()}`;
687
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
688
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
830
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
831
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
689
832
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
690
833
  const logFd = (0, fs_1.openSync)(logPath, 'a');
691
834
  let child;
@@ -697,6 +840,7 @@ function spawnClaudeApiAdapter(opts) {
697
840
  env: {
698
841
  ...process.env,
699
842
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
843
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
700
844
  [config_1.ENV.PLAYER_NAME]: opts.name,
701
845
  [config_1.ENV.CONDUCTOR]: opts.isConductor ? 'true' : '',
702
846
  [config_1.ENV.TEMPORAL_ADDRESS]: opts.temporalAddress,
@@ -749,10 +893,9 @@ function resolveOpenCodePath() {
749
893
  */
750
894
  function spawnOpenCodeAdapter(opts) {
751
895
  const { cmd, args } = resolveOpenCodePath();
752
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
753
896
  const logName = opts.name || `opencode-${Date.now()}`;
754
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
755
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
897
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
898
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
756
899
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
757
900
  const logFd = (0, fs_1.openSync)(logPath, 'a');
758
901
  let child;
@@ -764,6 +907,7 @@ function spawnOpenCodeAdapter(opts) {
764
907
  env: {
765
908
  ...process.env,
766
909
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
910
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
767
911
  [config_1.ENV.PLAYER_NAME]: opts.name,
768
912
  [config_1.ENV.CONDUCTOR]: opts.isConductor ? 'true' : '',
769
913
  [config_1.ENV.TEMPORAL_ADDRESS]: opts.temporalAddress,
@@ -811,10 +955,9 @@ function resolvePiPath() {
811
955
  */
812
956
  function spawnPiHeadless(opts) {
813
957
  const { cmd, args } = resolvePiPath();
814
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
815
958
  const logName = opts.name || `pi-${Date.now()}`;
816
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
817
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
959
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
960
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
818
961
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
819
962
  const logFd = (0, fs_1.openSync)(logPath, 'a');
820
963
  const toolAccess = opts.toolAccess || 'restricted';
@@ -827,6 +970,7 @@ function spawnPiHeadless(opts) {
827
970
  env: {
828
971
  ...process.env,
829
972
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
973
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
830
974
  [config_1.ENV.PLAYER_NAME]: opts.name,
831
975
  [config_1.ENV.CONDUCTOR]: opts.isConductor ? 'true' : '',
832
976
  [config_1.ENV.TEMPORAL_ADDRESS]: opts.temporalAddress,
@@ -890,10 +1034,9 @@ function resolveClaudeCodeHeadlessPath() {
890
1034
  */
891
1035
  function spawnClaudeCodeHeadlessAdapter(opts) {
892
1036
  const { cmd, args } = resolveClaudeCodeHeadlessPath();
893
- const logDirPath = opts.logDir || (0, path_1.join)(opts.workDir, 'logs');
894
1037
  const logName = opts.name || `claude-code-headless-${Date.now()}`;
895
- const logPath = (0, path_1.join)(logDirPath, `${logName}.log`);
896
- const pidPath = (0, path_1.join)(logDirPath, `${logName}.pid`);
1038
+ // #690 central ~/.agent-tempo/logs/<ensemble>/ (overrideDir = opts.logDir wins).
1039
+ const { dir: logDirPath, logPath, pidPath } = (0, config_1.bridgeLogPaths)(opts.ensemble, logName, opts.logDir);
897
1040
  (0, fs_1.mkdirSync)(logDirPath, { recursive: true });
898
1041
  const logFd = (0, fs_1.openSync)(logPath, 'a');
899
1042
  let child;
@@ -905,6 +1048,7 @@ function spawnClaudeCodeHeadlessAdapter(opts) {
905
1048
  env: {
906
1049
  ...process.env,
907
1050
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
1051
+ [config_1.ENV.PID_FILE]: pidPath, // #690 — adapter writes/unlinks THIS exact path (no re-derive → no split-brain)
908
1052
  [config_1.ENV.PLAYER_NAME]: opts.name,
909
1053
  [config_1.ENV.CONDUCTOR]: opts.isConductor ? 'true' : '',
910
1054
  [config_1.ENV.TEMPORAL_ADDRESS]: opts.temporalAddress,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared secret-classification — the single source of truth for "does this config
3
+ * key / env-var name hold a credential?" Used by:
4
+ * - `cli/config-command.ts` — masks secret VALUES in the config display (#684).
5
+ * - `spawn.ts` — partitions terminal-launch env so secret VALUES go to a 0600
6
+ * file instead of being inlined into the echoed command (#689).
7
+ *
8
+ * Lives in `utils/` (not `cli/`) so `spawn.ts` can import it without a layering
9
+ * violation (spawn must not depend on the CLI surface). Extracted from
10
+ * config-command.ts in #689 so the two consumers share ONE classifier and can't
11
+ * drift — a secret added to the pattern is masked AND kept off the command line
12
+ * everywhere at once.
13
+ */
14
+ /**
15
+ * Config/env keys that hold a credential value and must never be displayed raw or
16
+ * inlined into a command. Extend this (or {@link SECRET_KEY_PATTERN}) when a new
17
+ * secret config field / env var is introduced — both consumers pick it up.
18
+ */
19
+ export declare const SECRET_KEYS: Set<string>;
20
+ /**
21
+ * Matches credential-bearing key/env names: `*_API_KEY` / `*ApiKey` / `*Token` /
22
+ * `*Secret` / `*Password`. NOT `*Path` fields (those are file LOCATIONS, not the
23
+ * secret material — e.g. `temporalTlsKeyPath` must stay visible); the `path$`
24
+ * guard in {@link isSecretKey} excludes them.
25
+ */
26
+ export declare const SECRET_KEY_PATTERN: RegExp;
27
+ /**
28
+ * True when a config key or environment-variable name holds a credential value
29
+ * that must be masked on display and kept out of an echoed command line.
30
+ *
31
+ * Keys on the NAME, not the value. `*Path` fields are file locations (not secret
32
+ * material) and are explicitly excluded so they stay visible/inline.
33
+ */
34
+ export declare function isSecretKey(key: string): boolean;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Shared secret-classification — the single source of truth for "does this config
4
+ * key / env-var name hold a credential?" Used by:
5
+ * - `cli/config-command.ts` — masks secret VALUES in the config display (#684).
6
+ * - `spawn.ts` — partitions terminal-launch env so secret VALUES go to a 0600
7
+ * file instead of being inlined into the echoed command (#689).
8
+ *
9
+ * Lives in `utils/` (not `cli/`) so `spawn.ts` can import it without a layering
10
+ * violation (spawn must not depend on the CLI surface). Extracted from
11
+ * config-command.ts in #689 so the two consumers share ONE classifier and can't
12
+ * drift — a secret added to the pattern is masked AND kept off the command line
13
+ * everywhere at once.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.SECRET_KEY_PATTERN = exports.SECRET_KEYS = void 0;
17
+ exports.isSecretKey = isSecretKey;
18
+ /**
19
+ * Config/env keys that hold a credential value and must never be displayed raw or
20
+ * inlined into a command. Extend this (or {@link SECRET_KEY_PATTERN}) when a new
21
+ * secret config field / env var is introduced — both consumers pick it up.
22
+ */
23
+ exports.SECRET_KEYS = new Set([
24
+ 'temporalApiKey',
25
+ 'httpToken',
26
+ 'readToken',
27
+ 'adminToken',
28
+ ]);
29
+ /**
30
+ * Matches credential-bearing key/env names: `*_API_KEY` / `*ApiKey` / `*Token` /
31
+ * `*Secret` / `*Password`. NOT `*Path` fields (those are file LOCATIONS, not the
32
+ * secret material — e.g. `temporalTlsKeyPath` must stay visible); the `path$`
33
+ * guard in {@link isSecretKey} excludes them.
34
+ */
35
+ exports.SECRET_KEY_PATTERN = /(api[_-]?key|token|secret|password)/i;
36
+ /**
37
+ * True when a config key or environment-variable name holds a credential value
38
+ * that must be masked on display and kept out of an echoed command line.
39
+ *
40
+ * Keys on the NAME, not the value. `*Path` fields are file locations (not secret
41
+ * material) and are explicitly excluded so they stay visible/inline.
42
+ */
43
+ function isSecretKey(key) {
44
+ if (/path$/i.test(key))
45
+ return false;
46
+ return exports.SECRET_KEYS.has(key) || exports.SECRET_KEY_PATTERN.test(key);
47
+ }
@@ -57,6 +57,18 @@ You are a combination of Product Manager, Task Decomposition Expert, and Context
57
57
  - **Wrap-up**: Collect final reports, synthesize results, `detach` players who may be needed again (or `destroy` those who are truly done), report completion.
58
58
  - **Autonomous work session**: Pre-flight (check ensemble state — skip if active work is in progress) → review backlog → close completed items → identify tasks your ensemble can handle autonomously (flag those needing human design input) → kick off, track to completion, summarize results.
59
59
 
60
+ ## Message Delivery Model
61
+
62
+ Understanding how cues are delivered prevents the most common conductor anti-pattern — busy-waiting for replies.
63
+
64
+ **Cues wake you; you don't need to poll.** After cueing a player and expecting a reply, end your turn. When the reply arrives, the runtime wakes you automatically at the next turn boundary. There is nothing to poll.
65
+
66
+ **`listen` is a one-shot inbox drain, not a wait primitive.** `listen` reads whatever messages are already queued at call time — it cannot block or wait for future messages. A `sleep`+`listen` loop does not work as a wait: it burns tokens across every player in the ensemble without advancing any work. If you're waiting for a reply, end your turn.
67
+
68
+ **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 — your reply wakes them, they acknowledge, you're awake again — that wastes turns on both sides for zero information transfer.
69
+
70
+ **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 rather than starting a separate turn for each.
71
+
60
72
  ## Worktree Coordination
61
73
 
62
74
  Use the `worktree` tool to give players isolated git checkouts when two or more engineers need to work in the same repo on different branches simultaneously. Each worktree is an independent checkout — players can build, test, and commit without interfering with each other.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.6.1",
3
+ "version": "1.7.0-beta.0",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",