@phnx-labs/agents-cli 1.20.34 → 1.20.36
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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
package/dist/commands/exec.js
CHANGED
|
@@ -10,9 +10,11 @@ import chalk from 'chalk';
|
|
|
10
10
|
import { setHelpSections } from '../lib/help.js';
|
|
11
11
|
import { parseLoopInterval } from '../lib/loop.js';
|
|
12
12
|
import { AGENTS } from '../lib/agents.js';
|
|
13
|
+
import { recordDispatchedRun } from '../lib/audit/log.js';
|
|
13
14
|
import * as fs from 'fs';
|
|
14
15
|
import * as path from 'path';
|
|
15
16
|
import * as os from 'os';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
16
18
|
/** Type guard that narrows a string to a known AgentId. */
|
|
17
19
|
function isValidAgent(agent) {
|
|
18
20
|
return agent in AGENTS;
|
|
@@ -101,6 +103,100 @@ export function loopExitCode(stoppedBy) {
|
|
|
101
103
|
return 1;
|
|
102
104
|
}
|
|
103
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Drive a workflow's declarative `for_each:` fan-out end-to-end (issue #343).
|
|
108
|
+
*
|
|
109
|
+
* Runs the producer, expands one stage teammate per produced item (respecting
|
|
110
|
+
* `max_items` / `DEFAULT_FOR_EACH_CAP`, surfacing any truncation — never a
|
|
111
|
+
* silent cap), stages them into a fresh team via the existing `runForEach`
|
|
112
|
+
* bridge, then drives the teams supervisor until the DAG drains. When a verify
|
|
113
|
+
* panel is declared, tallies each item's `keep_if` gate from the skeptics'
|
|
114
|
+
* terminal status and logs which items survived.
|
|
115
|
+
*
|
|
116
|
+
* Reuses the teams substrate wholesale — no new orchestration engine. Returns
|
|
117
|
+
* the process exit code (0 on drain, 1 on producer failure / non-drain).
|
|
118
|
+
*/
|
|
119
|
+
export async function runWorkflowForEach(spec, opts) {
|
|
120
|
+
const [{ produceItems, runForEach, tallyForEach }, { expandForEach, DEFAULT_FOR_EACH_CAP }, { AgentManager }, { createTeam }, { runSupervisor }, { ALL_AGENT_IDS }] = await Promise.all([
|
|
121
|
+
import('../lib/teams/forEach.js'),
|
|
122
|
+
import('../lib/workflows.js'),
|
|
123
|
+
import('../lib/teams/agents.js'),
|
|
124
|
+
import('../lib/teams/registry.js'),
|
|
125
|
+
import('../lib/teams/supervisor.js'),
|
|
126
|
+
import('../lib/agents.js'),
|
|
127
|
+
]);
|
|
128
|
+
// The teams substrate spawns teammates as a real harness. When `agent:` names
|
|
129
|
+
// a known harness id, honor it; otherwise fall back to claude (the workflow
|
|
130
|
+
// harness) so a subagent-style name in `agent:` still runs.
|
|
131
|
+
const isHarness = (a) => ALL_AGENT_IDS.includes(a);
|
|
132
|
+
const runSpec = {
|
|
133
|
+
...spec,
|
|
134
|
+
agent: isHarness(spec.agent) ? spec.agent : 'claude',
|
|
135
|
+
...(spec.verify
|
|
136
|
+
? { verify: { ...spec.verify, agent: isHarness(spec.verify.agent) ? spec.verify.agent : 'claude' } }
|
|
137
|
+
: {}),
|
|
138
|
+
};
|
|
139
|
+
if (runSpec.agent !== spec.agent) {
|
|
140
|
+
process.stderr.write(chalk.gray(`[for_each] '${spec.agent}' is not a harness id — staging stage teammates as claude\n`));
|
|
141
|
+
}
|
|
142
|
+
// 1. Producer: run the shell command (or resolve itemsRef) into the item list.
|
|
143
|
+
let items;
|
|
144
|
+
try {
|
|
145
|
+
items = await produceItems(runSpec, { cwd: opts.cwd });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error(chalk.red(`[for_each] producer failed: ${err.message}`));
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
if (items.length === 0) {
|
|
152
|
+
process.stderr.write(chalk.yellow('[for_each] producer emitted no items — nothing to fan out.\n'));
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
// 2. Cap accounting (surfaced, never silent — acceptance criterion in #343).
|
|
156
|
+
const cap = runSpec.max_items ?? DEFAULT_FOR_EACH_CAP;
|
|
157
|
+
const { truncated } = expandForEach(runSpec, items);
|
|
158
|
+
if (truncated > 0) {
|
|
159
|
+
process.stderr.write(chalk.yellow(`[for_each] producer emitted ${items.length} items; capping at ${cap} (${truncated} dropped). Raise \`max_items\` to fan out more.\n`));
|
|
160
|
+
}
|
|
161
|
+
process.stderr.write(chalk.gray(`[for_each] ${Math.min(items.length, cap)} stage teammate(s) from ${items.length} produced item(s)\n`));
|
|
162
|
+
// 3. Stage the expanded teammates into a fresh team via the existing bridge.
|
|
163
|
+
const team = `foreach-${opts.workflowName.replace(/[^a-zA-Z0-9_-]/g, '-')}-${Date.now().toString(36)}`;
|
|
164
|
+
await createTeam(team, { description: `for_each fan-out from workflow '${opts.workflowName}'` });
|
|
165
|
+
const mgr = new AgentManager();
|
|
166
|
+
const { teammates } = await runForEach(mgr, team, runSpec, items, {
|
|
167
|
+
cwd: opts.cwd,
|
|
168
|
+
effort: opts.effort,
|
|
169
|
+
concurrency: runSpec.concurrency,
|
|
170
|
+
});
|
|
171
|
+
// 4. Drive the supervisor until the DAG drains (the same loop `teams start
|
|
172
|
+
// --watch` uses; it absorbs the staged teammates via rescanFromDisk).
|
|
173
|
+
const result = await runSupervisor(mgr, {
|
|
174
|
+
team,
|
|
175
|
+
onWave: (s) => {
|
|
176
|
+
const ts = s.timestamp.slice(11, 19);
|
|
177
|
+
process.stderr.write(`[${ts}] [for_each] wave ${s.wave} launched=${s.launched.length} running=${s.running} pending=${s.pending} done=${s.completed} failed=${s.failed}\n`);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// 5. keep_if tally: a skeptic that COMPLETED is a keep vote; a failed skeptic
|
|
181
|
+
// votes to drop. Gate each item and report which survived.
|
|
182
|
+
if (runSpec.verify) {
|
|
183
|
+
const loaded = await mgr.listByTask(team);
|
|
184
|
+
const statusByName = new Map(loaded.map((a) => [a.name, a.status]));
|
|
185
|
+
const verdicts = tallyForEach(teammates, (v) => statusByName.get(v.name) === 'completed');
|
|
186
|
+
const kept = verdicts.filter((v) => v.kept);
|
|
187
|
+
process.stderr.write(chalk.gray(`[for_each] keep_if=${runSpec.verify.keep_if}: kept ${kept.length}/${verdicts.length} item(s)\n`));
|
|
188
|
+
for (const v of verdicts) {
|
|
189
|
+
const tag = v.kept ? chalk.green('keep') : chalk.red('drop');
|
|
190
|
+
process.stderr.write(chalk.gray(` [${tag}] ${v.item} (${v.votes.filter(Boolean).length}/${v.votes.length} votes)\n`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (result.stoppedBy === 'drained') {
|
|
194
|
+
process.stderr.write(chalk.green(`[for_each] drained in ${Math.floor(result.elapsed_ms / 1000)}s (${result.waves} waves). Team: ${team}\n`));
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
process.stderr.write(chalk.yellow(`[for_each] stopped by ${result.stoppedBy} after ${result.waves} waves. Team: ${team}\n`));
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
104
200
|
/** Register the `agents run <agent> [prompt]` command. */
|
|
105
201
|
export function registerRunCommand(program) {
|
|
106
202
|
const runCmd = program
|
|
@@ -112,6 +208,8 @@ export function registerRunCommand(program) {
|
|
|
112
208
|
.option('--env <key=value>', 'Pass environment variable to the agent (repeatable, e.g., --env DEBUG=1 --env API_KEY=xyz)', (val, prev) => [...prev, val], [])
|
|
113
209
|
.option('--secrets <bundle>', 'Inject a secrets bundle (repeatable). Values resolve from macOS Keychain at run time. See `agents secrets`.', (val, prev) => [...prev, val], [])
|
|
114
210
|
.option('--no-auto-secrets', 'Skip auto-injection of secrets declared by a workflow\'s frontmatter `secrets:` field. Has no effect on bare-agent runs.')
|
|
211
|
+
.option('--secrets-keys <keys>', 'Inject only this comma-separated subset of keys from --secrets bundles (e.g. KEY1,KEY2). Missing keys are an error. Applies to all --secrets bundles on this run.')
|
|
212
|
+
.option('--allow-expired', 'Inject secrets even if their expiry date has passed (overrides the pre-run expiry abort).')
|
|
115
213
|
.option('--cwd <dir>', 'Working directory for the agent (defaults to current directory)')
|
|
116
214
|
.option('--add-dir <dir>', 'Grant access to an additional directory outside the project (Claude only, repeatable)', (val, prev) => [...prev, val], [])
|
|
117
215
|
.option('--json', 'Stream events as JSON lines (for parsing by other tools)')
|
|
@@ -133,7 +231,8 @@ export function registerRunCommand(program) {
|
|
|
133
231
|
.option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
|
|
134
232
|
.option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
|
|
135
233
|
.option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
|
|
136
|
-
.option('--host <name>', 'Offload this run onto
|
|
234
|
+
.option('--host <name>', 'Offload this run onto another machine over SSH instead of running locally — a device, a registered agent host, or user@host. See `agents devices` / `agents hosts`.')
|
|
235
|
+
.option('--device <name>', 'Alias of --host: offload this run onto a registered device (from `agents devices`).')
|
|
137
236
|
.option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
|
|
138
237
|
.option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
|
|
139
238
|
.option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.')
|
|
@@ -248,10 +347,10 @@ export function registerRunCommand(program) {
|
|
|
248
347
|
}
|
|
249
348
|
// --host/--on/--computer: offload this run onto a registered agent host
|
|
250
349
|
// over SSH instead of running locally. The three flags are aliases.
|
|
251
|
-
const hostGiven = [options.host, options.on, options.computer].filter((v) => !!v);
|
|
350
|
+
const hostGiven = [options.host, options.device, options.on, options.computer].filter((v) => !!v);
|
|
252
351
|
if (hostGiven.length > 0) {
|
|
253
352
|
if (new Set(hostGiven).size > 1) {
|
|
254
|
-
console.error(chalk.red('Conflicting --host/--
|
|
353
|
+
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
255
354
|
process.exit(1);
|
|
256
355
|
}
|
|
257
356
|
const hostName = hostGiven[0];
|
|
@@ -261,6 +360,10 @@ export function registerRunCommand(program) {
|
|
|
261
360
|
}
|
|
262
361
|
const { resolveHost, resolveHostByCap } = await import('../lib/hosts/registry.js');
|
|
263
362
|
const { dispatchToHost } = await import('../lib/hosts/dispatch.js');
|
|
363
|
+
const { registerHostSession } = await import('../lib/hosts/session-index.js');
|
|
364
|
+
// A password-auth device throws DeviceOffloadUnsupportedError here; it's
|
|
365
|
+
// printed cleanly by the top-level catch in index.ts (covers every
|
|
366
|
+
// resolveHost caller), so it never falls through to capability routing.
|
|
264
367
|
let host = await resolveHost(hostName);
|
|
265
368
|
if (!host) {
|
|
266
369
|
// Not a host name — try capability routing (e.g. --host gpu). A
|
|
@@ -282,19 +385,41 @@ export function registerRunCommand(program) {
|
|
|
282
385
|
process.exit(1);
|
|
283
386
|
}
|
|
284
387
|
try {
|
|
285
|
-
const
|
|
286
|
-
|
|
388
|
+
const runAgent = agentSpec.split('@')[0];
|
|
389
|
+
// `--resume [id]`: commander yields the string id, or `true` when the
|
|
390
|
+
// flag is passed bare. A bare resume needs the interactive picker,
|
|
391
|
+
// which can't run over a detached remote dispatch — only forward a
|
|
392
|
+
// concrete id.
|
|
393
|
+
const resumeId = typeof options.resume === 'string' ? options.resume : undefined;
|
|
394
|
+
// Mirror the local path (lib/exec.ts): only Claude accepts a forced
|
|
395
|
+
// `--session-id`. Generating it here lets us register the run in the
|
|
396
|
+
// local index and makes it resumable by that id. On resume the remote
|
|
397
|
+
// session keeps its existing id — don't mint a new one.
|
|
398
|
+
const hostSessionId = runAgent === 'claude' && !resumeId ? randomUUID() : undefined;
|
|
399
|
+
const { task, exitCode } = await dispatchToHost(host, {
|
|
400
|
+
agent: runAgent,
|
|
287
401
|
prompt,
|
|
288
402
|
mode: options.mode,
|
|
289
403
|
model: options.model,
|
|
290
404
|
remoteCwd: options.remoteCwd,
|
|
405
|
+
sessionId: hostSessionId,
|
|
406
|
+
resume: resumeId,
|
|
291
407
|
follow: options.follow !== false,
|
|
292
408
|
});
|
|
409
|
+
// Register the dispatched run in the LOCAL session index so it shows
|
|
410
|
+
// up in `agents sessions` and resolves by id, even though its
|
|
411
|
+
// transcript lives on the host. No-op when no session id was captured.
|
|
412
|
+
registerHostSession(task, { cwd: process.cwd(), prompt });
|
|
293
413
|
if (options.follow === false) {
|
|
294
414
|
console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
|
|
295
415
|
process.exit(0);
|
|
296
416
|
}
|
|
297
|
-
|
|
417
|
+
// -1 = the follow window closed but the run continues on the host (the
|
|
418
|
+
// reattach hint is already printed). That's a detach, not a failure —
|
|
419
|
+
// exit 0. Any real remote code passes through.
|
|
420
|
+
if (exitCode === -1)
|
|
421
|
+
process.exit(0);
|
|
422
|
+
process.exit(exitCode ?? 1);
|
|
298
423
|
}
|
|
299
424
|
catch (err) {
|
|
300
425
|
console.error(chalk.red(err.message));
|
|
@@ -377,9 +502,20 @@ export function registerRunCommand(program) {
|
|
|
377
502
|
sessionId: cp.sessionId,
|
|
378
503
|
});
|
|
379
504
|
process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
|
|
380
|
-
|
|
505
|
+
// Governance chokepoint (#347): --resume-checkpoint short-circuits normal
|
|
506
|
+
// dispatch and exits here — record its one audit entry with the agent,
|
|
507
|
+
// version, and cwd reconstructed from the checkpoint.
|
|
508
|
+
const resumeExit = loopExitCode(result.stoppedBy);
|
|
509
|
+
recordDispatchedRun({
|
|
510
|
+
agent: cp.agent,
|
|
511
|
+
version: cp.version ?? 'unknown',
|
|
512
|
+
mode: resumeExec.mode ?? 'auto',
|
|
513
|
+
cwd: resumeExec.cwd ?? process.cwd(),
|
|
514
|
+
exitCode: resumeExit,
|
|
515
|
+
});
|
|
516
|
+
process.exit(resumeExit);
|
|
381
517
|
}
|
|
382
|
-
const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
|
|
518
|
+
const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
|
|
383
519
|
import('../lib/exec.js'),
|
|
384
520
|
import('../lib/agents.js'),
|
|
385
521
|
import('../lib/profiles.js'),
|
|
@@ -400,13 +536,23 @@ export function registerRunCommand(program) {
|
|
|
400
536
|
let version = rawVersion || undefined;
|
|
401
537
|
let profileEnv;
|
|
402
538
|
let fromProfile = false;
|
|
539
|
+
let profileFallbackModel;
|
|
403
540
|
let workflowModel;
|
|
404
541
|
// WORKFLOW.md capability scoping, translated to Claude headless flags below.
|
|
405
542
|
let workflowToolsRestrict;
|
|
406
543
|
let workflowMcpConfigPath;
|
|
544
|
+
// Full paths of workflow subagent files THIS run copied into the shared
|
|
545
|
+
// per-agent agents dir. Torn down after the run to restore the shared dir
|
|
546
|
+
// (issue #401), mirroring cleanupWorkflowMcpConfig for the mcp-config.
|
|
547
|
+
const workflowSubagentTargets = [];
|
|
407
548
|
// WORKFLOW.md `loop:` block (issue #332). When a workflow declares it,
|
|
408
549
|
// `agents run <workflow>` honors the loop without a --loop flag.
|
|
409
550
|
let workflowLoop;
|
|
551
|
+
// WORKFLOW.md `for_each:` block (issue #343). When a workflow declares it,
|
|
552
|
+
// `agents run <workflow>` runs the producer, expands one stage teammate per
|
|
553
|
+
// produced item, and drives the teams supervisor to drain — no `teams`
|
|
554
|
+
// subcommands needed.
|
|
555
|
+
let workflowForEach;
|
|
410
556
|
const cwd = options.cwd ?? process.cwd();
|
|
411
557
|
if (isValidAgent(rawAgent)) {
|
|
412
558
|
agent = rawAgent;
|
|
@@ -422,6 +568,7 @@ export function registerRunCommand(program) {
|
|
|
422
568
|
if (!version)
|
|
423
569
|
version = resolved.version;
|
|
424
570
|
profileEnv = resolved.env;
|
|
571
|
+
profileFallbackModel = resolved.fallbackModel;
|
|
425
572
|
fromProfile = true;
|
|
426
573
|
process.stderr.write(chalk.gray(`Resolved profile '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
|
|
427
574
|
}
|
|
@@ -443,6 +590,7 @@ export function registerRunCommand(program) {
|
|
|
443
590
|
workflowModel = workflowFrontmatter.model.trim();
|
|
444
591
|
}
|
|
445
592
|
workflowLoop = workflowFrontmatter?.loop;
|
|
593
|
+
workflowForEach = workflowFrontmatter?.forEach;
|
|
446
594
|
const resolvedVersion = resolveVersionAlias('claude', version);
|
|
447
595
|
const versionHome = getVersionHomePath('claude', resolvedVersion ?? getGlobalDefault('claude') ?? '');
|
|
448
596
|
const claudeAgentsDir = path.join(versionHome, '.claude', 'agents');
|
|
@@ -464,6 +612,15 @@ export function registerRunCommand(program) {
|
|
|
464
612
|
const allFiles = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.md'));
|
|
465
613
|
const { allowedStems, missing } = resolveAllowedSubagents(allFiles, allowedAgents);
|
|
466
614
|
const allowStemSet = new Set(allowedStems);
|
|
615
|
+
// Fail-closed prune (issue #401, follow-up to #324). A prior
|
|
616
|
+
// unrestricted run may have left workflow subagent files that THIS
|
|
617
|
+
// scoped run does not permit; they linger in the shared dir and stay
|
|
618
|
+
// dispatchable. Remove those no-longer-permitted workflow-managed
|
|
619
|
+
// files BEFORE writing the allowed set — never a user's own subagent.
|
|
620
|
+
const pruned = pruneStaleWorkflowSubagents(claudeAgentsDir, allFiles, allowedStems);
|
|
621
|
+
if (pruned.length > 0) {
|
|
622
|
+
process.stderr.write(chalk.gray(`[workflow] pruned ${pruned.length} stale workflow subagent(s) from shared dir: ${pruned.join(', ')}\n`));
|
|
623
|
+
}
|
|
467
624
|
let copied = 0;
|
|
468
625
|
let skipped = 0;
|
|
469
626
|
for (const file of allFiles) {
|
|
@@ -472,7 +629,9 @@ export function registerRunCommand(program) {
|
|
|
472
629
|
skipped++;
|
|
473
630
|
continue;
|
|
474
631
|
}
|
|
475
|
-
|
|
632
|
+
const dest = path.join(claudeAgentsDir, file);
|
|
633
|
+
fs.copyFileSync(path.join(subagentsDir, file), dest);
|
|
634
|
+
workflowSubagentTargets.push(dest);
|
|
476
635
|
copied++;
|
|
477
636
|
}
|
|
478
637
|
if (allowedAgents !== undefined) {
|
|
@@ -717,6 +876,10 @@ export function registerRunCommand(program) {
|
|
|
717
876
|
}
|
|
718
877
|
const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
|
|
719
878
|
const explicitStrategy = options.strategy ? normalizeRunStrategy(options.strategy) : null;
|
|
879
|
+
// Captured from resolveRunVersion below so mid-run rate-limit failover can
|
|
880
|
+
// synthesize a same-agent fallback chain from the other healthy accounts
|
|
881
|
+
// (issue #348). Stays null unless a non-pinned strategy actually rotated.
|
|
882
|
+
let rotationResult = null;
|
|
720
883
|
if (options.strategy && !explicitStrategy) {
|
|
721
884
|
console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
|
|
722
885
|
process.exit(1);
|
|
@@ -743,6 +906,7 @@ export function registerRunCommand(program) {
|
|
|
743
906
|
const resolved = await resolveRunVersion(agent, strategy, cwd);
|
|
744
907
|
if (resolved.version) {
|
|
745
908
|
version = resolved.version;
|
|
909
|
+
rotationResult = resolved.rotation;
|
|
746
910
|
if (resolved.rotation && !options.quiet) {
|
|
747
911
|
const banner = formatRotationBanner(resolved.rotation, strategy);
|
|
748
912
|
process.stderr.write(chalk.gray(banner + '\n'));
|
|
@@ -831,11 +995,19 @@ export function registerRunCommand(program) {
|
|
|
831
995
|
// Resolve --secrets bundles in flag order. Later bundles override earlier
|
|
832
996
|
// ones. Any resolution failure (missing keychain item, blocked exec ref)
|
|
833
997
|
// aborts before spawn so the agent never sees a partial env.
|
|
998
|
+
const secretsKeysSubset = options.secretsKeys
|
|
999
|
+
? options.secretsKeys.split(',').map((k) => k.trim()).filter(Boolean)
|
|
1000
|
+
: undefined;
|
|
834
1001
|
let secretsEnv = {};
|
|
835
1002
|
for (const bundleRef of options.secrets) {
|
|
836
1003
|
try {
|
|
837
1004
|
const { bundle: bundleName, host } = splitBundleRef(bundleRef);
|
|
838
1005
|
if (host) {
|
|
1006
|
+
// Least-privilege flags (--secrets-keys / --allow-expired) do not
|
|
1007
|
+
// yet cross the SSH resolver — silently applying them would inject
|
|
1008
|
+
// the full remote env or an expired key. Fail loud so the user
|
|
1009
|
+
// can drop the flag or resolve locally instead.
|
|
1010
|
+
assertRemoteBundleFlagsUnsupported(bundleName, host, { keys: secretsKeysSubset, allowExpired: options.allowExpired }, { keysFlag: '--secrets-keys', allowExpiredFlag: '--allow-expired' });
|
|
839
1011
|
// Remote bundle (`bundle@host`): resolve over SSH and inject
|
|
840
1012
|
// ephemerally — values never touch this machine's keychain or disk.
|
|
841
1013
|
const target = await resolveSshTarget(host);
|
|
@@ -844,7 +1016,11 @@ export function registerRunCommand(program) {
|
|
|
844
1016
|
secretsEnv = { ...secretsEnv, ...bundleEnv };
|
|
845
1017
|
}
|
|
846
1018
|
else {
|
|
847
|
-
const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, {
|
|
1019
|
+
const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, {
|
|
1020
|
+
caller: `agent ${agent}`,
|
|
1021
|
+
keys: secretsKeysSubset,
|
|
1022
|
+
allowExpired: options.allowExpired,
|
|
1023
|
+
});
|
|
848
1024
|
const entries = describeBundle(bundle);
|
|
849
1025
|
const counts = {};
|
|
850
1026
|
for (const e of entries) {
|
|
@@ -940,6 +1116,50 @@ export function registerRunCommand(program) {
|
|
|
940
1116
|
fallback.push({ agent: fbAgent, version: resolveVersionAlias(fbAgent, fbVersion || undefined) });
|
|
941
1117
|
}
|
|
942
1118
|
}
|
|
1119
|
+
// Profile-declared same-host model swap (issue #325). Inserted BEFORE any
|
|
1120
|
+
// user --fallback entries so a rate limit first tries the cheaper/backup
|
|
1121
|
+
// model on the same provider (auth + base URL preserved via envOverride);
|
|
1122
|
+
// only if THAT still rate-limits do we cascade to a different agent CLI.
|
|
1123
|
+
// Requires a prompt for the same reason --fallback does — headless-only.
|
|
1124
|
+
if (fromProfile && profileFallbackModel && prompt !== undefined && !options.interactive) {
|
|
1125
|
+
fallback.unshift({
|
|
1126
|
+
agent,
|
|
1127
|
+
version,
|
|
1128
|
+
envOverride: { [profileFallbackModel.envKey]: profileFallbackModel.model },
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
// Mid-run rate-limit failover (issue #348). When a pre-flight rotation
|
|
1132
|
+
// picked an account and there are OTHER healthy accounts for the same
|
|
1133
|
+
// agent, synthesize a same-agent fallback chain from them so a 429 mid-run
|
|
1134
|
+
// re-dispatches on the next healthy account via the SAME runWithFallback
|
|
1135
|
+
// path (continuing the session via /continue). Because this injects into
|
|
1136
|
+
// the same `fallback` array `--fallback` uses, it must only arm for run
|
|
1137
|
+
// shapes that accept a fallback chain — shouldArmRotationFailover excludes
|
|
1138
|
+
// acp/loop/resume-checkpoint (which reject a non-empty fallback below),
|
|
1139
|
+
// interactive/no-prompt runs, and runs that already have an explicit or
|
|
1140
|
+
// profile fallback. Pinned/single-account runs stay unchanged because
|
|
1141
|
+
// rotationResult is null or rotationFailoverChain returns []. version is
|
|
1142
|
+
// set here because rotationResult is only populated when resolveRunVersion
|
|
1143
|
+
// picked one.
|
|
1144
|
+
if (shouldArmRotationFailover({
|
|
1145
|
+
hasRotation: !!rotationResult,
|
|
1146
|
+
hasVersion: !!version,
|
|
1147
|
+
hasPrompt: prompt !== undefined,
|
|
1148
|
+
explicitFallback: fallback.length > 0,
|
|
1149
|
+
interactive: !!options.interactive,
|
|
1150
|
+
acp: !!options.acp,
|
|
1151
|
+
loop: !!options.loop,
|
|
1152
|
+
resumeCheckpoint: !!options.resumeCheckpoint,
|
|
1153
|
+
})) {
|
|
1154
|
+
const failover = rotationFailoverChain(rotationResult, version);
|
|
1155
|
+
if (failover.length > 0) {
|
|
1156
|
+
fallback.push(...failover);
|
|
1157
|
+
if (!options.quiet) {
|
|
1158
|
+
const accounts = failover.map(f => `${f.agent}@${f.version}`).join(', ');
|
|
1159
|
+
process.stderr.write(chalk.gray(`[agents] rate-limit failover armed: ${accounts}\n`));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
943
1163
|
if (options.acp) {
|
|
944
1164
|
if (prompt === undefined) {
|
|
945
1165
|
console.error(chalk.red('--acp requires a prompt. ACP is a programmatic protocol; interactive TUI sessions still use the native CLI.'));
|
|
@@ -963,6 +1183,9 @@ export function registerRunCommand(program) {
|
|
|
963
1183
|
mode,
|
|
964
1184
|
json: options.json ?? false,
|
|
965
1185
|
});
|
|
1186
|
+
// Governance chokepoint (#347): the --acp path exits here, bypassing
|
|
1187
|
+
// the normal finalize below — record its one audit entry.
|
|
1188
|
+
recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode });
|
|
966
1189
|
process.exit(exitCode);
|
|
967
1190
|
}
|
|
968
1191
|
catch (err) {
|
|
@@ -1044,6 +1267,36 @@ export function registerRunCommand(program) {
|
|
|
1044
1267
|
// best-effort: nothing actionable if the temp dir is already gone.
|
|
1045
1268
|
}
|
|
1046
1269
|
};
|
|
1270
|
+
// Restore the shared per-agent agents dir after the run (issue #401):
|
|
1271
|
+
// remove the workflow subagent files THIS run copied in, so a scoped
|
|
1272
|
+
// workflow never leaves definitions behind for the next, unrelated run to
|
|
1273
|
+
// inherit. Mirrors cleanupWorkflowMcpConfig — tear down only what we made.
|
|
1274
|
+
const cleanupWorkflowSubagents = () => {
|
|
1275
|
+
for (const target of workflowSubagentTargets) {
|
|
1276
|
+
try {
|
|
1277
|
+
fs.rmSync(target, { force: true });
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
// best-effort: nothing actionable if the file is already gone.
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
// for_each dispatch (issue #343). A workflow that declares `for_each:` is a
|
|
1285
|
+
// declarative dynamic fan-out, not a single-agent run: execute the producer,
|
|
1286
|
+
// expand one stage teammate per produced item (+ optional verify panel),
|
|
1287
|
+
// stage them into a team, then drive the supervisor until the DAG drains.
|
|
1288
|
+
// This is mutually exclusive with the single-agent loop/fallback paths — the
|
|
1289
|
+
// fan-out IS the run.
|
|
1290
|
+
if (workflowForEach) {
|
|
1291
|
+
cleanupWorkflowMcpConfig();
|
|
1292
|
+
cleanupWorkflowSubagents();
|
|
1293
|
+
const exitCode = await runWorkflowForEach(workflowForEach, {
|
|
1294
|
+
workflowName: rawAgent,
|
|
1295
|
+
cwd,
|
|
1296
|
+
effort: options.effort,
|
|
1297
|
+
});
|
|
1298
|
+
process.exit(exitCode);
|
|
1299
|
+
}
|
|
1047
1300
|
// Loop dispatch (issue #332). Active when --loop is passed OR a workflow
|
|
1048
1301
|
// declares a `loop:` block. The loop path runs AFTER the #346 pre-flight
|
|
1049
1302
|
// gate above (which fired once) — the loop's token budget is an ADDITIONAL
|
|
@@ -1083,29 +1336,47 @@ export function registerRunCommand(program) {
|
|
|
1083
1336
|
version,
|
|
1084
1337
|
});
|
|
1085
1338
|
cleanupWorkflowMcpConfig();
|
|
1339
|
+
cleanupWorkflowSubagents();
|
|
1086
1340
|
process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens (checkpoint: ${path.join(runDir, 'checkpoint.json')})\n`));
|
|
1087
|
-
|
|
1341
|
+
// Governance chokepoint (#347): the --loop path exits here, bypassing
|
|
1342
|
+
// the normal finalize below — record its one audit entry.
|
|
1343
|
+
const loopExit = loopExitCode(result.stoppedBy);
|
|
1344
|
+
recordDispatchedRun({ agent, version: defaultVersion ?? 'unknown', mode, cwd, exitCode: loopExit });
|
|
1345
|
+
process.exit(loopExit);
|
|
1088
1346
|
}
|
|
1089
1347
|
catch (err) {
|
|
1090
1348
|
cleanupWorkflowMcpConfig();
|
|
1349
|
+
cleanupWorkflowSubagents();
|
|
1091
1350
|
console.error(chalk.red(`Loop failed for ${agent}: ${err.message}`));
|
|
1092
1351
|
process.exit(1);
|
|
1093
1352
|
}
|
|
1094
1353
|
}
|
|
1095
1354
|
try {
|
|
1096
1355
|
let exitCode;
|
|
1356
|
+
let ranAgent = agent;
|
|
1357
|
+
let ranVersion = defaultVersion;
|
|
1097
1358
|
if (fallback.length > 0) {
|
|
1098
1359
|
// fallback requires a prompt — enforced above, narrow the type here.
|
|
1099
|
-
|
|
1360
|
+
// The sink reports which chain entry actually executed (may differ from
|
|
1361
|
+
// the primary after a rate-limit handoff) so the audit record is honest.
|
|
1362
|
+
const sink = {};
|
|
1363
|
+
exitCode = await runWithFallback({ ...execOptions, prompt: prompt, fallback, dispatchSink: sink });
|
|
1364
|
+
ranAgent = sink.agent ?? agent;
|
|
1365
|
+
ranVersion = sink.version ?? defaultVersion;
|
|
1100
1366
|
}
|
|
1101
1367
|
else {
|
|
1102
1368
|
exitCode = await execAgent(execOptions);
|
|
1103
1369
|
}
|
|
1104
1370
|
cleanupWorkflowMcpConfig();
|
|
1371
|
+
cleanupWorkflowSubagents();
|
|
1372
|
+
// Governance chokepoint (#347): every dispatched run finalizes here.
|
|
1373
|
+
// ONE tamper-evident audit record per run — non-fatal by contract.
|
|
1374
|
+
recordDispatchedRun({ agent: ranAgent, version: ranVersion ?? 'unknown', mode, cwd, exitCode });
|
|
1105
1375
|
process.exit(exitCode);
|
|
1106
1376
|
}
|
|
1107
1377
|
catch (err) {
|
|
1108
1378
|
cleanupWorkflowMcpConfig();
|
|
1379
|
+
cleanupWorkflowSubagents();
|
|
1109
1380
|
console.error(chalk.red(`Failed to execute ${agent}: ${err.message}`));
|
|
1110
1381
|
process.exit(1);
|
|
1111
1382
|
}
|
package/dist/commands/hosts.js
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
* hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
|
|
8
8
|
*/
|
|
9
9
|
import chalk from 'chalk';
|
|
10
|
+
import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
|
|
10
11
|
import { checkbox, confirm } from '@inquirer/prompts';
|
|
11
12
|
import { assertValidSshTarget } from '../lib/ssh-exec.js';
|
|
12
13
|
import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
|
|
13
14
|
import { sshTargetFor } from '../lib/hosts/types.js';
|
|
14
15
|
import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
|
|
15
16
|
import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
17
|
+
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
18
|
+
import { listTasks } from '../lib/hosts/tasks.js';
|
|
19
|
+
import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
|
|
20
|
+
import { showHostTaskLog } from '../lib/hosts/logs.js';
|
|
19
21
|
/** Parse `user@host` or `host` into its pieces. */
|
|
20
22
|
function parseTarget(target) {
|
|
21
23
|
const at = target.indexOf('@');
|
|
@@ -23,20 +25,25 @@ function parseTarget(target) {
|
|
|
23
25
|
return { address: target };
|
|
24
26
|
return { user: target.slice(0, at), address: target.slice(at + 1) };
|
|
25
27
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts).
|
|
30
|
+
* Callers that just probed the host pass the result through to avoid a second,
|
|
31
|
+
* redundant `uname` round-trip; otherwise we probe here.
|
|
32
|
+
*/
|
|
33
|
+
async function maybeBootstrap(target, hostName, probe = probeHost(target, resolveRemoteOsSync(hostName))) {
|
|
29
34
|
if (!probe.reachable) {
|
|
30
35
|
console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
|
|
31
36
|
return;
|
|
32
37
|
}
|
|
33
|
-
|
|
38
|
+
// Prefer the OS the probe just observed; fall back to the registry hint.
|
|
39
|
+
const os = probe.os ?? resolveRemoteOsSync(hostName);
|
|
40
|
+
const remoteVer = remoteAgentsVersion(target, os);
|
|
34
41
|
const localVer = localCliVersion();
|
|
35
42
|
if (!remoteVer) {
|
|
36
43
|
const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
|
|
37
44
|
if (ok) {
|
|
38
45
|
console.log(chalk.gray(' Installing agents-cli on the host…'));
|
|
39
|
-
const r = bootstrapAgentsCli(target, localVer);
|
|
46
|
+
const r = bootstrapAgentsCli(target, localVer, os);
|
|
40
47
|
console.log(r.ok ? chalk.green(' Installed.') : chalk.red(` Install failed:\n${r.output}`));
|
|
41
48
|
}
|
|
42
49
|
return;
|
|
@@ -45,7 +52,7 @@ async function maybeBootstrap(target, hostName) {
|
|
|
45
52
|
if (localVer && remoteClean !== localVer) {
|
|
46
53
|
const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
|
|
47
54
|
if (ok) {
|
|
48
|
-
const r = bootstrapAgentsCli(target, localVer);
|
|
55
|
+
const r = bootstrapAgentsCli(target, localVer, os);
|
|
49
56
|
console.log(r.ok ? chalk.green(' Upgraded.') : chalk.red(` Upgrade failed:\n${r.output}`));
|
|
50
57
|
}
|
|
51
58
|
}
|
|
@@ -75,7 +82,7 @@ async function doAdd(name, target, opts) {
|
|
|
75
82
|
await registerHost({ name: c, provider: 'local', source, ...(source === 'inline' ? { address: c } : {}), os: probe.os, caps: opts.cap });
|
|
76
83
|
console.log(chalk.green(`Enrolled ${c}`) + chalk.gray(` (${source}${probe.os ? `, ${probe.os}` : ''})`));
|
|
77
84
|
if (opts.enroll !== false)
|
|
78
|
-
await maybeBootstrap(c, c);
|
|
85
|
+
await maybeBootstrap(c, c, probe);
|
|
79
86
|
}
|
|
80
87
|
return;
|
|
81
88
|
}
|
|
@@ -107,7 +114,7 @@ async function doAdd(name, target, opts) {
|
|
|
107
114
|
await registerHost(spec);
|
|
108
115
|
console.log(chalk.green(`Enrolled ${name}`) + chalk.gray(` (${spec.source}${spec.os ? `, ${spec.os}` : ''}${spec.caps?.length ? `, caps: ${spec.caps.join(',')}` : ''})`));
|
|
109
116
|
if (opts.enroll !== false)
|
|
110
|
-
await maybeBootstrap(sshTarget, name);
|
|
117
|
+
await maybeBootstrap(sshTarget, name, probe);
|
|
111
118
|
}
|
|
112
119
|
async function doList(json) {
|
|
113
120
|
const hosts = await listAllHosts();
|
|
@@ -122,8 +129,11 @@ async function doList(json) {
|
|
|
122
129
|
console.log(chalk.bold('NAME').padEnd(20) + chalk.bold('SOURCE').padEnd(13) + chalk.bold('TARGET').padEnd(28) + chalk.bold('CAPS'));
|
|
123
130
|
for (const h of hosts) {
|
|
124
131
|
const tgt = h.source === 'ssh-config' ? chalk.gray('(ssh-config)') : `${h.user ? h.user + '@' : ''}${h.address ?? ''}`;
|
|
132
|
+
// Cap TARGET at its column so a long user@host can't shove CAPS out of alignment.
|
|
133
|
+
const tgtW = stringWidth(tgt);
|
|
134
|
+
const tgtCol = tgtW > 28 ? truncateToWidth(tgt, 28) : tgt + ' '.repeat(28 - tgtW);
|
|
125
135
|
const mark = h.enrolled ? '' : chalk.gray(' ·available');
|
|
126
|
-
console.log(h.name.padEnd(20) + h.source.padEnd(13) +
|
|
136
|
+
console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgtCol + (h.caps?.join(',') ?? '') + mark);
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
139
|
async function doCheck(name) {
|
|
@@ -134,15 +144,16 @@ async function doCheck(name) {
|
|
|
134
144
|
return;
|
|
135
145
|
}
|
|
136
146
|
const target = sshTargetFor(host);
|
|
147
|
+
const os = host.os ?? resolveRemoteOsSync(name);
|
|
137
148
|
process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
|
|
138
|
-
const probe = probeHost(target);
|
|
149
|
+
const probe = probeHost(target, os);
|
|
139
150
|
if (!probe.reachable) {
|
|
140
151
|
console.log(chalk.red('unreachable'));
|
|
141
152
|
process.exitCode = 1;
|
|
142
153
|
return;
|
|
143
154
|
}
|
|
144
155
|
console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
|
|
145
|
-
const ver = remoteAgentsVersion(target);
|
|
156
|
+
const ver = remoteAgentsVersion(target, probe.os ?? os);
|
|
146
157
|
console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
|
|
147
158
|
}
|
|
148
159
|
async function doRemove(name) {
|
|
@@ -155,7 +166,10 @@ async function doRemove(name) {
|
|
|
155
166
|
console.log(chalk.green(`Removed ${name}`));
|
|
156
167
|
}
|
|
157
168
|
async function doPs(json) {
|
|
158
|
-
|
|
169
|
+
// Heal any 'running' record whose local follower died (dropped connection,
|
|
170
|
+
// laptop sleep) against the remote `.exit` before listing, so `ps` never shows
|
|
171
|
+
// a finished run stuck at 'running'.
|
|
172
|
+
const tasks = reconcileRunningTasks(listTasks());
|
|
159
173
|
if (json) {
|
|
160
174
|
console.log(JSON.stringify(tasks, null, 2));
|
|
161
175
|
return;
|
|
@@ -164,30 +178,24 @@ async function doPs(json) {
|
|
|
164
178
|
console.log(chalk.gray('No host tasks yet. Dispatch one: agents run <agent> "<task>" --host <name>'));
|
|
165
179
|
return;
|
|
166
180
|
}
|
|
181
|
+
const cols = terminalWidth();
|
|
167
182
|
console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
|
|
168
183
|
for (const t of tasks) {
|
|
169
184
|
const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
|
|
170
|
-
|
|
185
|
+
// Prompt fills the remaining width instead of a fixed 50-char byte slice (98-char rows).
|
|
186
|
+
const promptCol = truncateToWidth(t.prompt, Math.max(12, cols - (11 + 16 + 10 + 11)));
|
|
187
|
+
console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + promptCol);
|
|
171
188
|
}
|
|
172
189
|
}
|
|
173
190
|
async function doLogs(id, follow) {
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
191
|
+
const res = await showHostTaskLog(id, follow);
|
|
192
|
+
if (!res.found) {
|
|
176
193
|
console.log(chalk.red(`Unknown task "${id}".`));
|
|
177
194
|
process.exitCode = 1;
|
|
178
195
|
return;
|
|
179
196
|
}
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
process.exitCode = code === -1 ? 1 : code;
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
console.log(chalk.gray('(no local log captured for this task)'));
|
|
190
|
-
}
|
|
197
|
+
if (res.exitCode !== undefined)
|
|
198
|
+
process.exitCode = res.exitCode;
|
|
191
199
|
}
|
|
192
200
|
/** Register the `agents hosts` command tree. */
|
|
193
201
|
export function registerHostsCommand(program) {
|