agent-tempo 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -23,14 +26,8 @@ const fs_1 = require("fs");
23
26
  const path_1 = require("path");
24
27
  const os_1 = require("os");
25
28
  const zod_1 = require("zod");
29
+ const types_1 = require("./types");
26
30
  const validation_1 = require("./utils/validation");
27
- // `'mock'` is a valid `AgentType` value but intentionally NOT in the resolved
28
- // `defaultAgent` set — recruit pre-flight rejects it outside dev mode anyway,
29
- // and it's never a sensible *default* (each mock spawn is configured per call
30
- // via the `agent: 'mock'` flag, not via the resolved chain). Listing it here
31
- // would only enable users to set `defaultAgent=mock` in `~/.agent-tempo/config.json`,
32
- // which the recruit gate would then turn around and reject in production.
33
- const VALID_AGENTS = ['claude', 'copilot'];
34
31
  /** Environment variable name constants — use these instead of string literals. */
35
32
  exports.ENV = {
36
33
  ENSEMBLE: 'AGENT_TEMPO_ENSEMBLE',
@@ -161,6 +158,15 @@ exports.ENV = {
161
158
  * spawns do NOT set it, so recruited adapters keep the #604 anti-leak ppid-poll.
162
159
  */
163
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',
164
170
  /**
165
171
  * Escape hatch for triple-isolated environments (ADR 0014 §5.3). When
166
172
  * set, `resolveTempoHome()` returns this path verbatim — bypassing both
@@ -229,6 +235,51 @@ function resolveTempoHome() {
229
235
  }
230
236
  exports.AGENT_TEMPO_HOME = resolveTempoHome();
231
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
+ }
232
283
  // ── Daemon config (PR-E design §10.2) ──
233
284
  /**
234
285
  * Daemon-level configuration persisted in `~/.agent-tempo/config.json`
@@ -458,16 +509,25 @@ const AGENT_SOURCE_LABELS = {
458
509
  none: 'none',
459
510
  };
460
511
  /**
461
- * Parse an agent value against the {@link AgentType} union.
462
- * Throws when `value` is present but not a valid agent; returns `'claude'`
463
- * for empty/unset values so callers can use it as a source-aware default.
512
+ * Parse an agent value against the canonical {@link AGENT_TYPES} union — the
513
+ * SINGLE SOURCE OF TRUTH for agent validity (shared with `cli.ts`'s `--agent`
514
+ * parser). Throws when `value` is present but not a known agent; returns
515
+ * `'claude'` for empty/unset values so callers can use it as a source-aware default.
516
+ *
517
+ * This is a pure type-VALIDITY check — it accepts EVERY `AgentType` (including
518
+ * `mock` and the headless adapters). Narrower CAPABILITY constraints are gated
519
+ * separately downstream: the recruit pre-flight rejects `mock` outside dev mode,
520
+ * and `config`'s `VALID_DEFAULT_AGENTS` restricts the persistent default to the
521
+ * conductor-capable subset. (#683: the former hardcoded `['claude','copilot']`
522
+ * list was stale — it rejected `defaultAgent=pi` at config LOAD, poisoning every
523
+ * command before the `--agent` flag was even read.)
464
524
  */
465
525
  function parseAgent(value, source) {
466
526
  if (value == null || value === '')
467
527
  return 'claude';
468
- if (!VALID_AGENTS.includes(value)) {
528
+ if (!types_1.AGENT_TYPES.includes(value)) {
469
529
  throw new Error(`Invalid agent "${value}" from ${AGENT_SOURCE_LABELS[source]}. ` +
470
- `Valid values: ${VALID_AGENTS.join(', ')}.`);
530
+ `Valid values: ${types_1.AGENT_TYPES.join(', ')}.`);
471
531
  }
472
532
  return value;
473
533
  }
@@ -18,7 +18,15 @@ function buildPiInjector(rt) {
18
18
  const sendUser = typeof pi?.sendUserMessage === 'function' ? pi.sendUserMessage.bind(pi) : null;
19
19
  return {
20
20
  inject: (msg, opts) => send(msg, opts),
21
- ...(sendUser ? { escalate: (text) => sendUser(text) } : {}),
21
+ // #688 escalate with `deliverAs: 'followUp'`. maybeEscalate can fire while a
22
+ // turn is ALREADY in flight (one that started BEFORE the inject — a busy
23
+ // false-positive), and a bare sendUserMessage (no deliverAs) while Pi is
24
+ // streaming throws "Agent is already processing". followUp is correct in BOTH
25
+ // cases: cold-idle (behavior ignored → the user message still starts a turn,
26
+ // escalation works) and busy (queues + drains in order, no throw). NOT 'steer'
27
+ // — steer would let a peer cue preempt the operator's in-flight turn, breaking
28
+ // the operator-vs-peer guarantee (see file header).
29
+ ...(sendUser ? { escalate: (text) => sendUser(text, { deliverAs: 'followUp' }) } : {}),
22
30
  lastTurnStartAt: () => rt.lastTurnStartAt,
23
31
  };
24
32
  }
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,