@phnx-labs/agents-cli 1.20.62 → 1.20.64
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/CHANGELOG.md +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- package/package.json +1 -1
package/dist/lib/runner.js
CHANGED
|
@@ -44,17 +44,20 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
44
44
|
const cmd = ['agents', 'run', config.workflow, resolvedPrompt, '--mode', config.mode];
|
|
45
45
|
return cmd;
|
|
46
46
|
}
|
|
47
|
+
// Past the workflow branch this is an agent (or resume) job — command jobs never
|
|
48
|
+
// reach buildJobCommand (execute*Job branches out first), and validateJob guarantees agent.
|
|
49
|
+
const agent = config.agent;
|
|
47
50
|
// Resume branch: reopen an EXISTING session via `agents run <agent> --resume <id>`
|
|
48
51
|
// instead of starting fresh. The real session resumes with its full prior context
|
|
49
52
|
// (index-based lookup, cwd-independent) and `resolvedPrompt` becomes its next turn —
|
|
50
53
|
// so a self-scheduled wake (e.g. /hibernate) is handled by the session that scheduled
|
|
51
54
|
// it, not a fresh, context-less agent that would refuse an "opaque" instruction.
|
|
52
55
|
if (config.resume) {
|
|
53
|
-
return ['agents', 'run',
|
|
56
|
+
return ['agents', 'run', agent, '--resume', config.resume, resolvedPrompt, '--mode', config.mode];
|
|
54
57
|
}
|
|
55
|
-
const template = AGENT_COMMANDS[
|
|
58
|
+
const template = AGENT_COMMANDS[agent];
|
|
56
59
|
if (!template) {
|
|
57
|
-
throw new Error(`Unsupported agent for daemon jobs: ${
|
|
60
|
+
throw new Error(`Unsupported agent for daemon jobs: ${agent}`);
|
|
58
61
|
}
|
|
59
62
|
let cmd = template.map((part) => part.replace('{prompt}', resolvedPrompt));
|
|
60
63
|
// Canonicalize mode (accepts legacy `full` as alias for `skip`).
|
|
@@ -155,10 +158,12 @@ export function buildJobCommand(config, resolvedPrompt) {
|
|
|
155
158
|
* Reasoning level (config.config.reasoning) maps to per-agent flags via models.ts.
|
|
156
159
|
*/
|
|
157
160
|
function appendModelAndReasoning(cmd, config) {
|
|
161
|
+
// Only called from buildJobCommand's agent path — config.agent is set.
|
|
162
|
+
const agent = config.agent;
|
|
158
163
|
const model = config.config?.model;
|
|
159
164
|
if (model) {
|
|
160
165
|
if (config.version) {
|
|
161
|
-
const resolved = resolveModel(
|
|
166
|
+
const resolved = resolveModel(agent, config.version, model);
|
|
162
167
|
if (resolved.warning) {
|
|
163
168
|
process.stderr.write(`[agents] ${resolved.warning}\n`);
|
|
164
169
|
}
|
|
@@ -170,7 +175,7 @@ function appendModelAndReasoning(cmd, config) {
|
|
|
170
175
|
}
|
|
171
176
|
const reasoning = config.config?.reasoning;
|
|
172
177
|
if (reasoning) {
|
|
173
|
-
const flags = buildReasoningFlags(
|
|
178
|
+
const flags = buildReasoningFlags(agent, reasoning);
|
|
174
179
|
if (flags.length > 0)
|
|
175
180
|
cmd.push(...flags);
|
|
176
181
|
}
|
|
@@ -178,6 +183,48 @@ function appendModelAndReasoning(cmd, config) {
|
|
|
178
183
|
function generateRunId() {
|
|
179
184
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
180
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Build the argv for a command-mode routine: run the shell string directly
|
|
188
|
+
* through the platform shell. No agent binary, no rotation, no sandbox.
|
|
189
|
+
*/
|
|
190
|
+
function buildShellCommand(command) {
|
|
191
|
+
return process.platform === 'win32'
|
|
192
|
+
? ['cmd', '/c', command]
|
|
193
|
+
: ['/bin/sh', '-c', command];
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Real (un-sandboxed) environment for a command routine. Command routines do
|
|
197
|
+
* `npm i -g` / `git pull` and need the actual $HOME / $PATH, not the sandbox
|
|
198
|
+
* overlay. Only TZ is injected when the routine pins a timezone.
|
|
199
|
+
*/
|
|
200
|
+
function commandSpawnEnv(config) {
|
|
201
|
+
const env = { ...process.env };
|
|
202
|
+
if (config.timezone)
|
|
203
|
+
env.TZ = config.timezone;
|
|
204
|
+
return env;
|
|
205
|
+
}
|
|
206
|
+
/** POSIX single-quote a string so it is safe to embed in a `/bin/sh -c` script. */
|
|
207
|
+
function shSingleQuote(s) {
|
|
208
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Detached command routines write their own exit code to `<runDir>/exit-code`
|
|
212
|
+
* (see the wrapper in executeCommandJobDetached). `monitorRunningJobs` reads it
|
|
213
|
+
* to recover the true terminal status when the daemon restarted between spawn and
|
|
214
|
+
* exit and so missed the in-process `child.on('exit')`. Returns null when the
|
|
215
|
+
* file is absent/unparseable (child killed or crashed before writing it).
|
|
216
|
+
*/
|
|
217
|
+
function readCommandExitCode(runDir) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(path.join(runDir, 'exit-code'), 'utf-8').trim();
|
|
220
|
+
if (!/^-?\d+$/.test(raw))
|
|
221
|
+
return null;
|
|
222
|
+
return parseInt(raw, 10);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
181
228
|
/**
|
|
182
229
|
* Resolve the version/account chain for a routine the same way `agents run`
|
|
183
230
|
* does: honor an explicit `version:` pin; otherwise use the configured run
|
|
@@ -191,6 +238,8 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
191
238
|
if (config.workflow) {
|
|
192
239
|
return { chain: [], rotation: null, pinned: false };
|
|
193
240
|
}
|
|
241
|
+
// resolveRoutineLaunch is only called for agent jobs (workflow returns above;
|
|
242
|
+
// command jobs branch out of execute*Job before reaching this).
|
|
194
243
|
const agent = config.agent;
|
|
195
244
|
if (config.version) {
|
|
196
245
|
const version = config.version;
|
|
@@ -399,6 +448,12 @@ export async function executeJob(config, deps) {
|
|
|
399
448
|
if (eligibility) {
|
|
400
449
|
throw new Error(eligibility.message);
|
|
401
450
|
}
|
|
451
|
+
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
452
|
+
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
453
|
+
// list/runs/overdue keep working.
|
|
454
|
+
if (config.command) {
|
|
455
|
+
return executeCommandJobForeground(config);
|
|
456
|
+
}
|
|
402
457
|
maybeRotate();
|
|
403
458
|
const launch = await resolveRoutineLaunch(config);
|
|
404
459
|
const primaryVersion = launch.chain[0]?.version ?? config.version;
|
|
@@ -425,6 +480,7 @@ export async function executeJob(config, deps) {
|
|
|
425
480
|
: { ...process.env };
|
|
426
481
|
// Workflows run via `agents run <workflow>` which delegates to claude under the hood.
|
|
427
482
|
// Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
|
|
483
|
+
// (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
|
|
428
484
|
const effectiveAgent = config.workflow ? 'claude' : config.agent;
|
|
429
485
|
const meta = {
|
|
430
486
|
jobName: config.name,
|
|
@@ -561,6 +617,100 @@ export async function executeJob(config, deps) {
|
|
|
561
617
|
timer.end({ status: 'failed', exitCode: 1, runId });
|
|
562
618
|
return { meta, reportPath: null };
|
|
563
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Foreground execution for a command-mode routine (`config.command`). Runs a
|
|
622
|
+
* plain shell command in the REAL environment (no sandbox), captures stdout+
|
|
623
|
+
* stderr to `stdout.log`, awaits completion, and honors `config.timeout` with
|
|
624
|
+
* the same SIGTERM→SIGKILL kill mechanism the agent path uses. No agent is
|
|
625
|
+
* spawned; the run record carries `command` instead of `agent`. There is no
|
|
626
|
+
* agent report to extract, so `reportPath` is always null (the stdout log is
|
|
627
|
+
* the artifact — same location the agent path writes).
|
|
628
|
+
*/
|
|
629
|
+
async function executeCommandJobForeground(config) {
|
|
630
|
+
const timer = createTimer('agent.run', {
|
|
631
|
+
jobName: config.name,
|
|
632
|
+
mode: config.mode,
|
|
633
|
+
schedule: config.schedule,
|
|
634
|
+
});
|
|
635
|
+
const runId = generateRunId();
|
|
636
|
+
const runDir = getRunDir(config.name, runId);
|
|
637
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
638
|
+
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
639
|
+
const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
|
|
640
|
+
const meta = {
|
|
641
|
+
jobName: config.name,
|
|
642
|
+
runId,
|
|
643
|
+
command: config.command,
|
|
644
|
+
pid: null,
|
|
645
|
+
spawnedAt: Date.now(),
|
|
646
|
+
status: 'running',
|
|
647
|
+
startedAt: new Date().toISOString(),
|
|
648
|
+
completedAt: null,
|
|
649
|
+
exitCode: null,
|
|
650
|
+
};
|
|
651
|
+
writeRunMeta(meta);
|
|
652
|
+
const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
|
|
653
|
+
const cmd = buildShellCommand(config.command);
|
|
654
|
+
const env = commandSpawnEnv(config);
|
|
655
|
+
process.stderr.write(`[agents] routine ${config.name}: running command\n`);
|
|
656
|
+
const result = await new Promise((resolve) => {
|
|
657
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
658
|
+
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
659
|
+
...backgroundSpawnOptions({ fdStdio: true }),
|
|
660
|
+
env,
|
|
661
|
+
});
|
|
662
|
+
meta.pid = child.pid || null;
|
|
663
|
+
writeRunMeta(meta);
|
|
664
|
+
let settled = false;
|
|
665
|
+
const finish = (r) => {
|
|
666
|
+
if (settled)
|
|
667
|
+
return;
|
|
668
|
+
settled = true;
|
|
669
|
+
try {
|
|
670
|
+
fs.closeSync(stdoutFd);
|
|
671
|
+
}
|
|
672
|
+
catch { /* fd already closed */ }
|
|
673
|
+
resolve(r);
|
|
674
|
+
};
|
|
675
|
+
const timeoutTimer = setTimeout(() => {
|
|
676
|
+
try {
|
|
677
|
+
if (child.pid)
|
|
678
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
679
|
+
}
|
|
680
|
+
catch { /* process already exited */ }
|
|
681
|
+
setTimeout(() => {
|
|
682
|
+
try {
|
|
683
|
+
if (child.pid)
|
|
684
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
685
|
+
}
|
|
686
|
+
catch { /* process already exited */ }
|
|
687
|
+
}, 5000);
|
|
688
|
+
finish({ exitCode: null, status: 'timeout' });
|
|
689
|
+
}, timeoutMs);
|
|
690
|
+
child.on('exit', (code) => {
|
|
691
|
+
clearTimeout(timeoutTimer);
|
|
692
|
+
finish({ exitCode: code, status: code === 0 ? 'completed' : 'failed' });
|
|
693
|
+
});
|
|
694
|
+
child.on('error', (err) => {
|
|
695
|
+
clearTimeout(timeoutTimer);
|
|
696
|
+
finish({ exitCode: 1, status: 'failed', error: err.message });
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
meta.status = result.status;
|
|
700
|
+
meta.exitCode = result.exitCode ?? (result.status === 'completed' ? 0 : 1);
|
|
701
|
+
meta.completedAt = new Date().toISOString();
|
|
702
|
+
writeRunMeta(meta);
|
|
703
|
+
if (result.error) {
|
|
704
|
+
process.stderr.write(`[agents] routine ${config.name}: command spawn failed: ${result.error}\n`);
|
|
705
|
+
}
|
|
706
|
+
timer.end({
|
|
707
|
+
status: meta.status,
|
|
708
|
+
exitCode: meta.exitCode ?? undefined,
|
|
709
|
+
runId,
|
|
710
|
+
...(result.error ? { error: result.error } : {}),
|
|
711
|
+
});
|
|
712
|
+
return { meta, reportPath: null };
|
|
713
|
+
}
|
|
564
714
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
565
715
|
export async function executeJobDetached(config) {
|
|
566
716
|
const eligibility = checkJobDeviceEligibility(config);
|
|
@@ -568,6 +718,12 @@ export async function executeJobDetached(config) {
|
|
|
568
718
|
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
569
719
|
throw new Error(eligibility.message);
|
|
570
720
|
}
|
|
721
|
+
// Command-mode: fire a plain shell command detached (no agent, no rotation,
|
|
722
|
+
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
723
|
+
// list/runs, and overdue tracking keep working.
|
|
724
|
+
if (config.command) {
|
|
725
|
+
return executeCommandJobDetached(config);
|
|
726
|
+
}
|
|
571
727
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
572
728
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
573
729
|
// wait); the next schedule tick re-selects if this attempt still fails.
|
|
@@ -577,7 +733,7 @@ export async function executeJobDetached(config) {
|
|
|
577
733
|
let cmd = buildJobCommand(config, resolvedPrompt);
|
|
578
734
|
// workflow AND resume dispatch through `agents run` — never binary-pin them (pinning
|
|
579
735
|
// rewrites cmd[0] to the agent binary → broken `<binary> run …`).
|
|
580
|
-
if (!dispatchesViaAgentsRun(config) && version) {
|
|
736
|
+
if (!dispatchesViaAgentsRun(config) && version && config.agent) {
|
|
581
737
|
cmd = pinJobBinary(cmd, config.agent, version);
|
|
582
738
|
}
|
|
583
739
|
// Resume must run against the REAL home: `--resume <id>` resolves the session from
|
|
@@ -601,6 +757,7 @@ export async function executeJobDetached(config) {
|
|
|
601
757
|
e.TZ = config.timezone;
|
|
602
758
|
return e;
|
|
603
759
|
})()
|
|
760
|
+
// Non-command path only: config.agent is always set here (command/workflow branch earlier).
|
|
604
761
|
: buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone);
|
|
605
762
|
const effectiveAgent = config.workflow ? 'claude' : config.agent;
|
|
606
763
|
const meta = {
|
|
@@ -640,6 +797,74 @@ export async function executeJobDetached(config) {
|
|
|
640
797
|
writeRunMeta(meta);
|
|
641
798
|
return meta;
|
|
642
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* Detached (fire-and-forget) execution for a command-mode routine. Mirrors the
|
|
802
|
+
* agent detached flow: write an initial running record, spawn the shell command
|
|
803
|
+
* un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
|
|
804
|
+
* `monitorRunningJobs` reaps the record on the next tick.
|
|
805
|
+
*/
|
|
806
|
+
function executeCommandJobDetached(config) {
|
|
807
|
+
const runId = generateRunId();
|
|
808
|
+
const runDir = getRunDir(config.name, runId);
|
|
809
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
810
|
+
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
811
|
+
const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
|
|
812
|
+
// Wrap the shell so the child records its own exit code to <runDir>/exit-code.
|
|
813
|
+
// The in-process `child.on('exit')` below writes the terminal record while the
|
|
814
|
+
// daemon is alive (the common case); the file lets monitorRunningJobs recover
|
|
815
|
+
// the real status if the daemon restarted between spawn and exit. (win32 relies
|
|
816
|
+
// on the exit event only.)
|
|
817
|
+
const exitCodePath = path.join(runDir, 'exit-code');
|
|
818
|
+
// Run the command in a SUBSHELL `( … )` so that if it calls `exit`, only the
|
|
819
|
+
// subshell exits — the outer shell still captures `$?` and writes the file.
|
|
820
|
+
const cmd = process.platform === 'win32'
|
|
821
|
+
? buildShellCommand(config.command)
|
|
822
|
+
: ['/bin/sh', '-c',
|
|
823
|
+
`(\n${config.command}\n)\n__ac_rc=$?; printf '%s' "$__ac_rc" > ${shSingleQuote(exitCodePath)} 2>/dev/null; exit $__ac_rc`];
|
|
824
|
+
const env = commandSpawnEnv(config);
|
|
825
|
+
const meta = {
|
|
826
|
+
jobName: config.name,
|
|
827
|
+
runId,
|
|
828
|
+
command: config.command,
|
|
829
|
+
pid: null,
|
|
830
|
+
spawnedAt: Date.now(),
|
|
831
|
+
status: 'running',
|
|
832
|
+
startedAt: new Date().toISOString(),
|
|
833
|
+
completedAt: null,
|
|
834
|
+
exitCode: null,
|
|
835
|
+
};
|
|
836
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
837
|
+
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
838
|
+
...backgroundSpawnOptions({ fdStdio: true }),
|
|
839
|
+
env,
|
|
840
|
+
});
|
|
841
|
+
// Record the real terminal status ourselves — the daemon stays alive after this
|
|
842
|
+
// fire-and-forget call, so the exit event fires here. (monitorRunningJobs no
|
|
843
|
+
// longer force-fails command jobs; it reads exit-code only on the restart edge.)
|
|
844
|
+
let settled = false;
|
|
845
|
+
const settle = (status, exitCode) => {
|
|
846
|
+
if (settled)
|
|
847
|
+
return;
|
|
848
|
+
settled = true;
|
|
849
|
+
meta.status = status;
|
|
850
|
+
meta.exitCode = exitCode;
|
|
851
|
+
meta.completedAt = new Date().toISOString();
|
|
852
|
+
writeRunMeta(meta);
|
|
853
|
+
};
|
|
854
|
+
child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
|
|
855
|
+
child.on('error', (err) => {
|
|
856
|
+
settle('failed', 1);
|
|
857
|
+
process.stderr.write(`[agents] daemon: command spawn failed for job "${config.name}": ${err.message}\n`);
|
|
858
|
+
});
|
|
859
|
+
child.unref();
|
|
860
|
+
try {
|
|
861
|
+
fs.closeSync(stdoutFd);
|
|
862
|
+
}
|
|
863
|
+
catch { /* fd already closed */ }
|
|
864
|
+
meta.pid = child.pid || null;
|
|
865
|
+
writeRunMeta(meta);
|
|
866
|
+
return meta;
|
|
867
|
+
}
|
|
643
868
|
function extractAndSaveReport(stdoutPath, agentType, runDir) {
|
|
644
869
|
try {
|
|
645
870
|
const report = extractReport(stdoutPath, agentType);
|
|
@@ -792,26 +1017,43 @@ export function monitorRunningJobs() {
|
|
|
792
1017
|
continue;
|
|
793
1018
|
const runDirPath = path.join(jobRunsPath, runDirEntry.name);
|
|
794
1019
|
const stdoutPath = path.join(runDirPath, 'stdout.log');
|
|
1020
|
+
// Command-mode records carry no agent; there is no stream-json report to
|
|
1021
|
+
// parse or extract. Reap them on pid liveness alone.
|
|
1022
|
+
const isCommandRun = Boolean(meta.command) || !meta.agent;
|
|
795
1023
|
const wallClockMs = Date.now() - Date.parse(meta.startedAt);
|
|
796
1024
|
if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
|
|
797
1025
|
meta.status = 'timeout';
|
|
798
1026
|
meta.completedAt = new Date().toISOString();
|
|
799
1027
|
writeRunMeta(meta);
|
|
800
|
-
|
|
1028
|
+
if (!isCommandRun)
|
|
1029
|
+
extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
|
|
801
1030
|
continue;
|
|
802
1031
|
}
|
|
803
1032
|
if (!isPidOurs(meta.pid, meta.spawnedAt)) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1033
|
+
if (isCommandRun) {
|
|
1034
|
+
// Command routines normally record their own terminal status via
|
|
1035
|
+
// child.on('exit') (so this record would already be non-'running' and
|
|
1036
|
+
// skipped above). Reaching here means the daemon restarted mid-run and
|
|
1037
|
+
// missed the exit event — recover the true code from the exit-code file
|
|
1038
|
+
// the child wrote; its absence means the child was killed/crashed.
|
|
1039
|
+
const ec = readCommandExitCode(runDirPath);
|
|
1040
|
+
meta.status = ec === 0 ? 'completed' : 'failed';
|
|
1041
|
+
meta.exitCode = ec;
|
|
808
1042
|
}
|
|
809
1043
|
else {
|
|
810
|
-
|
|
1044
|
+
const inferred = inferFinalStatusFromLog(stdoutPath, meta.agent);
|
|
1045
|
+
if (inferred) {
|
|
1046
|
+
meta.status = inferred.status;
|
|
1047
|
+
meta.exitCode = inferred.exitCode;
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
meta.status = 'failed';
|
|
1051
|
+
}
|
|
811
1052
|
}
|
|
812
1053
|
meta.completedAt = new Date().toISOString();
|
|
813
1054
|
writeRunMeta(meta);
|
|
814
|
-
|
|
1055
|
+
if (!isCommandRun)
|
|
1056
|
+
extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
|
|
815
1057
|
}
|
|
816
1058
|
}
|
|
817
1059
|
catch { /* corrupt or unreadable meta.json */ }
|
package/dist/lib/sandbox.d.ts
CHANGED
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
import type { JobConfig } from './routines.js';
|
|
10
10
|
/** Build a restricted environment for a sandboxed process, setting HOME to the overlay. */
|
|
11
11
|
export declare function buildSpawnEnv(overlayHome: string, extraEnv?: Record<string, string>): Record<string, string>;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Get the overlay HOME directory path for a named job.
|
|
14
|
+
*
|
|
15
|
+
* The job name originates from routine YAML (`name:` field or file basename),
|
|
16
|
+
* which can arrive from a synced user/system config repo. `safeJoin` contains
|
|
17
|
+
* it to a single segment beneath the routines dir so a crafted name such as
|
|
18
|
+
* `../../../..` cannot steer `prepareJobHome`/`cleanJobHome` (which does a
|
|
19
|
+
* recursive `rmSync`) at a path outside `~/.agents/routines`.
|
|
20
|
+
*/
|
|
13
21
|
export declare function getJobHomePath(name: string): string;
|
|
14
22
|
/** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
|
|
15
23
|
export declare function prepareJobHome(config: JobConfig): string;
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as path from 'path';
|
|
|
11
11
|
import * as os from 'os';
|
|
12
12
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
|
|
13
13
|
import { getRoutinesDir, getUserAgentsDir } from './state.js';
|
|
14
|
+
import { safeJoin } from './paths.js';
|
|
14
15
|
import { createLink } from './platform/index.js';
|
|
15
16
|
function resolveRealHome() {
|
|
16
17
|
const home = os.homedir();
|
|
@@ -79,9 +80,17 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
|
|
|
79
80
|
}
|
|
80
81
|
return env;
|
|
81
82
|
}
|
|
82
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Get the overlay HOME directory path for a named job.
|
|
85
|
+
*
|
|
86
|
+
* The job name originates from routine YAML (`name:` field or file basename),
|
|
87
|
+
* which can arrive from a synced user/system config repo. `safeJoin` contains
|
|
88
|
+
* it to a single segment beneath the routines dir so a crafted name such as
|
|
89
|
+
* `../../../..` cannot steer `prepareJobHome`/`cleanJobHome` (which does a
|
|
90
|
+
* recursive `rmSync`) at a path outside `~/.agents/routines`.
|
|
91
|
+
*/
|
|
83
92
|
export function getJobHomePath(name) {
|
|
84
|
-
return path.join(getRoutinesDir(), name, 'home');
|
|
93
|
+
return path.join(safeJoin(getRoutinesDir(), name), 'home');
|
|
85
94
|
}
|
|
86
95
|
/** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
|
|
87
96
|
export function prepareJobHome(config) {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable session bundle — the on-the-wire format behind `agents sessions
|
|
3
|
+
* export` / `import` (RUSH-1710 / RUSH-1711).
|
|
4
|
+
*
|
|
5
|
+
* A bundle is a self-describing NDJSON stream: the FIRST line is a
|
|
6
|
+
* {@link BundleHeader}, every subsequent line is one {@link BundleRecord} (one
|
|
7
|
+
* constituent file of a session). NDJSON — not tar — because the bundle has to
|
|
8
|
+
* pipe cleanly over `agents ssh … export --stdout | … import -` (RUSH-1712)
|
|
9
|
+
* without any external archiver on either box, stays inspectable with `head`,
|
|
10
|
+
* and lets each file body carry its own encryption envelope.
|
|
11
|
+
*
|
|
12
|
+
* This module owns the FORMAT and the import PLACEMENT only; selecting which
|
|
13
|
+
* sessions to export (which needs the session DB) lives in the export command.
|
|
14
|
+
* Placement reuses the sync mirror model verbatim: a foreign machine's session
|
|
15
|
+
* lands at {@link mirrorPath}(spec, originMachine, relKey), exactly where the
|
|
16
|
+
* cross-machine sync writes it — so the existing scanner indexes it as a
|
|
17
|
+
* machine-tagged row and "local always wins" falls out of the scanner's
|
|
18
|
+
* live-home-first dedup with no extra logic here.
|
|
19
|
+
*/
|
|
20
|
+
import { type SyncAgentSpec } from './sync/agents.js';
|
|
21
|
+
export declare const BUNDLE_KIND = "agents-session-bundle";
|
|
22
|
+
export declare const BUNDLE_VERSION = 1;
|
|
23
|
+
/** First line of a bundle: what it is and how the bodies are encoded. */
|
|
24
|
+
export interface BundleHeader {
|
|
25
|
+
kind: typeof BUNDLE_KIND;
|
|
26
|
+
version: number;
|
|
27
|
+
/** ISO timestamp the bundle was produced. */
|
|
28
|
+
exportedAt: string;
|
|
29
|
+
/** Machine that produced the bundle (informational; per-record `machine` is authoritative for placement). */
|
|
30
|
+
origin: string;
|
|
31
|
+
/** True when record bodies are AES-256-GCM envelopes (see transcript-crypto). */
|
|
32
|
+
encrypted: boolean;
|
|
33
|
+
/** True when bodies were secret-scrubbed before hashing/sealing. */
|
|
34
|
+
redacted: boolean;
|
|
35
|
+
/** Number of file records that follow. */
|
|
36
|
+
count: number;
|
|
37
|
+
/** Distinct session count across those records. */
|
|
38
|
+
sessions: number;
|
|
39
|
+
}
|
|
40
|
+
/** One constituent file of one session. Dir-shaped sessions emit several, sharing `sessionId`. */
|
|
41
|
+
export interface BundleRecord {
|
|
42
|
+
/** SYNC_AGENTS id (claude, codex, kimi, …). */
|
|
43
|
+
agent: string;
|
|
44
|
+
/** ORIGIN machine of this session — where placement mirrors it to. */
|
|
45
|
+
machine: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
/** Storage-relative key within the agent's subdir (preserved on import). */
|
|
48
|
+
relKey: string;
|
|
49
|
+
/** Byte length of the plaintext body. */
|
|
50
|
+
size: number;
|
|
51
|
+
/** SHA-256 of the plaintext body — identity + byte-exact dedup. */
|
|
52
|
+
hash: string;
|
|
53
|
+
/** Human label carried from SessionMeta, if any. */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** True when `body` is an encryption envelope rather than plaintext. */
|
|
56
|
+
encrypted: boolean;
|
|
57
|
+
/** File content: plaintext, or a transcript-crypto envelope when `encrypted`. */
|
|
58
|
+
body: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ParsedBundle {
|
|
61
|
+
header: BundleHeader;
|
|
62
|
+
records: BundleRecord[];
|
|
63
|
+
}
|
|
64
|
+
/** A single file selected for export, resolved to an absolute on-disk path. */
|
|
65
|
+
export interface FileToExport {
|
|
66
|
+
agent: string;
|
|
67
|
+
/** Origin machine of the session (self for live-home, the peer for a mirror). */
|
|
68
|
+
machine: string;
|
|
69
|
+
sessionId: string;
|
|
70
|
+
relKey: string;
|
|
71
|
+
absPath: string;
|
|
72
|
+
label?: string;
|
|
73
|
+
}
|
|
74
|
+
export interface BuildRecordOpts {
|
|
75
|
+
/** Scrub secrets from the body before hashing/sealing (default-on at the command layer). */
|
|
76
|
+
redact: boolean;
|
|
77
|
+
/** Non-null → seal each body with this key; null → plaintext bodies. */
|
|
78
|
+
encryptKey: Buffer | null;
|
|
79
|
+
}
|
|
80
|
+
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
|
|
81
|
+
export declare function specForAgent(agentId: string): SyncAgentSpec | undefined;
|
|
82
|
+
/** True when an agent's sessions can be represented in a bundle (has a sync spec). */
|
|
83
|
+
export declare function isExportableAgent(agentId: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Read one file and turn it into a bundle record. The hash and size are always
|
|
86
|
+
* computed over the PLAINTEXT (post-redaction) body, so they equal what lands on
|
|
87
|
+
* disk after import — keeping dedup byte-exact whether or not the bundle is
|
|
88
|
+
* encrypted.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildRecord(file: FileToExport, opts: BuildRecordOpts): BundleRecord;
|
|
91
|
+
/** Build the header for a set of records. */
|
|
92
|
+
export declare function makeHeader(args: {
|
|
93
|
+
origin: string;
|
|
94
|
+
exportedAt: string;
|
|
95
|
+
encrypted: boolean;
|
|
96
|
+
redacted: boolean;
|
|
97
|
+
records: BundleRecord[];
|
|
98
|
+
}): BundleHeader;
|
|
99
|
+
/**
|
|
100
|
+
* Merge record sets from several bundles (e.g. a fan-out pull across hosts),
|
|
101
|
+
* deduping by agent + origin machine + session + file so the same session seen
|
|
102
|
+
* from two peers lands once. First occurrence wins.
|
|
103
|
+
*/
|
|
104
|
+
export declare function mergeRecords(sets: BundleRecord[][]): BundleRecord[];
|
|
105
|
+
/** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
|
|
106
|
+
export declare function serializeBundle(header: BundleHeader, records: BundleRecord[]): string;
|
|
107
|
+
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
|
|
108
|
+
export declare function parseBundle(text: string): ParsedBundle;
|
|
109
|
+
/** Placement outcome for one record, computed against what is already on disk. */
|
|
110
|
+
export type ImportStatus = 'new' | 'dup' | 'conflict' | 'unknown';
|
|
111
|
+
export interface ImportPlanItem {
|
|
112
|
+
record: BundleRecord;
|
|
113
|
+
/** Absolute path the file lands at (empty for `unknown` agents). */
|
|
114
|
+
targetPath: string;
|
|
115
|
+
status: ImportStatus;
|
|
116
|
+
}
|
|
117
|
+
export interface PlanImportOpts {
|
|
118
|
+
/** Key to open encrypted record bodies (null → only plaintext bodies readable). */
|
|
119
|
+
decryptKey: Buffer | null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Compute where each record lands and whether it duplicates / conflicts with an
|
|
123
|
+
* existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
|
|
124
|
+
* byte-exact: a target that already holds an identical body is `dup`; a target
|
|
125
|
+
* that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
|
|
126
|
+
* An agent with no sync spec is `unknown` and never placed.
|
|
127
|
+
*/
|
|
128
|
+
export declare function planImport(bundle: ParsedBundle, opts: PlanImportOpts): ImportPlanItem[];
|
|
129
|
+
export interface WriteResult {
|
|
130
|
+
/** New files written. */
|
|
131
|
+
placed: number;
|
|
132
|
+
/** Byte-exact dups skipped. */
|
|
133
|
+
skipped: number;
|
|
134
|
+
/** Conflicts replaced (only with overwrite). */
|
|
135
|
+
overwritten: number;
|
|
136
|
+
/** Conflicts left in place (overwrite off). */
|
|
137
|
+
conflicts: number;
|
|
138
|
+
/** Records for agents with no sync spec. */
|
|
139
|
+
unknown: number;
|
|
140
|
+
}
|
|
141
|
+
export interface WriteImportOpts {
|
|
142
|
+
overwrite: boolean;
|
|
143
|
+
decryptKey: Buffer | null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Materialize a plan to disk. `dup` records are always skipped (local wins);
|
|
147
|
+
* `conflict` records are replaced only when `overwrite` is set; `unknown` records
|
|
148
|
+
* are counted and skipped.
|
|
149
|
+
*/
|
|
150
|
+
export declare function writeImport(plan: ImportPlanItem[], opts: WriteImportOpts): WriteResult;
|