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/dashboard/package.json +1 -1
- package/dist/activities/hard-terminate.js +8 -2
- package/dist/adapters/claude-api/adapter.js +4 -3
- package/dist/adapters/claude-code-headless/adapter.js +4 -3
- package/dist/adapters/copilot/adapter.js +8 -3
- package/dist/adapters/opencode/adapter.js +3 -1
- package/dist/cli/commands.js +86 -34
- package/dist/cli/config-command.d.ts +14 -0
- package/dist/cli/config-command.js +42 -5
- package/dist/cli/resolve-ensemble.d.ts +17 -0
- package/dist/cli/resolve-ensemble.js +20 -0
- package/dist/cli/sa-preflight.d.ts +8 -0
- package/dist/cli/sa-preflight.js +31 -0
- package/dist/cli.js +5 -1
- package/dist/config.d.ts +63 -3
- package/dist/config.js +72 -12
- package/dist/pi/cue-pump.js +9 -1
- package/dist/spawn.d.ts +49 -3
- package/dist/spawn.js +200 -56
- package/dist/utils/secrets.d.ts +34 -0
- package/dist/utils/secrets.js +47 -0
- package/examples/agents/tempo-conductor.md +12 -0
- package/package.json +1 -1
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
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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 (!
|
|
528
|
+
if (!types_1.AGENT_TYPES.includes(value)) {
|
|
469
529
|
throw new Error(`Invalid agent "${value}" from ${AGENT_SOURCE_LABELS[source]}. ` +
|
|
470
|
-
`Valid values: ${
|
|
530
|
+
`Valid values: ${types_1.AGENT_TYPES.join(', ')}.`);
|
|
471
531
|
}
|
|
472
532
|
return value;
|
|
473
533
|
}
|
package/dist/pi/cue-pump.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
-
*
|
|
239
|
-
*
|
|
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
|
|
242
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
377
|
-
|
|
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
|
-
|
|
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 = `${
|
|
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
|
-
|
|
555
|
-
const pidPath = (0,
|
|
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
|
-
|
|
620
|
-
const pidPath = (0,
|
|
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
|
-
|
|
688
|
-
const pidPath = (0,
|
|
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
|
-
|
|
755
|
-
const pidPath = (0,
|
|
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
|
-
|
|
817
|
-
const pidPath = (0,
|
|
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
|
-
|
|
896
|
-
const pidPath = (0,
|
|
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,
|