@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 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 "Nh left"
70
- * hint when a `daily` bundle is currently held by the secrets-agent. `held`
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. */
@@ -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 "Nh left"
391
- * hint when a `daily` bundle is currently held by the secrets-agent. `held`
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 · NO ACL');
397
+ return chalk.red.bold('never · no prompt');
397
398
  if (bundlePolicy(b) === 'always')
398
- return chalk.yellow('always ask');
399
+ return chalk.yellow('always · prompt');
399
400
  const exp = held?.get(b.name);
400
- return exp ? chalk.green(`daily · ${compactRemaining(exp)} left`) : chalk.gray('daily');
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 left". Soft-fails to no hint if the broker is down.
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 {
@@ -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
@@ -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. Can be restarted later. Cleans up worktree if no uncommitted changes.')
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]')
@@ -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);
@@ -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().
@@ -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
- const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1454
- agent.worktreePath = worktreePath;
1455
- remoteCwd = worktreePath;
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: a plan-mode prefix for Claude (clarifying headless
1675
- // plan-mode restrictions) and a universal summary suffix. These are
1676
- // team-specific prompt scaffolding `agents run` does not apply them.
1677
- let fullPrompt = prompt + PROMPT_SUFFIX;
1678
- if (agentType === 'claude' && mode === 'plan') {
1679
- fullPrompt = CLAUDE_PLAN_MODE_PREFIX + fullPrompt;
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
- const args = [
1686
- 'run',
1687
- target,
1688
- fullPrompt,
1689
- '--mode', mode,
1690
- '--effort', effort,
1691
- '--json',
1692
- '--headless',
1693
- '--quiet',
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
- if (sessionId) {
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.56",
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",