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.
- 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 +61 -26
- package/dist/cli/config-command.d.ts +0 -2
- package/dist/cli/config-command.js +6 -15
- package/dist/config.d.ts +51 -0
- package/dist/config.js +57 -0
- package/dist/pi/extension.js +28 -0
- package/dist/pi/pi-types.d.ts +18 -0
- package/dist/server-tools.js +5 -0
- 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/dashboard/package.json
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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(
|
|
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
|
-
|
|
357
|
-
|
|
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(
|
|
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
|
-
|
|
390
|
-
|
|
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(
|
|
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
|
-
|
|
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');
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
1939
|
-
|
|
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
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
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).
|
|
75
|
-
//
|
|
76
|
-
|
|
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`
|
package/dist/pi/extension.js
CHANGED
|
@@ -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
|
package/dist/pi/pi-types.d.ts
CHANGED
|
@@ -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
|
package/dist/server-tools.js
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
@@ -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.
|