@phnx-labs/agents-cli 1.20.56 → 1.20.58

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +34 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +28 -4
  6. package/dist/commands/secrets.d.ts +3 -2
  7. package/dist/commands/secrets.js +35 -25
  8. package/dist/commands/teams.d.ts +20 -1
  9. package/dist/commands/teams.js +105 -2
  10. package/dist/commands/versions.js +11 -3
  11. package/dist/commands/view.js +19 -4
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +28 -4
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +88 -17
  16. package/dist/lib/git.d.ts +9 -0
  17. package/dist/lib/git.js +12 -0
  18. package/dist/lib/hosts/dispatch.d.ts +21 -0
  19. package/dist/lib/hosts/dispatch.js +88 -5
  20. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  21. package/dist/lib/permissions.d.ts +19 -1
  22. package/dist/lib/permissions.js +137 -0
  23. package/dist/lib/project-root.d.ts +65 -0
  24. package/dist/lib/project-root.js +133 -0
  25. package/dist/lib/resources/permissions.js +2 -0
  26. package/dist/lib/resources/types.d.ts +1 -1
  27. package/dist/lib/secrets/agent.d.ts +48 -18
  28. package/dist/lib/secrets/agent.js +288 -165
  29. package/dist/lib/secrets/remote.js +1 -0
  30. package/dist/lib/session/active.d.ts +3 -0
  31. package/dist/lib/session/active.js +1 -0
  32. package/dist/lib/session/parse.js +38 -15
  33. package/dist/lib/session/state.d.ts +4 -1
  34. package/dist/lib/session/state.js +18 -1
  35. package/dist/lib/session/types.d.ts +8 -0
  36. package/dist/lib/staleness/detectors/permissions.js +42 -0
  37. package/dist/lib/staleness/detectors/subagents.js +30 -0
  38. package/dist/lib/staleness/writers/subagents.js +13 -1
  39. package/dist/lib/subagents.d.ts +22 -0
  40. package/dist/lib/subagents.js +146 -0
  41. package/dist/lib/teams/agents.d.ts +30 -0
  42. package/dist/lib/teams/agents.js +271 -42
  43. package/dist/lib/types.d.ts +13 -0
  44. package/dist/lib/versions.d.ts +39 -0
  45. package/dist/lib/versions.js +199 -12
  46. package/package.json +1 -1
  47. package/scripts/postinstall.js +26 -11
@@ -27,7 +27,7 @@ import { recordRunName } from '../session/run-names.js';
27
27
  import { sshExec, shellQuote } from '../ssh-exec.js';
28
28
  import { resolveHost } from '../hosts/registry.js';
29
29
  import { sshTargetFor } from '../hosts/types.js';
30
- import { dispatchAgentsCommand } from '../hosts/dispatch.js';
30
+ import { dispatchAgentsCommand, terminateDispatchedTask } from '../hosts/dispatch.js';
31
31
  import { ensureHostReady } from '../hosts/ready.js';
32
32
  import { remoteShellFor } from '../hosts/remote-cmd.js';
33
33
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -746,6 +746,16 @@ export class AgentProcess {
746
746
  }
747
747
  }
748
748
  }
749
+ /** Reset the local stdout cursor for a newly truncated resume log. */
750
+ resetLogReadPosition() {
751
+ const previous = this.lastReadPos;
752
+ this.lastReadPos = 0;
753
+ return previous;
754
+ }
755
+ /** Restore the cursor when a resume transaction puts the prior log back. */
756
+ restoreLogReadPosition(position) {
757
+ this.lastReadPos = position;
758
+ }
749
759
  async readNewEvents() {
750
760
  // Distributed teammate: mirror the host's new log bytes locally first, then
751
761
  // fall through to the identical local read+parse below.
@@ -1107,6 +1117,62 @@ export class AgentProcess {
1107
1117
  }
1108
1118
  }
1109
1119
  }
1120
+ export async function beginResumeLogTransaction(agent) {
1121
+ const stdoutPath = await agent.getStdoutPath();
1122
+ const backupPath = `${stdoutPath}.resume-backup-${randomUUID()}`;
1123
+ let hadOriginal = false;
1124
+ try {
1125
+ await fs.rename(stdoutPath, backupPath);
1126
+ hadOriginal = true;
1127
+ }
1128
+ catch (err) {
1129
+ if (err?.code !== 'ENOENT')
1130
+ throw err;
1131
+ }
1132
+ const previousReadPos = agent.resetLogReadPosition();
1133
+ return { agent, stdoutPath, backupPath, hadOriginal, previousReadPos };
1134
+ }
1135
+ export async function commitResumeLogTransaction(transaction) {
1136
+ if (transaction.hadOriginal)
1137
+ await fs.rm(transaction.backupPath, { force: true });
1138
+ }
1139
+ async function rollbackResumeLogTransaction(transaction) {
1140
+ try {
1141
+ await fs.rm(transaction.stdoutPath, { force: true });
1142
+ if (transaction.hadOriginal) {
1143
+ await fs.rename(transaction.backupPath, transaction.stdoutPath);
1144
+ }
1145
+ }
1146
+ finally {
1147
+ transaction.agent.restoreLogReadPosition(transaction.previousReadPos);
1148
+ }
1149
+ }
1150
+ export async function terminateSpawnedProcess(pid) {
1151
+ try {
1152
+ process.kill(-pid, 'SIGTERM');
1153
+ }
1154
+ catch (err) {
1155
+ if (err?.code === 'ESRCH')
1156
+ return;
1157
+ throw err;
1158
+ }
1159
+ await new Promise(resolve => setTimeout(resolve, 250));
1160
+ try {
1161
+ process.kill(-pid, 0);
1162
+ }
1163
+ catch (err) {
1164
+ if (err?.code === 'ESRCH')
1165
+ return;
1166
+ throw err;
1167
+ }
1168
+ try {
1169
+ process.kill(-pid, 'SIGKILL');
1170
+ }
1171
+ catch (err) {
1172
+ if (err?.code !== 'ESRCH')
1173
+ throw err;
1174
+ }
1175
+ }
1110
1176
  export class AgentManager {
1111
1177
  agents = new Map();
1112
1178
  maxAgents;
@@ -1357,11 +1423,95 @@ export class AgentManager {
1357
1423
  await this.cleanupOldAgents();
1358
1424
  return agent;
1359
1425
  }
1426
+ /**
1427
+ * Resume a STOPPED teammate (completed / failed / stopped) by re-entering its
1428
+ * own session with `message` as the next user turn. Re-launches through the
1429
+ * SAME backend the teammate first used (local process or remote host), reusing
1430
+ * its stored cwd / worktree / host / version / model / effort, and flips it
1431
+ * back to RUNNING so the team tracks it live again.
1432
+ *
1433
+ * The resume target is the teammate's underlying agent session id: for Claude
1434
+ * that IS its agent_id (unified identity, pinned via --session-id at first
1435
+ * launch); other harnesses only expose their session/thread id after their
1436
+ * first stream event, captured as `remoteSessionId`.
1437
+ *
1438
+ * Callers branch on status first — a RUNNING teammate is steered via its
1439
+ * mailbox, never re-launched — so this method assumes a non-running teammate.
1440
+ */
1441
+ async resumeTeammate(agentId, message) {
1442
+ await this.initialize();
1443
+ const agent = await this.get(agentId);
1444
+ if (!agent)
1445
+ throw new Error(`No teammate with id ${agentId}`);
1446
+ const who = agent.name ?? agent.agentId.slice(0, 8);
1447
+ // The message rides as `agents run`'s prompt positional. A leading '-' makes
1448
+ // commander parse it as an (unknown) flag, exiting the child non-zero — the
1449
+ // teammate would silently land FAILED. `--` can't rescue it: `agents run`
1450
+ // treats post-`--` tokens as native passthrough and unsets the prompt. Fail
1451
+ // loud and early instead. (Steer/mailbox delivery has no such limit.)
1452
+ if (message.startsWith('-')) {
1453
+ throw new Error(`Resume message can't start with '-' — \`agents run\` would parse it as a flag. ` +
1454
+ `Rephrase so it leads with a word (e.g. "Please ${message}").`);
1455
+ }
1456
+ // Cloud-backed teammates run on remote provider infrastructure with no local
1457
+ // or host process to re-launch; continuing them goes through the provider.
1458
+ if (agent.cloudProvider) {
1459
+ throw new Error(`Teammate '${who}' is a ${agent.cloudProvider} cloud task — resume it with ` +
1460
+ `\`agents message ${agent.cloudSessionId ?? agent.agentId} "<message>"\` instead.`);
1461
+ }
1462
+ // For non-Claude teammates the agent_id is NOT the harness session id — that
1463
+ // is only known once the agent emitted its first stream event. If it never
1464
+ // did (e.g. it failed before its first turn), there is no resumable handle.
1465
+ if (agent.agentType !== 'claude' && !agent.remoteSessionId) {
1466
+ throw new Error(`No resumable session id was captured for ${agent.agentType} teammate '${who}' — ` +
1467
+ `its session id is discovered from the agent's own output, which never arrived ` +
1468
+ `(it may have failed before its first turn). Start a fresh teammate instead.`);
1469
+ }
1470
+ const resume = { id: agent.remoteSessionId ?? agent.agentId, message };
1471
+ const priorRuntime = {
1472
+ status: agent.status,
1473
+ completedAt: agent.completedAt,
1474
+ pid: agent.pid,
1475
+ startTime: agent.startTime,
1476
+ startedAt: agent.startedAt,
1477
+ remotePid: agent.remotePid,
1478
+ remoteLog: agent.remoteLog,
1479
+ remoteExit: agent.remoteExit,
1480
+ remoteLogOffset: agent.remoteLogOffset,
1481
+ worktreePath: agent.worktreePath,
1482
+ };
1483
+ // Flip to RUNNING up front so a concurrent status poll can't reap the
1484
+ // teammate between the exit-sentinel clear and the new PID landing; the
1485
+ // launch re-persists with the fresh pid/startTime. If relaunch fails before
1486
+ // that happens, restore the stopped lifecycle state and keep its existing
1487
+ // metadata/log directory intact so the user can retry.
1488
+ agent.status = AgentStatus.RUNNING;
1489
+ agent.completedAt = null;
1490
+ try {
1491
+ if (agent.hostName) {
1492
+ await this.launchRemoteProcess(agent, resume);
1493
+ }
1494
+ else {
1495
+ await this.launchProcess(agent, resume);
1496
+ }
1497
+ }
1498
+ catch (err) {
1499
+ Object.assign(agent, priorRuntime);
1500
+ try {
1501
+ await agent.saveMeta();
1502
+ }
1503
+ catch (restoreErr) {
1504
+ throw new Error(`Failed to resume teammate: ${err.message}; restoring stopped state also failed: ${restoreErr.message}`, { cause: err });
1505
+ }
1506
+ throw err;
1507
+ }
1508
+ return agent;
1509
+ }
1360
1510
  /**
1361
1511
  * Actually spawn the OS process for a teammate. Extracted from spawn() so
1362
1512
  * staged teammates can be launched later by startReady().
1363
1513
  */
1364
- async launchProcess(agent) {
1514
+ async launchProcess(agent, resume) {
1365
1515
  const running = await this.listRunning();
1366
1516
  warnIfMemoryLow(running.length);
1367
1517
  const effort = agent.effort ?? 'medium';
@@ -1369,11 +1519,26 @@ export class AgentManager {
1369
1519
  // forwarded). Effort is a separate knob wired into buildReasoningFlags
1370
1520
  // inside buildCommand.
1371
1521
  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(' ')}...`);
1522
+ const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName, resume);
1523
+ debug(`Launching ${agent.agentType} agent ${agent.agentId} [${agent.mode}]${resume ? ' (resume)' : ''}: ${cmd.slice(0, 3).join(' ')}...`);
1524
+ let childProcess = null;
1525
+ let stdoutFile = null;
1526
+ let resumeLog = null;
1374
1527
  try {
1375
- const stdoutPath = await agent.getStdoutPath();
1376
- const stdoutFile = await fs.open(stdoutPath, 'w');
1528
+ if (resume)
1529
+ resumeLog = await beginResumeLogTransaction(agent);
1530
+ const stdoutPath = resumeLog?.stdoutPath ?? await agent.getStdoutPath();
1531
+ // Always TRUNCATE — including on resume. The status reader re-reads the
1532
+ // whole log from byte 0 every poll (lastReadPos is in-memory, not
1533
+ // persisted) and marks terminal status from the last `result` event it
1534
+ // sees, with no liveness guard. If the resumed turn's stream were appended
1535
+ // after the prior turn's `result:success`, that stale event would win for
1536
+ // the entire duration of the new (still-running) turn — reporting the
1537
+ // teammate COMPLETED while it works, and steering a second follow-up into
1538
+ // a forked session. Truncating keeps exactly one turn in the log, so the
1539
+ // re-read is always correct. The authoritative transcript lives in the
1540
+ // agent's own session (resumed via --resume), not this stdout mirror.
1541
+ stdoutFile = await fs.open(stdoutPath, 'w');
1377
1542
  const stdoutFd = stdoutFile.fd;
1378
1543
  // Wrap the teammate command in a shell that records the underlying CLI's
1379
1544
  // exit code to a sentinel file. Detached + unref()'d children can't be
@@ -1387,7 +1552,7 @@ export class AgentManager {
1387
1552
  const wrappedCmd = buildSentinelCommand(cmd, exitCodePath);
1388
1553
  // detached:true makes the shell the process-group leader, so stop()'s
1389
1554
  // `kill(-pid)` still reaches the underlying CLI through the group.
1390
- const childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1555
+ childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1391
1556
  stdio: ['ignore', stdoutFd, stdoutFd],
1392
1557
  cwd: agent.cwd || undefined,
1393
1558
  detached: true,
@@ -1395,8 +1560,13 @@ export class AgentManager {
1395
1560
  ? { ...sanitizeProcessEnv(process.env), ...agent.envOverrides }
1396
1561
  : sanitizeProcessEnv(process.env),
1397
1562
  });
1563
+ await new Promise((resolve, reject) => {
1564
+ childProcess.once('spawn', resolve);
1565
+ childProcess.once('error', reject);
1566
+ });
1398
1567
  childProcess.unref();
1399
- stdoutFile.close().catch(() => { });
1568
+ await stdoutFile.close();
1569
+ stdoutFile = null;
1400
1570
  agent.pid = childProcess.pid || null;
1401
1571
  // Capture start-time NOW, while we know the PID is ours. Once the
1402
1572
  // OS reuses this PID slot, /proc and `ps` will report a different
@@ -1406,9 +1576,21 @@ export class AgentManager {
1406
1576
  agent.status = AgentStatus.RUNNING;
1407
1577
  agent.startedAt = new Date();
1408
1578
  await agent.saveMeta();
1579
+ if (resumeLog)
1580
+ await commitResumeLogTransaction(resumeLog);
1409
1581
  }
1410
1582
  catch (err) {
1411
- await this.cleanupPartialAgent(agent);
1583
+ if (stdoutFile)
1584
+ await stdoutFile.close().catch(() => { });
1585
+ if (childProcess?.pid)
1586
+ await terminateSpawnedProcess(childProcess.pid);
1587
+ if (resumeLog)
1588
+ await rollbackResumeLogTransaction(resumeLog);
1589
+ // Fresh spawns own a newly-created directory, so a failed launch removes
1590
+ // that partial record. A resume reuses an existing teammate: its caller
1591
+ // restores the prior terminal state and preserves the directory for retry.
1592
+ if (!resume)
1593
+ await this.cleanupPartialAgent(agent);
1412
1594
  console.error(`Failed to spawn agent ${agent.agentId}:`, err);
1413
1595
  throw new Error(`Failed to spawn agent: ${err.message}`);
1414
1596
  }
@@ -1424,7 +1606,7 @@ export class AgentManager {
1424
1606
  * created ON THE HOST off the freshly-fetched default branch; the teammate runs
1425
1607
  * there. Otherwise it runs in the host repo path directly.
1426
1608
  */
1427
- async launchRemoteProcess(agent) {
1609
+ async launchRemoteProcess(agent, resume) {
1428
1610
  if (!agent.hostName || !agent.hostTarget || !agent.repoPath) {
1429
1611
  throw new Error(`Remote teammate ${agent.agentId} is missing host placement (host/target/repo).`);
1430
1612
  }
@@ -1448,33 +1630,74 @@ export class AgentManager {
1448
1630
  }
1449
1631
  // Worktree isolation on the host, if the team enables it. createRemoteWorktree
1450
1632
  // fetches origin and branches off origin/<default>, returning the host path.
1633
+ // On RESUME the worktree already exists from the original launch — reuse it
1634
+ // (its path is persisted) instead of re-creating (which would fail on the
1635
+ // existing branch and would also discard the teammate's in-progress work).
1451
1636
  let remoteCwd = agent.repoPath;
1452
1637
  if (agent.worktreeName) {
1453
- const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1454
- agent.worktreePath = worktreePath;
1455
- remoteCwd = worktreePath;
1638
+ if (resume && agent.worktreePath) {
1639
+ remoteCwd = agent.worktreePath;
1640
+ }
1641
+ else {
1642
+ const worktreePath = createRemoteWorktree(agent.hostTarget, agent.repoPath, agent.worktreeName);
1643
+ agent.worktreePath = worktreePath;
1644
+ remoteCwd = worktreePath;
1645
+ }
1456
1646
  }
1457
1647
  // Same run argv the local path builds (shared buildRunArgv keeps the prompt
1458
1648
  // scaffolding + flags from drifting); dispatched non-blocking (follow:false)
1459
1649
  // — the supervisor polls the host, we don't block here.
1460
1650
  const effort = agent.effort ?? 'medium';
1461
- const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName);
1651
+ const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName, resume);
1652
+ let dispatchedTask = null;
1653
+ let resumeLog = null;
1462
1654
  try {
1655
+ if (resume) {
1656
+ resumeLog = await beginResumeLogTransaction(agent);
1657
+ await fs.writeFile(resumeLog.stdoutPath, '');
1658
+ }
1463
1659
  const { task } = await dispatchAgentsCommand(host, {
1464
1660
  forwardedArgs,
1465
1661
  remoteCwd,
1466
1662
  follow: false,
1467
1663
  });
1664
+ dispatchedTask = task;
1468
1665
  agent.remotePid = task.pid ?? null;
1469
1666
  agent.remoteLog = task.remoteLog ?? null;
1470
1667
  agent.remoteExit = task.remoteExit ?? null;
1471
1668
  agent.remoteLogOffset = 0;
1669
+ // On resume the offset resets to 0 against a FRESH remote log, and
1670
+ // syncRemoteMirror appends the delta onto the local mirror. Truncate that
1671
+ // mirror first so the prior turn's terminal event can't linger and get
1672
+ // re-read as the current status (same hazard the local path truncates for).
1472
1673
  agent.status = AgentStatus.RUNNING;
1473
1674
  agent.startedAt = new Date();
1474
1675
  await agent.saveMeta();
1676
+ if (resumeLog)
1677
+ await commitResumeLogTransaction(resumeLog);
1475
1678
  }
1476
1679
  catch (err) {
1680
+ let cleanupError = null;
1681
+ if (dispatchedTask) {
1682
+ try {
1683
+ terminateDispatchedTask(dispatchedTask);
1684
+ }
1685
+ catch (cleanupErr) {
1686
+ cleanupError = cleanupErr;
1687
+ }
1688
+ }
1689
+ if (resumeLog) {
1690
+ try {
1691
+ await rollbackResumeLogTransaction(resumeLog);
1692
+ }
1693
+ catch (cleanupErr) {
1694
+ cleanupError = cleanupError ?? cleanupErr;
1695
+ }
1696
+ }
1477
1697
  console.error(`Failed to launch remote teammate ${agent.agentId} on ${agent.hostName}:`, err);
1698
+ if (cleanupError) {
1699
+ throw new Error(`Failed to launch remote teammate: ${err.message}; cleanup failed: ${cleanupError.message}`, { cause: err });
1700
+ }
1478
1701
  throw new Error(`Failed to launch remote teammate: ${err.message}`);
1479
1702
  }
1480
1703
  debug(`Launched remote agent ${agent.agentId} on ${agent.hostName} (remote pid ${agent.remotePid})`);
@@ -1670,38 +1893,46 @@ export class AgentManager {
1670
1893
  * before invoking `agents`. `sessionId` is likewise local-only (the remote run
1671
1894
  * mints its own session on the host).
1672
1895
  */
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;
1896
+ buildRunArgv(agentType, prompt, mode, model, effort, version, profileName, resume) {
1897
+ // Compose the prompt. On RESUME the message is the teammate's next user turn,
1898
+ // not a fresh brief so skip the original brief and the plan-mode prefix, but
1899
+ // keep PROMPT_SUFFIX so the resumed run still emits a final summary the team
1900
+ // parser reads. On a fresh launch, add the plan-mode prefix for Claude and the
1901
+ // universal summary suffix. These are team-specific prompt scaffolding —
1902
+ // `agents run` does not apply them.
1903
+ let fullPrompt;
1904
+ if (resume) {
1905
+ fullPrompt = resume.message + PROMPT_SUFFIX;
1906
+ }
1907
+ else {
1908
+ fullPrompt = prompt + PROMPT_SUFFIX;
1909
+ if (agentType === 'claude' && mode === 'plan') {
1910
+ fullPrompt = CLAUDE_PLAN_MODE_PREFIX + fullPrompt;
1911
+ }
1680
1912
  }
1681
1913
  // Profile target takes precedence — `agents run <profile>` resolves the
1682
1914
  // host harness, version pin, and env injection in one place. Plain
1683
1915
  // version pins only apply when no profile is selected.
1684
1916
  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
- ];
1917
+ // Keep the prompt as the first positional (right after target), matching the
1918
+ // fresh-launch shape, and add `--resume <id>` among the flags. `agents run`
1919
+ // continues the teammate's own session natively (claude `--resume`, codex
1920
+ // `resume`) or via the universal `/continue` replay for other harnesses.
1921
+ const args = ['run', target, fullPrompt];
1922
+ if (resume) {
1923
+ args.push('--resume', resume.id);
1924
+ }
1925
+ args.push('--mode', mode, '--effort', effort, '--json', '--headless', '--quiet');
1695
1926
  if (model)
1696
1927
  args.push('--model', model);
1697
1928
  args.push('--env', 'AGENTS_RUNTIME=teams');
1698
1929
  return args;
1699
1930
  }
1700
- buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null) {
1931
+ buildCommand(agentType, prompt, mode, model, cwd = null, sessionId = null, effort = 'medium', version = null, profileName = null, resume) {
1701
1932
  // Route through getAgentsInvocation so a teammate launched by the compiled
1702
1933
  // standalone binary (#315) doesn't relaunch as `agents /$bunfs/root/agents …`
1703
1934
  // (process.argv[1] is the bun virtual entry there) → "unknown command".
1704
- const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName));
1935
+ const inv = getAgentsInvocation(this.buildRunArgv(agentType, prompt, mode, model, effort, version, profileName, resume));
1705
1936
  const cmd = [inv.command, ...inv.args];
1706
1937
  if (cwd)
1707
1938
  cmd.push('--cwd', cwd);
@@ -1709,7 +1940,9 @@ export class AgentManager {
1709
1940
  // AGENTS_MAILBOX_DIR by the same id mailboxIdForActiveSession returns.
1710
1941
  // Claude also forwards --session-id to its CLI (unified identity);
1711
1942
  // other agents ignore the flag but still get the correct mailbox dir.
1712
- if (sessionId) {
1943
+ // On RESUME we continue an existing session — `--session-id` CREATES one and
1944
+ // `agents run` rejects it alongside `--resume`, so it must be omitted.
1945
+ if (sessionId && !resume) {
1713
1946
  cmd.push('--session-id', sessionId);
1714
1947
  }
1715
1948
  // Claude: grant access to the teammate's working directory.
@@ -1817,17 +2050,13 @@ export class AgentManager {
1817
2050
  if (!agent) {
1818
2051
  return false;
1819
2052
  }
1820
- // Distributed teammate: no local PID — signal it over SSH. Try the process
1821
- // GROUP first (negative pid, matching local `kill(-pid)`) to catch the
1822
- // detached `agents run` and its children; but the remote launcher is
1823
- // `nohup bash -lc … &` under a non-interactive shell where job control is off,
1824
- // so `&` may NOT open a new group — fall back to signalling the wrapper pid
1825
- // directly. Best-effort either way; the `.exit` sentinel is the durable
1826
- // terminal-status source if a grandchild lingers.
2053
+ // Distributed teammate: no local PID — signal the dedicated process group
2054
+ // created by dispatch.ts. The persisted PID is the group leader, so one
2055
+ // negative-PID signal reaches the login-shell wrapper and every descendant.
1827
2056
  if (agent.hostName && agent.status === AgentStatus.RUNNING) {
1828
2057
  if (agent.hostTarget && agent.remotePid) {
1829
2058
  try {
1830
- sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null || kill -TERM ${agent.remotePid} 2>/dev/null`, {
2059
+ sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null`, {
1831
2060
  timeoutMs: 10000,
1832
2061
  multiplex: true,
1833
2062
  });
@@ -643,6 +643,12 @@ export interface Meta {
643
643
  registries?: Record<RegistryType, Record<string, RegistryConfig>>;
644
644
  versions?: Partial<Record<AgentId, Record<string, VersionResources>>>;
645
645
  source?: string;
646
+ /**
647
+ * Projects root for the `agents run --project <slug>` shorthand, e.g.
648
+ * `~/src/github.com/<user>`. Auto-inferred from the repo you launch inside and
649
+ * cached here; stored home-relative (`~/…`) so it resolves on remote hosts too.
650
+ */
651
+ projectRoot?: string;
646
652
  /**
647
653
  * Extra DotAgent repos merged after ~/.agents/. Managed clones live as peer
648
654
  * dirs at ~/.agents-<alias>/; user-owned repos can point at arbitrary paths
@@ -771,6 +777,13 @@ export interface ClaudePermissions {
771
777
  additionalDirectories?: string[];
772
778
  };
773
779
  }
780
+ /** Cursor CLI native format in ~/.cursor/cli-config.json (Shell/Read/Write/WebFetch/Mcp). */
781
+ export interface CursorPermissions {
782
+ permissions: {
783
+ allow: string[];
784
+ deny: string[];
785
+ };
786
+ }
774
787
  /** OpenCode's native permission format (per-command allow/deny/ask). */
775
788
  export interface OpenCodePermissions {
776
789
  permission: {
@@ -114,6 +114,41 @@ export declare function getVersionDir(agent: AgentId, version: string): string;
114
114
  * Get the binary path for a specific agent version.
115
115
  */
116
116
  export declare function getBinaryPath(agent: AgentId, version: string): string;
117
+ /**
118
+ * Does this agent resolve to ONE global binary that is the same file regardless
119
+ * of the `version` argument? (droid → always `~/.local/bin/droid`.) Computed
120
+ * generically by probing `getBinaryPath` with two distinct versions rather than
121
+ * hardcoding an agent id, so it stays correct if another global-binary agent is
122
+ * added.
123
+ *
124
+ * This is the narrower cousin of `isSelfUpdatingAgent`: every global-binary
125
+ * agent is self-updating, but grok is self-updating WITHOUT a global binary — it
126
+ * stores a real per-version binary copy under each version-home
127
+ * (`versions/grok/<v>/home/.grok/downloads/grok-<v>`), so its version-homes are
128
+ * genuinely distinct and must NOT be collapsed. Gate the single-binary
129
+ * collapse/live-version logic on THIS predicate; gate pin-refusal / "switch
130
+ * profile" copy on `isSelfUpdatingAgent`.
131
+ */
132
+ export declare function isGlobalBinaryAgent(agent: AgentId): boolean;
133
+ /** Drop the live-version cache (call after an install/remove that changes the
134
+ * running binary, e.g. `agents add droid@latest`). */
135
+ export declare function invalidateLiveVersionCache(agent?: AgentId): void;
136
+ /**
137
+ * Resolve the version the ONE globally-installed binary actually reports via
138
+ * `<cli> --version`, cached for {@link LIVE_VERSION_TTL_MS}. For a self-updating
139
+ * global-binary agent (droid) this is the single source of truth for "which
140
+ * version is installed" — the on-disk version-dir NAMES are just stale labels
141
+ * left behind by successive `agents add`/self-update cycles. Returns null when
142
+ * the binary isn't on PATH or the probe fails.
143
+ */
144
+ export declare function getLiveVersion(agent: AgentId): Promise<string | null>;
145
+ /**
146
+ * Synchronous, non-blocking read of the live-version cache — returns the value
147
+ * only if a recent {@link getLiveVersion} call already warmed it, else null.
148
+ * `listInstalledVersions` is sync and must not shell out, so it prefers this
149
+ * warm value (accurate) and otherwise falls back to the newest on-disk dir.
150
+ */
151
+ export declare function getCachedLiveVersion(agent: AgentId): string | null;
117
152
  /**
118
153
  * Get the isolated HOME directory for a specific agent version.
119
154
  * Each version has its own config isolation (like jobs sandbox).
@@ -155,6 +190,10 @@ export declare function isOldestInstalled(agent: AgentId): Promise<{
155
190
  export declare function invalidateInstalledVersionsCache(agent?: AgentId): void;
156
191
  /**
157
192
  * List all installed versions for an agent (cached by versions-dir mtime).
193
+ *
194
+ * For a self-updating global-binary agent (droid) every version dir resolves to
195
+ * the SAME binary, so this collapses them to a single canonical entry — one
196
+ * install, one row in `agents view`, never the phantom set of semver dir names.
158
197
  */
159
198
  export declare function listInstalledVersions(agent: AgentId): string[];
160
199
  /**