@phnx-labs/agents-cli 1.20.55 → 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.
@@ -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().
@@ -8,6 +8,7 @@
8
8
  * multiple permission modes (plan, edit, full).
9
9
  */
10
10
  import { spawn, execSync, execFileSync } from 'child_process';
11
+ import { getAgentsInvocation } from '../daemon.js';
11
12
  import * as fs from 'fs/promises';
12
13
  import * as fsSync from 'fs';
13
14
  import * as path from 'path';
@@ -1356,11 +1357,69 @@ export class AgentManager {
1356
1357
  await this.cleanupOldAgents();
1357
1358
  return agent;
1358
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
+ }
1359
1418
  /**
1360
1419
  * Actually spawn the OS process for a teammate. Extracted from spawn() so
1361
1420
  * staged teammates can be launched later by startReady().
1362
1421
  */
1363
- async launchProcess(agent) {
1422
+ async launchProcess(agent, resume) {
1364
1423
  const running = await this.listRunning();
1365
1424
  warnIfMemoryLow(running.length);
1366
1425
  const effort = agent.effort ?? 'medium';
@@ -1368,10 +1427,20 @@ export class AgentManager {
1368
1427
  // forwarded). Effort is a separate knob wired into buildReasoningFlags
1369
1428
  // inside buildCommand.
1370
1429
  const resolvedModel = agent.model ?? null;
1371
- const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName);
1372
- 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(' ')}...`);
1373
1432
  try {
1374
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.
1375
1444
  const stdoutFile = await fs.open(stdoutPath, 'w');
1376
1445
  const stdoutFd = stdoutFile.fd;
1377
1446
  // Wrap the teammate command in a shell that records the underlying CLI's
@@ -1423,7 +1492,7 @@ export class AgentManager {
1423
1492
  * created ON THE HOST off the freshly-fetched default branch; the teammate runs
1424
1493
  * there. Otherwise it runs in the host repo path directly.
1425
1494
  */
1426
- async launchRemoteProcess(agent) {
1495
+ async launchRemoteProcess(agent, resume) {
1427
1496
  if (!agent.hostName || !agent.hostTarget || !agent.repoPath) {
1428
1497
  throw new Error(`Remote teammate ${agent.agentId} is missing host placement (host/target/repo).`);
1429
1498
  }
@@ -1447,17 +1516,25 @@ export class AgentManager {
1447
1516
  }
1448
1517
  // Worktree isolation on the host, if the team enables it. createRemoteWorktree
1449
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).
1450
1522
  let remoteCwd = agent.repoPath;
1451
1523
  if (agent.worktreeName) {
1452
- const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1453
- agent.worktreePath = worktreePath;
1454
- 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
+ }
1455
1532
  }
1456
1533
  // Same run argv the local path builds (shared buildRunArgv keeps the prompt
1457
1534
  // scaffolding + flags from drifting); dispatched non-blocking (follow:false)
1458
1535
  // — the supervisor polls the host, we don't block here.
1459
1536
  const effort = agent.effort ?? 'medium';
1460
- 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);
1461
1538
  try {
1462
1539
  const { task } = await dispatchAgentsCommand(host, {
1463
1540
  forwardedArgs,
@@ -1468,6 +1545,13 @@ export class AgentManager {
1468
1545
  agent.remoteLog = task.remoteLog ?? null;
1469
1546
  agent.remoteExit = task.remoteExit ?? null;
1470
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
+ }
1471
1555
  agent.status = AgentStatus.RUNNING;
1472
1556
  agent.startedAt = new Date();
1473
1557
  await agent.saveMeta();
@@ -1669,47 +1753,56 @@ export class AgentManager {
1669
1753
  * before invoking `agents`. `sessionId` is likewise local-only (the remote run
1670
1754
  * mints its own session on the host).
1671
1755
  */
1672
- buildRunArgv(agentType, prompt, mode, model, effort, version, profileName) {
1673
- // Compose the prompt: a plan-mode prefix for Claude (clarifying headless
1674
- // plan-mode restrictions) and a universal summary suffix. These are
1675
- // team-specific prompt scaffolding `agents run` does not apply them.
1676
- let fullPrompt = prompt + PROMPT_SUFFIX;
1677
- if (agentType === 'claude' && mode === 'plan') {
1678
- 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
+ }
1679
1772
  }
1680
1773
  // Profile target takes precedence — `agents run <profile>` resolves the
1681
1774
  // host harness, version pin, and env injection in one place. Plain
1682
1775
  // version pins only apply when no profile is selected.
1683
1776
  const target = profileName ?? (version ? `${agentType}@${version}` : agentType);
1684
- const args = [
1685
- 'run',
1686
- target,
1687
- fullPrompt,
1688
- '--mode', mode,
1689
- '--effort', effort,
1690
- '--json',
1691
- '--headless',
1692
- '--quiet',
1693
- ];
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');
1694
1786
  if (model)
1695
1787
  args.push('--model', model);
1696
1788
  args.push('--env', 'AGENTS_RUNTIME=teams');
1697
1789
  return args;
1698
1790
  }
1699
- buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1700
- const agentsCli = process.argv[1];
1701
- const cmd = [
1702
- process.execPath,
1703
- agentsCli,
1704
- ...this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName),
1705
- ];
1791
+ buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null, resume) {
1792
+ // Route through getAgentsInvocation so a teammate launched by the compiled
1793
+ // standalone binary (#315) doesn't relaunch as `agents /$bunfs/root/agents …`
1794
+ // (process.argv[1] is the bun virtual entry there) → "unknown command".
1795
+ const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName, resume));
1796
+ const cmd = [inv.command, ...inv.args];
1706
1797
  if (cwd)
1707
1798
  cmd.push('--cwd', cwd);
1708
1799
  // Pin the session UUID to our agent_id so buildExecEnv keys
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.55",
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",