@phnx-labs/agents-cli 1.20.57 → 1.20.59

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 (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +40 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +29 -5
  6. package/dist/commands/output.d.ts +19 -0
  7. package/dist/commands/output.js +333 -0
  8. package/dist/commands/secrets.js +34 -25
  9. package/dist/commands/versions.js +11 -3
  10. package/dist/commands/view.js +19 -4
  11. package/dist/index.js +2 -1
  12. package/dist/lib/agents.d.ts +21 -0
  13. package/dist/lib/agents.js +42 -14
  14. package/dist/lib/daemon.d.ts +5 -5
  15. package/dist/lib/daemon.js +65 -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/hosts/passthrough.js +1 -0
  21. package/dist/lib/mcp.js +1 -1
  22. package/dist/lib/output/git-output.d.ts +74 -0
  23. package/dist/lib/output/git-output.js +213 -0
  24. package/dist/lib/permissions.d.ts +31 -1
  25. package/dist/lib/permissions.js +210 -9
  26. package/dist/lib/project-root.d.ts +65 -0
  27. package/dist/lib/project-root.js +134 -0
  28. package/dist/lib/resources/mcp.js +1 -1
  29. package/dist/lib/resources/permissions.js +5 -1
  30. package/dist/lib/resources/skills.js +6 -1
  31. package/dist/lib/resources/types.d.ts +1 -1
  32. package/dist/lib/secrets/agent.d.ts +26 -23
  33. package/dist/lib/secrets/agent.js +196 -216
  34. package/dist/lib/secrets/remote.d.ts +7 -2
  35. package/dist/lib/secrets/remote.js +12 -10
  36. package/dist/lib/session/active.d.ts +3 -0
  37. package/dist/lib/session/active.js +1 -0
  38. package/dist/lib/session/db.d.ts +3 -0
  39. package/dist/lib/session/db.js +20 -4
  40. package/dist/lib/session/discover.d.ts +2 -0
  41. package/dist/lib/session/discover.js +40 -4
  42. package/dist/lib/session/parse.js +38 -15
  43. package/dist/lib/session/state.d.ts +4 -1
  44. package/dist/lib/session/state.js +18 -1
  45. package/dist/lib/session/types.d.ts +10 -0
  46. package/dist/lib/staleness/detectors/permissions.js +64 -1
  47. package/dist/lib/staleness/detectors/subagents.js +30 -0
  48. package/dist/lib/staleness/writers/commands.js +3 -3
  49. package/dist/lib/staleness/writers/subagents.js +13 -1
  50. package/dist/lib/startup/command-registry.d.ts +1 -0
  51. package/dist/lib/startup/command-registry.js +2 -0
  52. package/dist/lib/subagents.d.ts +23 -0
  53. package/dist/lib/subagents.js +161 -12
  54. package/dist/lib/teams/agents.d.ts +14 -0
  55. package/dist/lib/teams/agents.js +158 -22
  56. package/dist/lib/types.d.ts +13 -0
  57. package/dist/lib/versions.d.ts +39 -0
  58. package/dist/lib/versions.js +199 -12
  59. package/package.json +1 -1
  60. package/scripts/postinstall.js +26 -11
@@ -13,7 +13,7 @@
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
  import { capableAgents } from '../../capabilities.js';
16
- import { listInstalledSubagents, transformSubagentForClaude, transformSubagentForCodex, writeKimiSubagentFiles, buildKimiSubagentsParentYaml, KIMI_SUBAGENTS_PARENT_FILE, transformSubagentForOpenCode, transformSubagentForDroid, syncSubagentToOpenclaw, parseSubagentFrontmatter, } from '../../subagents.js';
16
+ import { listInstalledSubagents, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, writeKimiSubagentFiles, buildKimiSubagentsParentYaml, KIMI_SUBAGENTS_PARENT_FILE, transformSubagentForOpenCode, transformSubagentForDroid, transformSubagentForKiro, syncSubagentToOpenclaw, parseSubagentFrontmatter, } from '../../subagents.js';
17
17
  import { safeJoin } from '../../paths.js';
18
18
  import { lazyAgentMap } from './lazy-map.js';
19
19
  function buildSubagentsWriter(agent) {
@@ -57,12 +57,24 @@ function buildSubagentsWriter(agent) {
57
57
  fs.writeFileSync(safeJoin(droidsDir, `${sub.name}.md`), transformSubagentForDroid(sub.path));
58
58
  synced.push(sub.name);
59
59
  }
60
+ else if (agent === 'copilot') {
61
+ const agentsDir = path.join(versionHome, '.copilot', 'agents');
62
+ fs.mkdirSync(agentsDir, { recursive: true });
63
+ fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.agent.md`), transformSubagentForCopilot(sub.path));
64
+ synced.push(sub.name);
65
+ }
60
66
  else if (agent === 'openclaw') {
61
67
  const target = safeJoin(path.join(versionHome, '.openclaw'), sub.name);
62
68
  const r = syncSubagentToOpenclaw(sub.path, target);
63
69
  if (r.success)
64
70
  synced.push(sub.name);
65
71
  }
72
+ else if (agent === 'kiro') {
73
+ const agentsDir = path.join(versionHome, '.kiro', 'agents');
74
+ fs.mkdirSync(agentsDir, { recursive: true });
75
+ fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.json`), transformSubagentForKiro(sub.path));
76
+ synced.push(sub.name);
77
+ }
66
78
  }
67
79
  catch { /* per-item sync failure: skip */ }
68
80
  }
@@ -65,6 +65,7 @@ export declare const loadDrive: ModuleLoader;
65
65
  export declare const loadFactory: ModuleLoader;
66
66
  export declare const loadUsage: ModuleLoader;
67
67
  export declare const loadCost: ModuleLoader;
68
+ export declare const loadOutput: ModuleLoader;
68
69
  export declare const loadBudget: ModuleLoader;
69
70
  export declare const loadAlias: ModuleLoader;
70
71
  export declare const loadPty: ModuleLoader;
@@ -43,6 +43,7 @@ export const loadDrive = async () => (await import('../../commands/drive.js')).r
43
43
  export const loadFactory = async () => (await import('../../commands/factory.js')).registerFactoryCommands;
44
44
  export const loadUsage = async () => (await import('../../commands/usage.js')).registerUsageCommand;
45
45
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
46
+ export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
46
47
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
47
48
  export const loadAlias = async () => (await import('../../commands/alias.js')).registerAliasCommand;
48
49
  export const loadPty = async () => (await import('../../commands/pty.js')).registerPtyCommands;
@@ -137,6 +138,7 @@ export const COMMAND_LOADERS = {
137
138
  factory: [loadFactory],
138
139
  usage: [loadUsage],
139
140
  cost: [loadCost],
141
+ output: [loadOutput],
140
142
  budget: [loadBudget],
141
143
  alias: [loadAlias],
142
144
  pty: [loadPty],
@@ -56,6 +56,18 @@ export declare function transformSubagentForClaude(subagentDir: string): string;
56
56
  * See https://docs.factory.ai/cli/configuration/custom-droids.
57
57
  */
58
58
  export declare function transformSubagentForDroid(subagentDir: string): string;
59
+ /**
60
+ * Transform a subagent into a GitHub Copilot CLI custom agent `.agent.md` file.
61
+ *
62
+ * Copilot custom agents are Markdown profiles with YAML frontmatter stored in
63
+ * `~/.copilot/agents/` (user) or `.github/agents/` (project). The file name
64
+ * ends in `.agent.md` and the frontmatter carries `name`, `description`, and
65
+ * optionally `model` and `tools`. The emitted body is identical to Factory
66
+ * Droid's custom-droid format (flatten frontmatter + body + appended .md
67
+ * sections, `color` dropped), so this is an alias of transformSubagentForDroid.
68
+ * See GitHub docs for custom agents.
69
+ */
70
+ export declare const transformSubagentForCopilot: typeof transformSubagentForDroid;
59
71
  /**
60
72
  * Transform a subagent into an OpenCode agent markdown file.
61
73
  *
@@ -107,6 +119,16 @@ export declare function writeKimiSubagentFiles(agentsDir: string, subagentDir: s
107
119
  * https://developers.openai.com/codex/subagents (custom agents section)
108
120
  */
109
121
  export declare function transformSubagentForCodex(subagentDir: string): string;
122
+ /**
123
+ * Transform a subagent into a Kiro CLI custom-agent JSON file.
124
+ *
125
+ * Kiro custom agents live in `~/.kiro/agents/<name>.json` (or `.kiro/agents/`
126
+ * workspace-local) and declare name, description, prompt, tools, and optional
127
+ * model. We flatten the AGENT.md frontmatter + body plus any sibling .md files
128
+ * as sections into a single `prompt`, and expose the standard built-in tool
129
+ * set so the subagent can actually run.
130
+ */
131
+ export declare function transformSubagentForKiro(subagentDir: string): string;
110
132
  /**
111
133
  * Sync a subagent to an OpenClaw workspace
112
134
  * Copies full directory, renames AGENT.md to AGENTS.md
@@ -137,6 +159,7 @@ export declare function subagentContentMatches(installedDir: string, sourceDir:
137
159
  * List subagents installed to a specific agent's home
138
160
  * Claude: scans ~/.claude/agents/{name}.md
139
161
  * Kimi: scans ~/.kimi-code/agents/{name}.yaml (+ sibling .system.md)
162
+ * Kiro: scans ~/.kiro/agents/{name}.json
140
163
  * OpenClaw: scans ~/.openclaw/{name}/AGENTS.md
141
164
  */
142
165
  export declare function listSubagentsForAgent(agentId: AgentId, home: string): InstalledSubagent[];
@@ -262,6 +262,18 @@ export function transformSubagentForDroid(subagentDir) {
262
262
  }
263
263
  return result;
264
264
  }
265
+ /**
266
+ * Transform a subagent into a GitHub Copilot CLI custom agent `.agent.md` file.
267
+ *
268
+ * Copilot custom agents are Markdown profiles with YAML frontmatter stored in
269
+ * `~/.copilot/agents/` (user) or `.github/agents/` (project). The file name
270
+ * ends in `.agent.md` and the frontmatter carries `name`, `description`, and
271
+ * optionally `model` and `tools`. The emitted body is identical to Factory
272
+ * Droid's custom-droid format (flatten frontmatter + body + appended .md
273
+ * sections, `color` dropped), so this is an alias of transformSubagentForDroid.
274
+ * See GitHub docs for custom agents.
275
+ */
276
+ export const transformSubagentForCopilot = transformSubagentForDroid;
265
277
  /**
266
278
  * Transform a subagent into an OpenCode agent markdown file.
267
279
  *
@@ -381,21 +393,10 @@ export function writeKimiSubagentFiles(agentsDir, subagentDir, name) {
381
393
  export function transformSubagentForCodex(subagentDir) {
382
394
  const agentMd = path.join(subagentDir, 'AGENT.md');
383
395
  const frontmatter = parseSubagentFrontmatter(agentMd);
384
- const body = getSubagentBody(agentMd);
385
396
  if (!frontmatter) {
386
397
  throw new Error(`Invalid AGENT.md in ${subagentDir}`);
387
398
  }
388
- // Append other .md files into the developer_instructions body.
389
- let instructions = body.trim();
390
- const files = fs.readdirSync(subagentDir)
391
- .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
392
- .sort();
393
- for (const file of files) {
394
- const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
395
- const sectionName = file.replace('.md', '');
396
- const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
397
- instructions += `\n\n## ${title}\n\n${content}`;
398
- }
399
+ const instructions = flattenSubagentInstructions(subagentDir);
399
400
  // Escape TOML multi-line string (""") content — only """ needs escaping.
400
401
  const safeInstructions = instructions.replace(/"""/g, '\\"""');
401
402
  const safeName = frontmatter.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
@@ -409,6 +410,56 @@ export function transformSubagentForCodex(subagentDir) {
409
410
  toml += `developer_instructions = """\n${safeInstructions}\n"""\n`;
410
411
  return toml;
411
412
  }
413
+ function flattenSubagentInstructions(subagentDir) {
414
+ let instructions = getSubagentBody(path.join(subagentDir, 'AGENT.md')).trim();
415
+ const files = fs.readdirSync(subagentDir)
416
+ .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
417
+ .sort();
418
+ for (const file of files) {
419
+ const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
420
+ const sectionName = file.replace('.md', '');
421
+ const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
422
+ instructions += `\n\n## ${title}\n\n${content}`;
423
+ }
424
+ return instructions;
425
+ }
426
+ /**
427
+ * Transform a subagent into a Kiro CLI custom-agent JSON file.
428
+ *
429
+ * Kiro custom agents live in `~/.kiro/agents/<name>.json` (or `.kiro/agents/`
430
+ * workspace-local) and declare name, description, prompt, tools, and optional
431
+ * model. We flatten the AGENT.md frontmatter + body plus any sibling .md files
432
+ * as sections into a single `prompt`, and expose the standard built-in tool
433
+ * set so the subagent can actually run.
434
+ */
435
+ export function transformSubagentForKiro(subagentDir) {
436
+ const agentMd = path.join(subagentDir, 'AGENT.md');
437
+ const frontmatter = parseSubagentFrontmatter(agentMd);
438
+ const body = getSubagentBody(agentMd);
439
+ if (!frontmatter) {
440
+ throw new Error(`Invalid AGENT.md in ${subagentDir}`);
441
+ }
442
+ const files = fs.readdirSync(subagentDir)
443
+ .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
444
+ .sort();
445
+ let prompt = body;
446
+ for (const file of files) {
447
+ const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
448
+ const sectionName = file.replace('.md', '');
449
+ const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
450
+ prompt += `\n\n## ${title}\n\n${content}`;
451
+ }
452
+ const config = {
453
+ name: frontmatter.name,
454
+ description: frontmatter.description,
455
+ prompt,
456
+ tools: ['read', 'write', 'shell', 'web_search', 'web_fetch'],
457
+ };
458
+ if (frontmatter.model) {
459
+ config.model = frontmatter.model;
460
+ }
461
+ return JSON.stringify(config, null, 2);
462
+ }
412
463
  /**
413
464
  * Sync a subagent to an OpenClaw workspace
414
465
  * Copies full directory, renames AGENT.md to AGENTS.md
@@ -497,6 +548,21 @@ export function installSubagentToAgent(subagentDir, subagentName, agent, agentHo
497
548
  const targetDir = safeJoin(path.join(agentHome, '.openclaw'), subagentName);
498
549
  return syncSubagentToOpenclaw(subagentDir, targetDir);
499
550
  }
551
+ else if (agent === 'kiro') {
552
+ // Kiro: JSON custom-agent file under ~/.kiro/agents/
553
+ const agentsDir = path.join(agentHome, '.kiro', 'agents');
554
+ if (!fs.existsSync(agentsDir)) {
555
+ fs.mkdirSync(agentsDir, { recursive: true });
556
+ }
557
+ try {
558
+ const transformed = transformSubagentForKiro(subagentDir);
559
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.json`), transformed);
560
+ return { success: true };
561
+ }
562
+ catch (err) {
563
+ return { success: false, error: String(err) };
564
+ }
565
+ }
500
566
  else {
501
567
  // Other agents don't support subagents yet
502
568
  return { success: false, error: `Agent '${agent}' does not support subagents` };
@@ -545,6 +611,13 @@ export function removeSubagentFromAgent(subagentName, agent, agentHome) {
545
611
  }
546
612
  return { success: true };
547
613
  }
614
+ else if (agent === 'kiro') {
615
+ const targetPath = safeJoin(path.join(agentHome, '.kiro', 'agents'), `${subagentName}.json`);
616
+ if (fs.existsSync(targetPath)) {
617
+ fs.unlinkSync(targetPath);
618
+ }
619
+ return { success: true };
620
+ }
548
621
  else {
549
622
  return { success: true }; // No-op for unsupported agents
550
623
  }
@@ -584,6 +657,7 @@ export function subagentContentMatches(installedDir, sourceDir) {
584
657
  * List subagents installed to a specific agent's home
585
658
  * Claude: scans ~/.claude/agents/{name}.md
586
659
  * Kimi: scans ~/.kimi-code/agents/{name}.yaml (+ sibling .system.md)
660
+ * Kiro: scans ~/.kiro/agents/{name}.json
587
661
  * OpenClaw: scans ~/.openclaw/{name}/AGENTS.md
588
662
  */
589
663
  export function listSubagentsForAgent(agentId, home) {
@@ -656,6 +730,22 @@ export function listSubagentsForAgent(agentId, home) {
656
730
  subagents.push({ name, path: filePath, files: [file], frontmatter });
657
731
  }
658
732
  }
733
+ else if (agentId === 'copilot') {
734
+ // Copilot: flat `<name>.agent.md` files under ~/.copilot/agents/
735
+ const agentsDir = path.join(home, '.copilot', 'agents');
736
+ if (!fs.existsSync(agentsDir))
737
+ return subagents;
738
+ for (const file of fs.readdirSync(agentsDir)) {
739
+ if (!file.endsWith('.agent.md'))
740
+ continue;
741
+ const filePath = path.join(agentsDir, file);
742
+ if (!fs.statSync(filePath).isFile())
743
+ continue;
744
+ const name = file.replace(/\.agent\.md$/, '');
745
+ const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
746
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
747
+ }
748
+ }
659
749
  else if (agentId === 'openclaw') {
660
750
  // OpenClaw: directories with AGENTS.md
661
751
  const openclawDir = path.join(home, '.openclaw');
@@ -697,6 +787,38 @@ export function listSubagentsForAgent(agentId, home) {
697
787
  });
698
788
  }
699
789
  }
790
+ else if (agentId === 'kiro') {
791
+ // Kiro: JSON files under ~/.kiro/agents/
792
+ const agentsDir = path.join(home, '.kiro', 'agents');
793
+ if (!fs.existsSync(agentsDir))
794
+ return subagents;
795
+ for (const file of fs.readdirSync(agentsDir)) {
796
+ if (!file.endsWith('.json'))
797
+ continue;
798
+ const filePath = path.join(agentsDir, file);
799
+ if (!fs.statSync(filePath).isFile())
800
+ continue;
801
+ let frontmatter;
802
+ try {
803
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
804
+ frontmatter = {
805
+ name: config.name || file.replace('.json', ''),
806
+ description: config.description || '',
807
+ model: config.model,
808
+ };
809
+ }
810
+ catch {
811
+ continue;
812
+ }
813
+ const name = file.replace('.json', '');
814
+ subagents.push({
815
+ name,
816
+ path: filePath,
817
+ files: [file],
818
+ frontmatter,
819
+ });
820
+ }
821
+ }
700
822
  return subagents;
701
823
  }
702
824
  /**
@@ -768,6 +890,19 @@ export function diffVersionSubagents(agent, version) {
768
890
  }
769
891
  }
770
892
  }
893
+ else if (agent === 'kiro') {
894
+ const agentsDir = path.join(versionHome, '.kiro', 'agents');
895
+ if (fs.existsSync(agentsDir)) {
896
+ for (const file of fs.readdirSync(agentsDir)) {
897
+ if (!file.endsWith('.json'))
898
+ continue;
899
+ const name = path.basename(file, '.json');
900
+ if (!discovered.has(name)) {
901
+ orphans.push(name);
902
+ }
903
+ }
904
+ }
905
+ }
771
906
  return { agent, version, orphans: orphans.sort() };
772
907
  }
773
908
  /**
@@ -826,6 +961,13 @@ export function removeSubagentFromVersion(agent, version, subagentName) {
826
961
  fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
827
962
  }
828
963
  }
964
+ else if (agent === 'copilot') {
965
+ const targetPath = path.join(versionHome, '.copilot', 'agents', `${subagentName}.agent.md`);
966
+ if (fs.existsSync(targetPath)) {
967
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
968
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.agent.md.${stamp}`));
969
+ }
970
+ }
829
971
  else if (agent === 'openclaw') {
830
972
  const targetDir = path.join(versionHome, '.openclaw', subagentName);
831
973
  if (fs.existsSync(targetDir)) {
@@ -834,6 +976,13 @@ export function removeSubagentFromVersion(agent, version, subagentName) {
834
976
  fs.renameSync(targetDir, trashDest);
835
977
  }
836
978
  }
979
+ else if (agent === 'kiro') {
980
+ const targetPath = path.join(versionHome, '.kiro', 'agents', `${subagentName}.json`);
981
+ if (fs.existsSync(targetPath)) {
982
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
983
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.json.${stamp}`));
984
+ }
985
+ }
837
986
  return { success: true };
838
987
  }
839
988
  catch (err) {
@@ -213,6 +213,10 @@ export declare class AgentProcess {
213
213
  * round-trips so a bare `teams status`/`teams logs` is still correct.
214
214
  */
215
215
  private syncRemoteMirror;
216
+ /** Reset the local stdout cursor for a newly truncated resume log. */
217
+ resetLogReadPosition(): number;
218
+ /** Restore the cursor when a resume transaction puts the prior log back. */
219
+ restoreLogReadPosition(position: number): void;
216
220
  readNewEvents(): Promise<void>;
217
221
  /**
218
222
  * Truncate the local mirror to its trailing REMOTE_MIRROR_MAX_BYTES and reset
@@ -268,6 +272,16 @@ export declare class AgentProcess {
268
272
  export type CloudDispatchFn = (agent: AgentProcess) => Promise<{
269
273
  cloudSessionId: string;
270
274
  }>;
275
+ interface ResumeLogTransaction {
276
+ agent: AgentProcess;
277
+ stdoutPath: string;
278
+ backupPath: string;
279
+ hadOriginal: boolean;
280
+ previousReadPos: number;
281
+ }
282
+ export declare function beginResumeLogTransaction(agent: AgentProcess): Promise<ResumeLogTransaction>;
283
+ export declare function commitResumeLogTransaction(transaction: ResumeLogTransaction): Promise<void>;
284
+ export declare function terminateSpawnedProcess(pid: number): Promise<void>;
271
285
  export declare class AgentManager {
272
286
  private agents;
273
287
  private maxAgents;
@@ -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;
@@ -1402,16 +1468,42 @@ export class AgentManager {
1402
1468
  `(it may have failed before its first turn). Start a fresh teammate instead.`);
1403
1469
  }
1404
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
+ };
1405
1483
  // Flip to RUNNING up front so a concurrent status poll can't reap the
1406
1484
  // teammate between the exit-sentinel clear and the new PID landing; the
1407
- // launch re-persists with the fresh pid/startTime.
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.
1408
1488
  agent.status = AgentStatus.RUNNING;
1409
1489
  agent.completedAt = null;
1410
- if (agent.hostName) {
1411
- await this.launchRemoteProcess(agent, resume);
1490
+ try {
1491
+ if (agent.hostName) {
1492
+ await this.launchRemoteProcess(agent, resume);
1493
+ }
1494
+ else {
1495
+ await this.launchProcess(agent, resume);
1496
+ }
1412
1497
  }
1413
- else {
1414
- await this.launchProcess(agent, resume);
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;
1415
1507
  }
1416
1508
  return agent;
1417
1509
  }
@@ -1429,8 +1521,13 @@ export class AgentManager {
1429
1521
  const resolvedModel = agent.model ?? null;
1430
1522
  const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName, resume);
1431
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;
1432
1527
  try {
1433
- const stdoutPath = await agent.getStdoutPath();
1528
+ if (resume)
1529
+ resumeLog = await beginResumeLogTransaction(agent);
1530
+ const stdoutPath = resumeLog?.stdoutPath ?? await agent.getStdoutPath();
1434
1531
  // Always TRUNCATE — including on resume. The status reader re-reads the
1435
1532
  // whole log from byte 0 every poll (lastReadPos is in-memory, not
1436
1533
  // persisted) and marks terminal status from the last `result` event it
@@ -1441,7 +1538,7 @@ export class AgentManager {
1441
1538
  // a forked session. Truncating keeps exactly one turn in the log, so the
1442
1539
  // re-read is always correct. The authoritative transcript lives in the
1443
1540
  // agent's own session (resumed via --resume), not this stdout mirror.
1444
- const stdoutFile = await fs.open(stdoutPath, 'w');
1541
+ stdoutFile = await fs.open(stdoutPath, 'w');
1445
1542
  const stdoutFd = stdoutFile.fd;
1446
1543
  // Wrap the teammate command in a shell that records the underlying CLI's
1447
1544
  // exit code to a sentinel file. Detached + unref()'d children can't be
@@ -1455,7 +1552,7 @@ export class AgentManager {
1455
1552
  const wrappedCmd = buildSentinelCommand(cmd, exitCodePath);
1456
1553
  // detached:true makes the shell the process-group leader, so stop()'s
1457
1554
  // `kill(-pid)` still reaches the underlying CLI through the group.
1458
- const childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1555
+ childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1459
1556
  stdio: ['ignore', stdoutFd, stdoutFd],
1460
1557
  cwd: agent.cwd || undefined,
1461
1558
  detached: true,
@@ -1463,8 +1560,13 @@ export class AgentManager {
1463
1560
  ? { ...sanitizeProcessEnv(process.env), ...agent.envOverrides }
1464
1561
  : sanitizeProcessEnv(process.env),
1465
1562
  });
1563
+ await new Promise((resolve, reject) => {
1564
+ childProcess.once('spawn', resolve);
1565
+ childProcess.once('error', reject);
1566
+ });
1466
1567
  childProcess.unref();
1467
- stdoutFile.close().catch(() => { });
1568
+ await stdoutFile.close();
1569
+ stdoutFile = null;
1468
1570
  agent.pid = childProcess.pid || null;
1469
1571
  // Capture start-time NOW, while we know the PID is ours. Once the
1470
1572
  // OS reuses this PID slot, /proc and `ps` will report a different
@@ -1474,9 +1576,21 @@ export class AgentManager {
1474
1576
  agent.status = AgentStatus.RUNNING;
1475
1577
  agent.startedAt = new Date();
1476
1578
  await agent.saveMeta();
1579
+ if (resumeLog)
1580
+ await commitResumeLogTransaction(resumeLog);
1477
1581
  }
1478
1582
  catch (err) {
1479
- 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);
1480
1594
  console.error(`Failed to spawn agent ${agent.agentId}:`, err);
1481
1595
  throw new Error(`Failed to spawn agent: ${err.message}`);
1482
1596
  }
@@ -1535,12 +1649,19 @@ export class AgentManager {
1535
1649
  // — the supervisor polls the host, we don't block here.
1536
1650
  const effort = agent.effort ?? 'medium';
1537
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;
1538
1654
  try {
1655
+ if (resume) {
1656
+ resumeLog = await beginResumeLogTransaction(agent);
1657
+ await fs.writeFile(resumeLog.stdoutPath, '');
1658
+ }
1539
1659
  const { task } = await dispatchAgentsCommand(host, {
1540
1660
  forwardedArgs,
1541
1661
  remoteCwd,
1542
1662
  follow: false,
1543
1663
  });
1664
+ dispatchedTask = task;
1544
1665
  agent.remotePid = task.pid ?? null;
1545
1666
  agent.remoteLog = task.remoteLog ?? null;
1546
1667
  agent.remoteExit = task.remoteExit ?? null;
@@ -1549,15 +1670,34 @@ export class AgentManager {
1549
1670
  // syncRemoteMirror appends the delta onto the local mirror. Truncate that
1550
1671
  // mirror first so the prior turn's terminal event can't linger and get
1551
1672
  // 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
- }
1555
1673
  agent.status = AgentStatus.RUNNING;
1556
1674
  agent.startedAt = new Date();
1557
1675
  await agent.saveMeta();
1676
+ if (resumeLog)
1677
+ await commitResumeLogTransaction(resumeLog);
1558
1678
  }
1559
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
+ }
1560
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
+ }
1561
1701
  throw new Error(`Failed to launch remote teammate: ${err.message}`);
1562
1702
  }
1563
1703
  debug(`Launched remote agent ${agent.agentId} on ${agent.hostName} (remote pid ${agent.remotePid})`);
@@ -1910,17 +2050,13 @@ export class AgentManager {
1910
2050
  if (!agent) {
1911
2051
  return false;
1912
2052
  }
1913
- // Distributed teammate: no local PID — signal it over SSH. Try the process
1914
- // GROUP first (negative pid, matching local `kill(-pid)`) to catch the
1915
- // detached `agents run` and its children; but the remote launcher is
1916
- // `nohup bash -lc … &` under a non-interactive shell where job control is off,
1917
- // so `&` may NOT open a new group — fall back to signalling the wrapper pid
1918
- // directly. Best-effort either way; the `.exit` sentinel is the durable
1919
- // 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.
1920
2056
  if (agent.hostName && agent.status === AgentStatus.RUNNING) {
1921
2057
  if (agent.hostTarget && agent.remotePid) {
1922
2058
  try {
1923
- 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`, {
1924
2060
  timeoutMs: 10000,
1925
2061
  multiplex: true,
1926
2062
  });