@phnx-labs/agents-cli 1.20.56 → 1.20.57
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 +6 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/secrets.d.ts +3 -2
- package/dist/commands/secrets.js +7 -6
- package/dist/commands/teams.d.ts +20 -1
- package/dist/commands/teams.js +105 -2
- package/dist/lib/daemon.js +23 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/agent.d.ts +27 -0
- package/dist/lib/secrets/agent.js +144 -1
- package/dist/lib/teams/agents.d.ts +16 -0
- package/dist/lib/teams/agents.js +121 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.57
|
|
6
|
+
|
|
7
|
+
- **`agents teams resume` / `agents teams message` — resume a stopped teammate with a follow-up message.** A teammate that ended its turn with more to do (PR open awaiting review, headless turn cap, a redirect after the fact) could not be reached: `agents message` resolves only *live* sessions, so a completed/stopped/failed teammate had no path back short of finishing the work by hand or spawning a fresh, context-less teammate. `teams resume <team> <teammate> <message>` re-enters the teammate's **own** session with the message as the next user turn, re-launching through the same backend (local process or remote host) in its original worktree and flipping it back to `running` so `teams status` tracks it live. `teams message` is the same command with automatic routing by reconciled status: a **running** teammate is steered via its mailbox (delivered at its next tool call, no re-launch); a **stopped** one is resumed; a **pending** one is refused with a pointer to `teams start`. Works for every harness — the resume delegates to `agents run --resume`, inheriting native resume for Claude/Codex and the universal `/continue` replay for the rest (OpenCode, Grok, Kimi, …); the resume target is the teammate's captured underlying session id (`remoteSessionId ?? agentId`), and a non-Claude teammate that died before emitting a session id is refused with a clear error rather than resumed into a fresh run. This also makes good on `teams stop`'s long-standing "can be restarted later" promise, which no code implemented. Source: `apps/cli/src/commands/teams.ts` (`message`/`resume` subcommands, `decideTeamMessageRoute`), `apps/cli/src/lib/teams/agents.ts` (`AgentManager.resumeTeammate`, resume-aware `buildRunArgv`/`buildCommand`/`launchProcess`/`launchRemoteProcess`).
|
|
8
|
+
- **The always-on daemon now hosts the secrets broker (socket-first) — one supervised backbone instead of a separate service (#416, step 1).** `runDaemon()` binds the broker via the new `startHostedBroker()` before the scheduler and the heavy browser/session-sync services, so `agents secrets` resolves within ms of daemon start. It serves the same socket + wire protocol as the standalone broker (no `PROTOCOL_VERSION` bump — `agentGetSync`/`agentPing`/`agentAutoLoadSync` are unchanged), but is daemon-safe: no pid-guard, no `process.exit`/signal handlers/self-heal-exit (which would take the daemon down), TTL-eviction only. `ensureAgentRunning()` gains a Path 0 that prefers the daemon and falls back to the standalone `com.phnx-labs.agents-secrets-agent` launchd service, and the daemon only hosts when no broker is already reachable, so a live standalone broker is never orphaned. Retiring the standalone service (a gated `launchctl bootout` migration) and child-spawning the heavy services are the follow-on (#417). Source: `apps/cli/src/lib/secrets/agent.ts` (`startHostedBroker`, `ensureAgentRunning` Path 0, `agentPing` exported), `apps/cli/src/lib/daemon.ts` (`runDaemon` broker host + shutdown).
|
|
9
|
+
- **Clarified `agents secrets list` POLICY column labels.** The column previously mixed policy names, runtime state, and implementation jargon (`daily · 7d left`, `always ask`, `never · NO ACL`). It now uses a consistent `policy · state` form: `daily`, `daily · held 7d`, `always · prompt`, and `never · no prompt`. Source: `apps/cli/src/commands/secrets.ts` (`renderPolicyCol`).
|
|
10
|
+
|
|
5
11
|
## 1.20.56
|
|
6
12
|
|
|
7
13
|
- **Fix native routine schedulers rejecting the published CLI as a Bun virtual path.** Bun's standalone runtime reports the embedded `/$bunfs/root/agents` entry as existing at `process.argv[1]`, while the real physical executable lives at `process.execPath`. Daemon resolution now substitutes that physical executable before generating launchd/systemd manifests or detached launches; the existing virtual-path guard still rejects any virtual path that reaches supervision. Source: `apps/cli/src/lib/daemon.ts`.
|
|
8
|
-
|
|
9
14
|
- **Fix: `agents teams`, `agents message`, and `agents profiles check` work again on the signed standalone binary (regression from #315).** When `agents` resolves to the bun-compiled Mach-O (shipped since 1.20.53), three self-spawn sites relaunched the CLI as `[process.execPath, process.argv[1], …]` — but under a bun standalone executable `process.argv[1]` is the virtual entry `/$bunfs/root/agents`, so the child died with `unknown command '/$bunfs/root/agents'` (or `/bin/sh: /$bunfs/root/agents: No such file or directory`). Every teammate spawned by a compiled-binary install failed in 0s. New shared `getAgentsInvocation(subArgs)` (`apps/cli/src/lib/daemon.ts`) resolves the real on-disk binary — mapping the `/$bunfs/root/…` virtual path to `process.execPath`, running a `.js` entry under node, and a native binary directly — and `teams/agents.ts`, `commands/message.ts`, and `commands/profiles.ts` route through it. Verified end-to-end: a teammate spawned by the freshly-compiled binary runs to `completed` with no `$bunfs` error. Source: `apps/cli/src/lib/daemon.ts` (`getAgentsInvocation`), `apps/cli/src/lib/teams/agents.ts`, `apps/cli/src/commands/{message,profiles}.ts`.
|
|
10
15
|
## 1.20.55
|
|
11
16
|
|
package/dist/bin/agents
CHANGED
|
Binary file
|
|
@@ -66,8 +66,9 @@ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secret
|
|
|
66
66
|
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
67
67
|
*/
|
|
68
68
|
export declare function bundleEnvToDotenv(env: Record<string, string>): string;
|
|
69
|
-
/** The POLICY column for `secrets list`: the prompt policy, plus a
|
|
70
|
-
* hint
|
|
69
|
+
/** The POLICY column for `secrets list`: the prompt policy, plus a concise
|
|
70
|
+
* state hint. `daily` shows `held Nh` when the secrets-agent is currently
|
|
71
|
+
* caching the bundle; `always` and `never` show whether they prompt. `held`
|
|
71
72
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
72
73
|
export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
|
|
73
74
|
/** Register the `agents secrets` command tree. */
|
package/dist/commands/secrets.js
CHANGED
|
@@ -387,17 +387,18 @@ function compactRemaining(expiresAt) {
|
|
|
387
387
|
return `${hours}h`;
|
|
388
388
|
return `${Math.round(hours / 24)}d`;
|
|
389
389
|
}
|
|
390
|
-
/** The POLICY column for `secrets list`: the prompt policy, plus a
|
|
391
|
-
* hint
|
|
390
|
+
/** The POLICY column for `secrets list`: the prompt policy, plus a concise
|
|
391
|
+
* state hint. `daily` shows `held Nh` when the secrets-agent is currently
|
|
392
|
+
* caching the bundle; `always` and `never` show whether they prompt. `held`
|
|
392
393
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
393
394
|
export function renderPolicyCol(b, held) {
|
|
394
395
|
// `never` is loud on purpose — it's the only tier with no user-presence gate.
|
|
395
396
|
if (bundlePolicy(b) === 'never')
|
|
396
|
-
return chalk.red.bold('never ·
|
|
397
|
+
return chalk.red.bold('never · no prompt');
|
|
397
398
|
if (bundlePolicy(b) === 'always')
|
|
398
|
-
return chalk.yellow('always
|
|
399
|
+
return chalk.yellow('always · prompt');
|
|
399
400
|
const exp = held?.get(b.name);
|
|
400
|
-
return exp ? chalk.green(`daily · ${compactRemaining(exp)}
|
|
401
|
+
return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
|
|
401
402
|
}
|
|
402
403
|
/** Below this width the fixed date columns no longer fit; `list` uses cards. */
|
|
403
404
|
const SECRETS_WIDE = 96;
|
|
@@ -657,7 +658,7 @@ export function registerSecretsCommands(program) {
|
|
|
657
658
|
return;
|
|
658
659
|
}
|
|
659
660
|
// Cross-reference the secrets-agent so `daily` bundles that are currently
|
|
660
|
-
// held can show "· Nh
|
|
661
|
+
// held can show "· held Nh". Soft-fails to no hint if the broker is down.
|
|
661
662
|
const held = new Map();
|
|
662
663
|
if (process.platform === 'darwin') {
|
|
663
664
|
try {
|
package/dist/commands/teams.d.ts
CHANGED
|
@@ -6,7 +6,26 @@
|
|
|
6
6
|
* dependencies between teammates, and clean up when work is done.
|
|
7
7
|
*/
|
|
8
8
|
import type { Command } from 'commander';
|
|
9
|
-
import { AgentManager } from '../lib/teams/agents.js';
|
|
9
|
+
import { AgentManager, AgentStatus } from '../lib/teams/agents.js';
|
|
10
|
+
/** Where `teams message`/`teams resume` routes a follow-up, by teammate status. */
|
|
11
|
+
export type TeamMessageRoute = {
|
|
12
|
+
kind: 'steer';
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'resume';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'need-message';
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'not-started';
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Decide how a follow-up to a teammate is delivered from its reconciled status.
|
|
22
|
+
* Pure — the source of truth for the routing table, unit-tested without I/O.
|
|
23
|
+
* - pending -> not-started (tell them to `teams start`)
|
|
24
|
+
* - running + message -> steer (mailbox)
|
|
25
|
+
* - stopped/etc + message -> resume (re-enter session)
|
|
26
|
+
* - any actionable + no message -> need-message
|
|
27
|
+
*/
|
|
28
|
+
export declare function decideTeamMessageRoute(status: AgentStatus, hasMessage: boolean): TeamMessageRoute;
|
|
10
29
|
/**
|
|
11
30
|
* Register the generic cloud dispatcher — staged cloud teammates get
|
|
12
31
|
* dispatched when their --after deps resolve, using repo/branch stored on
|
package/dist/commands/teams.js
CHANGED
|
@@ -3,7 +3,8 @@ import { die, relTime, truncate, isJsonMode, padRight } from '../lib/format.js';
|
|
|
3
3
|
import * as fs from 'fs/promises';
|
|
4
4
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
5
5
|
import * as path from 'path';
|
|
6
|
-
import { AgentManager, checkCliSignedIn, collectTeamsDoctorData, getAgentsDir, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
|
|
6
|
+
import { AgentManager, AgentStatus, checkCliSignedIn, collectTeamsDoctorData, getAgentsDir, VALID_TASK_TYPES, } from '../lib/teams/agents.js';
|
|
7
|
+
import { mailboxDir, enqueue } from '../lib/mailbox.js';
|
|
7
8
|
import { resolveProvider } from '../lib/cloud/registry.js';
|
|
8
9
|
import { emit } from '../lib/events.js';
|
|
9
10
|
import { runSupervisor } from '../lib/teams/supervisor.js';
|
|
@@ -126,6 +127,23 @@ function parseTeammate(spec) {
|
|
|
126
127
|
function shortId(id) {
|
|
127
128
|
return id.slice(0, 8);
|
|
128
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide how a follow-up to a teammate is delivered from its reconciled status.
|
|
132
|
+
* Pure — the source of truth for the routing table, unit-tested without I/O.
|
|
133
|
+
* - pending -> not-started (tell them to `teams start`)
|
|
134
|
+
* - running + message -> steer (mailbox)
|
|
135
|
+
* - stopped/etc + message -> resume (re-enter session)
|
|
136
|
+
* - any actionable + no message -> need-message
|
|
137
|
+
*/
|
|
138
|
+
export function decideTeamMessageRoute(status, hasMessage) {
|
|
139
|
+
if (status === AgentStatus.PENDING)
|
|
140
|
+
return { kind: 'not-started' };
|
|
141
|
+
if (!hasMessage)
|
|
142
|
+
return { kind: 'need-message' };
|
|
143
|
+
if (status === AgentStatus.RUNNING)
|
|
144
|
+
return { kind: 'steer' };
|
|
145
|
+
return { kind: 'resume' };
|
|
146
|
+
}
|
|
129
147
|
/**
|
|
130
148
|
* Preamble injected into every factory worker's prompt. Tells the worker
|
|
131
149
|
* which team + teammate name + task-type it is, and how to file new tasks.
|
|
@@ -816,6 +834,12 @@ export function registerTeamsCommands(program) {
|
|
|
816
834
|
# Delta-poll status without rereading everything
|
|
817
835
|
agents teams status pricing-page --since 2026-04-24T09:00:00-07:00
|
|
818
836
|
|
|
837
|
+
# Nudge a teammate that stopped with more to do — resumes its own session
|
|
838
|
+
agents teams resume pricing-page backend "Review's in — rebase-merge the PR, then release"
|
|
839
|
+
|
|
840
|
+
# Steer a still-running teammate mid-flight (delivered at its next tool call)
|
|
841
|
+
agents teams message pricing-page qa "Skip the flaky screenshot test for now"
|
|
842
|
+
|
|
819
843
|
# Wind everyone down when shipped
|
|
820
844
|
agents teams disband pricing-page
|
|
821
845
|
`,
|
|
@@ -1646,7 +1670,7 @@ export function registerTeamsCommands(program) {
|
|
|
1646
1670
|
});
|
|
1647
1671
|
// stop
|
|
1648
1672
|
addHostOption(teams.command('stop [team] [teammate]'))
|
|
1649
|
-
.description('Stop a running teammate.
|
|
1673
|
+
.description('Stop a running teammate. Resume it later with `agents teams resume`. Cleans up worktree if no uncommitted changes.')
|
|
1650
1674
|
.option('--json', 'Output machine-readable JSON')
|
|
1651
1675
|
.action(async (team, ref, opts) => {
|
|
1652
1676
|
const mgr = mkManager();
|
|
@@ -1733,6 +1757,85 @@ export function registerTeamsCommands(program) {
|
|
|
1733
1757
|
console.log(chalk.yellow(`Worktree '${agent.worktreeName}' has uncommitted changes. Keeping it at: ${agent.worktreePath}`));
|
|
1734
1758
|
}
|
|
1735
1759
|
});
|
|
1760
|
+
// message / resume — send a follow-up message to a teammate. Routes by the
|
|
1761
|
+
// teammate's reconciled status: a RUNNING teammate is STEERED via its mailbox
|
|
1762
|
+
// (delivered at its next tool call); a STOPPED one (completed/failed/stopped)
|
|
1763
|
+
// is RESUMED — re-entering its own session with the message as the next user
|
|
1764
|
+
// turn, re-attaching it to the team as live.
|
|
1765
|
+
async function teamMessageAction(team, ref, message, opts) {
|
|
1766
|
+
const mgr = mkManager();
|
|
1767
|
+
const lookup = await mgr.resolveAgentIdInTask(team, ref);
|
|
1768
|
+
if (lookup.kind === 'none')
|
|
1769
|
+
die(`No teammate matching '${ref}' in team ${team}`, 2);
|
|
1770
|
+
if (lookup.kind === 'ambiguous') {
|
|
1771
|
+
const shorts = lookup.matches.map(shortId).join(', ');
|
|
1772
|
+
die(`'${ref}' matches multiple teammates: ${shorts}. Use more characters or a name.`, 2);
|
|
1773
|
+
}
|
|
1774
|
+
const agentId = lookup.agentId;
|
|
1775
|
+
// mgr.get reconciles the teammate's status (PID + start-time guard / remote
|
|
1776
|
+
// .exit sentinel / exit-code reap) before we branch — so running-vs-stopped
|
|
1777
|
+
// is a fact, not a guess.
|
|
1778
|
+
const agent = await mgr.get(agentId);
|
|
1779
|
+
if (!agent)
|
|
1780
|
+
die(`Teammate ${shortId(agentId)} vanished from team ${team}.`);
|
|
1781
|
+
const display = agent.name || shortId(agentId);
|
|
1782
|
+
const status = agent.status;
|
|
1783
|
+
const hasMessage = message != null && message.trim().length > 0;
|
|
1784
|
+
const route = decideTeamMessageRoute(status, hasMessage);
|
|
1785
|
+
switch (route.kind) {
|
|
1786
|
+
case 'not-started':
|
|
1787
|
+
die(`Teammate '${display}' hasn't started yet (waiting on --after deps). Run \`agents teams start ${team}\` to launch it.`);
|
|
1788
|
+
return;
|
|
1789
|
+
case 'need-message':
|
|
1790
|
+
if (status === AgentStatus.RUNNING) {
|
|
1791
|
+
die(`Teammate '${display}' is running — pass a message to steer it.`);
|
|
1792
|
+
}
|
|
1793
|
+
die(`Teammate '${display}' is ${status} — pass a message to resume it: \`agents teams resume ${team} ${display} "<message>"\`.`);
|
|
1794
|
+
return;
|
|
1795
|
+
case 'steer': {
|
|
1796
|
+
// Running -> steer via mailbox; never re-launch (that forks a 2nd session).
|
|
1797
|
+
enqueue(mailboxDir(agentId), { to: agentId, text: message, from: opts.from });
|
|
1798
|
+
if (isJsonMode(opts)) {
|
|
1799
|
+
console.log(JSON.stringify({ team, agent_id: agentId, name: agent.name ?? null, action: 'steer', status }, null, 2));
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
console.log(chalk.green(`Steering ${chalk.cyan(display)} (running) — `) +
|
|
1803
|
+
chalk.dim('message queued; it will see it at its next tool call.'));
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
case 'resume': {
|
|
1807
|
+
// Stopped / completed / failed -> resume its own session with the message.
|
|
1808
|
+
try {
|
|
1809
|
+
await mgr.resumeTeammate(agentId, message);
|
|
1810
|
+
}
|
|
1811
|
+
catch (err) {
|
|
1812
|
+
die(err.message);
|
|
1813
|
+
}
|
|
1814
|
+
if (isJsonMode(opts)) {
|
|
1815
|
+
console.log(JSON.stringify({ team, agent_id: agentId, name: agent.name ?? null, action: 'resume', prior_status: status }, null, 2));
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
console.log(chalk.green(`Resuming ${chalk.cyan(display)} `) +
|
|
1819
|
+
chalk.dim(`(was ${status}) in team ${team} — re-entering its session with your message.`));
|
|
1820
|
+
console.log(chalk.dim(`Track it with \`agents teams status ${team}\`.`));
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
addHostOption(teams.command('message <team> <teammate> <message>'))
|
|
1826
|
+
.description('Send a follow-up message to a teammate. A running teammate is steered via its mailbox; a stopped one is resumed — re-entering its own session with the message.')
|
|
1827
|
+
.option('--from <who>', 'Label recorded as the sender of this message')
|
|
1828
|
+
.option('--json', 'Output machine-readable JSON')
|
|
1829
|
+
.action(async (team, ref, message, opts) => {
|
|
1830
|
+
await teamMessageAction(team, ref, message, opts);
|
|
1831
|
+
});
|
|
1832
|
+
addHostOption(teams.command('resume <team> <teammate> [message]'))
|
|
1833
|
+
.description("Resume a stopped teammate (completed/failed/stopped) by re-entering its own session with a message as the next user turn. If the teammate is still running, the message is steered via its mailbox instead.")
|
|
1834
|
+
.option('--from <who>', 'Label recorded as the sender of this message')
|
|
1835
|
+
.option('--json', 'Output machine-readable JSON')
|
|
1836
|
+
.action(async (team, ref, message, opts) => {
|
|
1837
|
+
await teamMessageAction(team, ref, message, opts);
|
|
1838
|
+
});
|
|
1736
1839
|
// remove
|
|
1737
1840
|
teams
|
|
1738
1841
|
.command('remove [team] [teammate]')
|
package/dist/lib/daemon.js
CHANGED
|
@@ -306,6 +306,28 @@ export async function runDaemon() {
|
|
|
306
306
|
catch (err) {
|
|
307
307
|
log('ERROR', `Stray daemon reaper failed: ${err.message}`);
|
|
308
308
|
}
|
|
309
|
+
// #416: host the secrets broker socket-first — before the scheduler and the
|
|
310
|
+
// heavy browser/session-sync services — so `agents secrets` resolves within
|
|
311
|
+
// ms of daemon start. Only host when no broker is already reachable, so we
|
|
312
|
+
// never orphan a live standalone broker's clients (that broker stays the
|
|
313
|
+
// server until it idle-exits or the daemon restarts). Best-effort: a failure
|
|
314
|
+
// here must not stop the daemon. Retiring the standalone launchd service is
|
|
315
|
+
// the follow-on (#416 step 2 / #417).
|
|
316
|
+
let hostedBroker = null;
|
|
317
|
+
try {
|
|
318
|
+
const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
|
|
319
|
+
if ((await agentPing()).reachable) {
|
|
320
|
+
log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
hostedBroker = await startHostedBroker();
|
|
324
|
+
if (hostedBroker)
|
|
325
|
+
log('INFO', 'Secrets broker hosted in daemon (socket-first)');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
log('WARN', `Secrets broker host skipped: ${err.message}`);
|
|
330
|
+
}
|
|
309
331
|
const scheduler = new JobScheduler(async (config) => {
|
|
310
332
|
log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
|
|
311
333
|
try {
|
|
@@ -594,6 +616,7 @@ export async function runDaemon() {
|
|
|
594
616
|
clearTimeout(tmuxReconcileKickoff);
|
|
595
617
|
clearInterval(launchHealthInterval);
|
|
596
618
|
clearTimeout(launchHealthKickoff);
|
|
619
|
+
hostedBroker?.close();
|
|
597
620
|
removeDaemonPid();
|
|
598
621
|
removeHeartbeat();
|
|
599
622
|
process.exit(0);
|
|
Binary file
|
|
@@ -172,6 +172,27 @@ export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
|
|
|
172
172
|
export declare function runSecretsAgent(opts?: {
|
|
173
173
|
service?: boolean;
|
|
174
174
|
}): Promise<void>;
|
|
175
|
+
/**
|
|
176
|
+
* Host the secrets broker inside the always-on daemon (#416).
|
|
177
|
+
*
|
|
178
|
+
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
179
|
+
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
180
|
+
* keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
|
|
181
|
+
*
|
|
182
|
+
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
183
|
+
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
184
|
+
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
185
|
+
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
186
|
+
*
|
|
187
|
+
* The caller (`runDaemon`) must only invoke this when NO broker is already
|
|
188
|
+
* reachable (ping first) — this function clears a stale socket before binding,
|
|
189
|
+
* so calling it while a live standalone broker holds the socket would orphan
|
|
190
|
+
* that broker's clients. Returns a handle the daemon closes on shutdown, or
|
|
191
|
+
* null off-darwin (nothing to broker without biometry).
|
|
192
|
+
*/
|
|
193
|
+
export declare function startHostedBroker(): Promise<{
|
|
194
|
+
close(): void;
|
|
195
|
+
} | null>;
|
|
175
196
|
/** True if a broker socket exists at all. Cheap; gates the sync read so the
|
|
176
197
|
* never-unlocked path stays a single stat. */
|
|
177
198
|
export declare function agentSocketExists(): boolean;
|
|
@@ -246,6 +267,12 @@ export declare function agentLock(name?: string): Promise<number>;
|
|
|
246
267
|
* predates the server-side exclusion, so this keeps the internal entry from
|
|
247
268
|
* surfacing in `agents secrets status` in that skew window. */
|
|
248
269
|
export declare function agentStatus(): Promise<AgentStatusEntry[]>;
|
|
270
|
+
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
271
|
+
* version of the code it's running (for staleness detection). */
|
|
272
|
+
export declare function agentPing(): Promise<{
|
|
273
|
+
reachable: boolean;
|
|
274
|
+
cliVersion?: string;
|
|
275
|
+
}>;
|
|
249
276
|
/**
|
|
250
277
|
* Ensure a broker is running and reachable. Returns true once the socket answers
|
|
251
278
|
* a ping. macOS only.
|
|
@@ -500,6 +500,132 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
500
500
|
watcher = null;
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Host the secrets broker inside the always-on daemon (#416).
|
|
505
|
+
*
|
|
506
|
+
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
507
|
+
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
508
|
+
* keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
|
|
509
|
+
*
|
|
510
|
+
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
511
|
+
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
512
|
+
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
513
|
+
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
514
|
+
*
|
|
515
|
+
* The caller (`runDaemon`) must only invoke this when NO broker is already
|
|
516
|
+
* reachable (ping first) — this function clears a stale socket before binding,
|
|
517
|
+
* so calling it while a live standalone broker holds the socket would orphan
|
|
518
|
+
* that broker's clients. Returns a handle the daemon closes on shutdown, or
|
|
519
|
+
* null off-darwin (nothing to broker without biometry).
|
|
520
|
+
*/
|
|
521
|
+
export async function startHostedBroker() {
|
|
522
|
+
if (!onDarwin())
|
|
523
|
+
return null;
|
|
524
|
+
const store = new Map();
|
|
525
|
+
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
526
|
+
const handle = (req) => handleAgentRequest(store, req);
|
|
527
|
+
const onConn = (conn) => {
|
|
528
|
+
conn.setEncoding('utf-8');
|
|
529
|
+
let buf = '';
|
|
530
|
+
conn.on('data', (chunk) => {
|
|
531
|
+
buf += chunk;
|
|
532
|
+
let nl;
|
|
533
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
534
|
+
const line = buf.slice(0, nl);
|
|
535
|
+
buf = buf.slice(nl + 1);
|
|
536
|
+
if (!line.trim())
|
|
537
|
+
continue;
|
|
538
|
+
let resp;
|
|
539
|
+
try {
|
|
540
|
+
resp = handle(JSON.parse(line));
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
resp = { ok: false, error: err.message };
|
|
544
|
+
}
|
|
545
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
conn.on('error', () => { });
|
|
549
|
+
};
|
|
550
|
+
// Bind race-safely: never clobber a LIVE standalone broker. Try to listen; if
|
|
551
|
+
// the socket is already bound, a broker raced us after runDaemon's ping — if
|
|
552
|
+
// it answers, back off and let it serve (return null); if it's a stale socket
|
|
553
|
+
// file with no live listener, reclaim it and retry once. (Unconditionally
|
|
554
|
+
// unlinking before bind — as an earlier draft did — could remove a live
|
|
555
|
+
// broker's socket in the sub-ms window after runDaemon's reachability check.)
|
|
556
|
+
const listenOnce = () => new Promise((resolve, reject) => {
|
|
557
|
+
const s = net.createServer(onConn);
|
|
558
|
+
s.once('error', (err) => {
|
|
559
|
+
if (err.code === 'EADDRINUSE')
|
|
560
|
+
resolve('inuse');
|
|
561
|
+
else
|
|
562
|
+
reject(err);
|
|
563
|
+
});
|
|
564
|
+
s.listen(sock, () => {
|
|
565
|
+
try {
|
|
566
|
+
fs.chmodSync(sock, 0o600);
|
|
567
|
+
}
|
|
568
|
+
catch { /* dir 0700 already gates it */ }
|
|
569
|
+
resolve(s);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
let bound = await listenOnce();
|
|
573
|
+
if (bound === 'inuse') {
|
|
574
|
+
if ((await agentPing()).reachable)
|
|
575
|
+
return null; // a live broker holds it — don't clobber
|
|
576
|
+
try {
|
|
577
|
+
fs.unlinkSync(sock);
|
|
578
|
+
}
|
|
579
|
+
catch { /* gone */ }
|
|
580
|
+
bound = await listenOnce();
|
|
581
|
+
if (bound === 'inuse')
|
|
582
|
+
return null; // still contended — the standalone fallback covers it
|
|
583
|
+
}
|
|
584
|
+
const server = bound;
|
|
585
|
+
// TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
|
|
586
|
+
// self-heal-exit or idle-exit here — the daemon is always-on and owns the
|
|
587
|
+
// upgrade/lifecycle path; a broker that called process.exit() would take the
|
|
588
|
+
// whole daemon down with it.
|
|
589
|
+
const sweepTimer = setInterval(() => {
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
for (const [name, e] of store)
|
|
592
|
+
if (now >= e.expiresAt)
|
|
593
|
+
store.delete(name);
|
|
594
|
+
}, SWEEP_INTERVAL_MS);
|
|
595
|
+
// Auto-lock on sleep, same as the standalone broker: the signed helper emits
|
|
596
|
+
// LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
|
|
597
|
+
let watcher = null;
|
|
598
|
+
try {
|
|
599
|
+
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
600
|
+
watcher.stdout?.setEncoding('utf-8');
|
|
601
|
+
watcher.stdout?.on('data', (chunk) => {
|
|
602
|
+
if (shouldWipeOnWatchEvent(chunk))
|
|
603
|
+
store.clear();
|
|
604
|
+
});
|
|
605
|
+
watcher.on('error', () => { watcher = null; });
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
watcher = null;
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
close() {
|
|
612
|
+
store.clear();
|
|
613
|
+
clearInterval(sweepTimer);
|
|
614
|
+
try {
|
|
615
|
+
watcher?.kill();
|
|
616
|
+
}
|
|
617
|
+
catch { /* already gone */ }
|
|
618
|
+
try {
|
|
619
|
+
server.close();
|
|
620
|
+
}
|
|
621
|
+
catch { /* not listening */ }
|
|
622
|
+
try {
|
|
623
|
+
fs.unlinkSync(sock);
|
|
624
|
+
}
|
|
625
|
+
catch { /* gone */ }
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
503
629
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
504
630
|
/** Open the socket, send one request, resolve the one response. Async path —
|
|
505
631
|
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
@@ -766,7 +892,7 @@ export async function agentStatus() {
|
|
|
766
892
|
}
|
|
767
893
|
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
768
894
|
* version of the code it's running (for staleness detection). */
|
|
769
|
-
async function agentPing() {
|
|
895
|
+
export async function agentPing() {
|
|
770
896
|
if (!agentSocketExists())
|
|
771
897
|
return { reachable: false };
|
|
772
898
|
const r = await request({ cmd: 'ping' });
|
|
@@ -802,6 +928,23 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
802
928
|
return true;
|
|
803
929
|
await teardownStaleBroker();
|
|
804
930
|
}
|
|
931
|
+
// Path 0 (#416): prefer the always-on daemon — it hosts the broker socket
|
|
932
|
+
// (one supervised backbone rather than a separate launchd service). If
|
|
933
|
+
// bringing the daemon up makes the broker answer, we're done. Fall through to
|
|
934
|
+
// the standalone-service paths below when the daemon path isn't available
|
|
935
|
+
// (kept as a fallback until the standalone service is retired, #416 step 2).
|
|
936
|
+
try {
|
|
937
|
+
const { ensureDaemonStarted } = await import('../daemon.js');
|
|
938
|
+
if (ensureDaemonStarted()) {
|
|
939
|
+
const d0 = Date.now() + timeoutMs;
|
|
940
|
+
while (Date.now() < d0) {
|
|
941
|
+
if ((await agentPing()).reachable)
|
|
942
|
+
return true;
|
|
943
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
catch { /* daemon path unavailable — fall through to the standalone service */ }
|
|
805
948
|
// Path 1: the persistent service. installSecretsAgentService is idempotent and
|
|
806
949
|
// waits for the socket; for an already-installed service we kickstart and wait.
|
|
807
950
|
try {
|
|
@@ -301,6 +301,22 @@ export declare class AgentManager {
|
|
|
301
301
|
rescanFromDisk(): Promise<number>;
|
|
302
302
|
private loadExistingAgents;
|
|
303
303
|
spawn(taskName: string, agentType: AgentType, prompt: string, cwd?: string | null, mode?: Mode | null, effort?: EffortLevel, parentSessionId?: string | null, workspaceDir?: string | null, version?: string | null, name?: string | null, after?: string[], model?: string | null, envOverrides?: Record<string, string> | null, taskType?: TaskType | null, cloudProvider?: string | null, cloudSessionId?: string | null, cloudRepo?: string | null, cloudBranch?: string | null, worktreeName?: string | null, worktreePath?: string | null, profileName?: string | null, hostName?: string | null, hostTarget?: string | null, repoPath?: string | null): Promise<AgentProcess>;
|
|
304
|
+
/**
|
|
305
|
+
* Resume a STOPPED teammate (completed / failed / stopped) by re-entering its
|
|
306
|
+
* own session with `message` as the next user turn. Re-launches through the
|
|
307
|
+
* SAME backend the teammate first used (local process or remote host), reusing
|
|
308
|
+
* its stored cwd / worktree / host / version / model / effort, and flips it
|
|
309
|
+
* back to RUNNING so the team tracks it live again.
|
|
310
|
+
*
|
|
311
|
+
* The resume target is the teammate's underlying agent session id: for Claude
|
|
312
|
+
* that IS its agent_id (unified identity, pinned via --session-id at first
|
|
313
|
+
* launch); other harnesses only expose their session/thread id after their
|
|
314
|
+
* first stream event, captured as `remoteSessionId`.
|
|
315
|
+
*
|
|
316
|
+
* Callers branch on status first — a RUNNING teammate is steered via its
|
|
317
|
+
* mailbox, never re-launched — so this method assumes a non-running teammate.
|
|
318
|
+
*/
|
|
319
|
+
resumeTeammate(agentId: string, message: string): Promise<AgentProcess>;
|
|
304
320
|
/**
|
|
305
321
|
* Actually spawn the OS process for a teammate. Extracted from spawn() so
|
|
306
322
|
* staged teammates can be launched later by startReady().
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -1357,11 +1357,69 @@ export class AgentManager {
|
|
|
1357
1357
|
await this.cleanupOldAgents();
|
|
1358
1358
|
return agent;
|
|
1359
1359
|
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Resume a STOPPED teammate (completed / failed / stopped) by re-entering its
|
|
1362
|
+
* own session with `message` as the next user turn. Re-launches through the
|
|
1363
|
+
* SAME backend the teammate first used (local process or remote host), reusing
|
|
1364
|
+
* its stored cwd / worktree / host / version / model / effort, and flips it
|
|
1365
|
+
* back to RUNNING so the team tracks it live again.
|
|
1366
|
+
*
|
|
1367
|
+
* The resume target is the teammate's underlying agent session id: for Claude
|
|
1368
|
+
* that IS its agent_id (unified identity, pinned via --session-id at first
|
|
1369
|
+
* launch); other harnesses only expose their session/thread id after their
|
|
1370
|
+
* first stream event, captured as `remoteSessionId`.
|
|
1371
|
+
*
|
|
1372
|
+
* Callers branch on status first — a RUNNING teammate is steered via its
|
|
1373
|
+
* mailbox, never re-launched — so this method assumes a non-running teammate.
|
|
1374
|
+
*/
|
|
1375
|
+
async resumeTeammate(agentId, message) {
|
|
1376
|
+
await this.initialize();
|
|
1377
|
+
const agent = await this.get(agentId);
|
|
1378
|
+
if (!agent)
|
|
1379
|
+
throw new Error(`No teammate with id ${agentId}`);
|
|
1380
|
+
const who = agent.name ?? agent.agentId.slice(0, 8);
|
|
1381
|
+
// The message rides as `agents run`'s prompt positional. A leading '-' makes
|
|
1382
|
+
// commander parse it as an (unknown) flag, exiting the child non-zero — the
|
|
1383
|
+
// teammate would silently land FAILED. `--` can't rescue it: `agents run`
|
|
1384
|
+
// treats post-`--` tokens as native passthrough and unsets the prompt. Fail
|
|
1385
|
+
// loud and early instead. (Steer/mailbox delivery has no such limit.)
|
|
1386
|
+
if (message.startsWith('-')) {
|
|
1387
|
+
throw new Error(`Resume message can't start with '-' — \`agents run\` would parse it as a flag. ` +
|
|
1388
|
+
`Rephrase so it leads with a word (e.g. "Please ${message}").`);
|
|
1389
|
+
}
|
|
1390
|
+
// Cloud-backed teammates run on remote provider infrastructure with no local
|
|
1391
|
+
// or host process to re-launch; continuing them goes through the provider.
|
|
1392
|
+
if (agent.cloudProvider) {
|
|
1393
|
+
throw new Error(`Teammate '${who}' is a ${agent.cloudProvider} cloud task — resume it with ` +
|
|
1394
|
+
`\`agents message ${agent.cloudSessionId ?? agent.agentId} "<message>"\` instead.`);
|
|
1395
|
+
}
|
|
1396
|
+
// For non-Claude teammates the agent_id is NOT the harness session id — that
|
|
1397
|
+
// is only known once the agent emitted its first stream event. If it never
|
|
1398
|
+
// did (e.g. it failed before its first turn), there is no resumable handle.
|
|
1399
|
+
if (agent.agentType !== 'claude' && !agent.remoteSessionId) {
|
|
1400
|
+
throw new Error(`No resumable session id was captured for ${agent.agentType} teammate '${who}' — ` +
|
|
1401
|
+
`its session id is discovered from the agent's own output, which never arrived ` +
|
|
1402
|
+
`(it may have failed before its first turn). Start a fresh teammate instead.`);
|
|
1403
|
+
}
|
|
1404
|
+
const resume = { id: agent.remoteSessionId ?? agent.agentId, message };
|
|
1405
|
+
// Flip to RUNNING up front so a concurrent status poll can't reap the
|
|
1406
|
+
// teammate between the exit-sentinel clear and the new PID landing; the
|
|
1407
|
+
// launch re-persists with the fresh pid/startTime.
|
|
1408
|
+
agent.status = AgentStatus.RUNNING;
|
|
1409
|
+
agent.completedAt = null;
|
|
1410
|
+
if (agent.hostName) {
|
|
1411
|
+
await this.launchRemoteProcess(agent, resume);
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
await this.launchProcess(agent, resume);
|
|
1415
|
+
}
|
|
1416
|
+
return agent;
|
|
1417
|
+
}
|
|
1360
1418
|
/**
|
|
1361
1419
|
* Actually spawn the OS process for a teammate. Extracted from spawn() so
|
|
1362
1420
|
* staged teammates can be launched later by startReady().
|
|
1363
1421
|
*/
|
|
1364
|
-
async launchProcess(agent) {
|
|
1422
|
+
async launchProcess(agent, resume) {
|
|
1365
1423
|
const running = await this.listRunning();
|
|
1366
1424
|
warnIfMemoryLow(running.length);
|
|
1367
1425
|
const effort = agent.effort ?? 'medium';
|
|
@@ -1369,10 +1427,20 @@ export class AgentManager {
|
|
|
1369
1427
|
// forwarded). Effort is a separate knob wired into buildReasoningFlags
|
|
1370
1428
|
// inside buildCommand.
|
|
1371
1429
|
const resolvedModel = agent.model ?? null;
|
|
1372
|
-
const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName);
|
|
1373
|
-
debug(`Launching ${agent.agentType} agent ${agent.agentId} [${agent.mode}]: ${cmd.slice(0, 3).join(' ')}...`);
|
|
1430
|
+
const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName, resume);
|
|
1431
|
+
debug(`Launching ${agent.agentType} agent ${agent.agentId} [${agent.mode}]${resume ? ' (resume)' : ''}: ${cmd.slice(0, 3).join(' ')}...`);
|
|
1374
1432
|
try {
|
|
1375
1433
|
const stdoutPath = await agent.getStdoutPath();
|
|
1434
|
+
// Always TRUNCATE — including on resume. The status reader re-reads the
|
|
1435
|
+
// whole log from byte 0 every poll (lastReadPos is in-memory, not
|
|
1436
|
+
// persisted) and marks terminal status from the last `result` event it
|
|
1437
|
+
// sees, with no liveness guard. If the resumed turn's stream were appended
|
|
1438
|
+
// after the prior turn's `result:success`, that stale event would win for
|
|
1439
|
+
// the entire duration of the new (still-running) turn — reporting the
|
|
1440
|
+
// teammate COMPLETED while it works, and steering a second follow-up into
|
|
1441
|
+
// a forked session. Truncating keeps exactly one turn in the log, so the
|
|
1442
|
+
// re-read is always correct. The authoritative transcript lives in the
|
|
1443
|
+
// agent's own session (resumed via --resume), not this stdout mirror.
|
|
1376
1444
|
const stdoutFile = await fs.open(stdoutPath, 'w');
|
|
1377
1445
|
const stdoutFd = stdoutFile.fd;
|
|
1378
1446
|
// Wrap the teammate command in a shell that records the underlying CLI's
|
|
@@ -1424,7 +1492,7 @@ export class AgentManager {
|
|
|
1424
1492
|
* created ON THE HOST off the freshly-fetched default branch; the teammate runs
|
|
1425
1493
|
* there. Otherwise it runs in the host repo path directly.
|
|
1426
1494
|
*/
|
|
1427
|
-
async launchRemoteProcess(agent) {
|
|
1495
|
+
async launchRemoteProcess(agent, resume) {
|
|
1428
1496
|
if (!agent.hostName || !agent.hostTarget || !agent.repoPath) {
|
|
1429
1497
|
throw new Error(`Remote teammate ${agent.agentId} is missing host placement (host/target/repo).`);
|
|
1430
1498
|
}
|
|
@@ -1448,17 +1516,25 @@ export class AgentManager {
|
|
|
1448
1516
|
}
|
|
1449
1517
|
// Worktree isolation on the host, if the team enables it. createRemoteWorktree
|
|
1450
1518
|
// fetches origin and branches off origin/<default>, returning the host path.
|
|
1519
|
+
// On RESUME the worktree already exists from the original launch — reuse it
|
|
1520
|
+
// (its path is persisted) instead of re-creating (which would fail on the
|
|
1521
|
+
// existing branch and would also discard the teammate's in-progress work).
|
|
1451
1522
|
let remoteCwd = agent.repoPath;
|
|
1452
1523
|
if (agent.worktreeName) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1524
|
+
if (resume && agent.worktreePath) {
|
|
1525
|
+
remoteCwd = agent.worktreePath;
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
|
|
1529
|
+
agent.worktreePath = worktreePath;
|
|
1530
|
+
remoteCwd = worktreePath;
|
|
1531
|
+
}
|
|
1456
1532
|
}
|
|
1457
1533
|
// Same run argv the local path builds (shared buildRunArgv keeps the prompt
|
|
1458
1534
|
// scaffolding + flags from drifting); dispatched non-blocking (follow:false)
|
|
1459
1535
|
// — the supervisor polls the host, we don't block here.
|
|
1460
1536
|
const effort = agent.effort ?? 'medium';
|
|
1461
|
-
const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName);
|
|
1537
|
+
const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName, resume);
|
|
1462
1538
|
try {
|
|
1463
1539
|
const { task } = await dispatchAgentsCommand(host, {
|
|
1464
1540
|
forwardedArgs,
|
|
@@ -1469,6 +1545,13 @@ export class AgentManager {
|
|
|
1469
1545
|
agent.remoteLog = task.remoteLog ?? null;
|
|
1470
1546
|
agent.remoteExit = task.remoteExit ?? null;
|
|
1471
1547
|
agent.remoteLogOffset = 0;
|
|
1548
|
+
// On resume the offset resets to 0 against a FRESH remote log, and
|
|
1549
|
+
// syncRemoteMirror appends the delta onto the local mirror. Truncate that
|
|
1550
|
+
// mirror first so the prior turn's terminal event can't linger and get
|
|
1551
|
+
// re-read as the current status (same hazard the local path truncates for).
|
|
1552
|
+
if (resume) {
|
|
1553
|
+
await fs.writeFile(await agent.getStdoutPath(), '').catch(() => { });
|
|
1554
|
+
}
|
|
1472
1555
|
agent.status = AgentStatus.RUNNING;
|
|
1473
1556
|
agent.startedAt = new Date();
|
|
1474
1557
|
await agent.saveMeta();
|
|
@@ -1670,38 +1753,46 @@ export class AgentManager {
|
|
|
1670
1753
|
* before invoking `agents`. `sessionId` is likewise local-only (the remote run
|
|
1671
1754
|
* mints its own session on the host).
|
|
1672
1755
|
*/
|
|
1673
|
-
buildRunArgv(agentType, prompt, mode, model, effort, version, profileName) {
|
|
1674
|
-
// Compose the prompt
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1756
|
+
buildRunArgv(agentType, prompt, mode, model, effort, version, profileName, resume) {
|
|
1757
|
+
// Compose the prompt. On RESUME the message is the teammate's next user turn,
|
|
1758
|
+
// not a fresh brief — so skip the original brief and the plan-mode prefix, but
|
|
1759
|
+
// keep PROMPT_SUFFIX so the resumed run still emits a final summary the team
|
|
1760
|
+
// parser reads. On a fresh launch, add the plan-mode prefix for Claude and the
|
|
1761
|
+
// universal summary suffix. These are team-specific prompt scaffolding —
|
|
1762
|
+
// `agents run` does not apply them.
|
|
1763
|
+
let fullPrompt;
|
|
1764
|
+
if (resume) {
|
|
1765
|
+
fullPrompt = resume.message + PROMPT_SUFFIX;
|
|
1766
|
+
}
|
|
1767
|
+
else {
|
|
1768
|
+
fullPrompt = prompt + PROMPT_SUFFIX;
|
|
1769
|
+
if (agentType === 'claude' && mode === 'plan') {
|
|
1770
|
+
fullPrompt = CLAUDE_PLAN_MODE_PREFIX + fullPrompt;
|
|
1771
|
+
}
|
|
1680
1772
|
}
|
|
1681
1773
|
// Profile target takes precedence — `agents run <profile>` resolves the
|
|
1682
1774
|
// host harness, version pin, and env injection in one place. Plain
|
|
1683
1775
|
// version pins only apply when no profile is selected.
|
|
1684
1776
|
const target = profileName ?? (version ? `${agentType}@${version}` : agentType);
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
'--
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
];
|
|
1777
|
+
// Keep the prompt as the first positional (right after target), matching the
|
|
1778
|
+
// fresh-launch shape, and add `--resume <id>` among the flags. `agents run`
|
|
1779
|
+
// continues the teammate's own session natively (claude `--resume`, codex
|
|
1780
|
+
// `resume`) or via the universal `/continue` replay for other harnesses.
|
|
1781
|
+
const args = ['run', target, fullPrompt];
|
|
1782
|
+
if (resume) {
|
|
1783
|
+
args.push('--resume', resume.id);
|
|
1784
|
+
}
|
|
1785
|
+
args.push('--mode', mode, '--effort', effort, '--json', '--headless', '--quiet');
|
|
1695
1786
|
if (model)
|
|
1696
1787
|
args.push('--model', model);
|
|
1697
1788
|
args.push('--env', 'AGENTS_RUNTIME=teams');
|
|
1698
1789
|
return args;
|
|
1699
1790
|
}
|
|
1700
|
-
buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
|
|
1791
|
+
buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null, resume) {
|
|
1701
1792
|
// Route through getAgentsInvocation so a teammate launched by the compiled
|
|
1702
1793
|
// standalone binary (#315) doesn't relaunch as `agents /$bunfs/root/agents …`
|
|
1703
1794
|
// (process.argv[1] is the bun virtual entry there) → "unknown command".
|
|
1704
|
-
const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName));
|
|
1795
|
+
const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName, resume));
|
|
1705
1796
|
const cmd = [inv.command, ...inv.args];
|
|
1706
1797
|
if (cwd)
|
|
1707
1798
|
cmd.push('--cwd', cwd);
|
|
@@ -1709,7 +1800,9 @@ export class AgentManager {
|
|
|
1709
1800
|
// AGENTS_MAILBOX_DIR by the same id mailboxIdForActiveSession returns.
|
|
1710
1801
|
// Claude also forwards --session-id to its CLI (unified identity);
|
|
1711
1802
|
// other agents ignore the flag but still get the correct mailbox dir.
|
|
1712
|
-
|
|
1803
|
+
// On RESUME we continue an existing session — `--session-id` CREATES one and
|
|
1804
|
+
// `agents run` rejects it alongside `--resume`, so it must be omitted.
|
|
1805
|
+
if (sessionId && !resume) {
|
|
1713
1806
|
cmd.push('--session-id', sessionId);
|
|
1714
1807
|
}
|
|
1715
1808
|
// Claude: grant access to the teammate's working directory.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.57",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|