@phnx-labs/agents-cli 1.18.5 → 1.19.0

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 (105) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.md +22 -20
  3. package/dist/commands/browser.js +25 -2
  4. package/dist/commands/cloud.js +3 -3
  5. package/dist/commands/computer.d.ts +6 -0
  6. package/dist/commands/computer.js +477 -0
  7. package/dist/commands/doctor.js +19 -17
  8. package/dist/commands/exec.js +37 -59
  9. package/dist/commands/factory.js +12 -5
  10. package/dist/commands/import.js +6 -1
  11. package/dist/commands/mcp.js +9 -4
  12. package/dist/commands/packages.d.ts +3 -0
  13. package/dist/commands/packages.js +20 -12
  14. package/dist/commands/permissions.d.ts +2 -0
  15. package/dist/commands/permissions.js +20 -1
  16. package/dist/commands/plugins.d.ts +2 -0
  17. package/dist/commands/plugins.js +23 -4
  18. package/dist/commands/profiles.js +1 -1
  19. package/dist/commands/pty.js +126 -112
  20. package/dist/commands/pull.js +29 -25
  21. package/dist/commands/repo.js +24 -26
  22. package/dist/commands/routines.js +29 -26
  23. package/dist/commands/secrets.js +66 -73
  24. package/dist/commands/sessions-tail.js +21 -22
  25. package/dist/commands/sessions.js +36 -68
  26. package/dist/commands/setup.js +20 -24
  27. package/dist/commands/teams.js +30 -39
  28. package/dist/commands/versions.js +60 -68
  29. package/dist/commands/worktree.d.ts +20 -0
  30. package/dist/commands/worktree.js +242 -0
  31. package/dist/computer.d.ts +2 -0
  32. package/dist/computer.js +7 -0
  33. package/dist/index.js +70 -26
  34. package/dist/lib/agents.d.ts +4 -1
  35. package/dist/lib/agents.js +23 -5
  36. package/dist/lib/browser/cdp.d.ts +15 -1
  37. package/dist/lib/browser/cdp.js +77 -8
  38. package/dist/lib/browser/chrome.js +17 -24
  39. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  40. package/dist/lib/browser/drivers/ssh.js +20 -8
  41. package/dist/lib/browser/ipc.js +38 -5
  42. package/dist/lib/browser/profiles.js +34 -2
  43. package/dist/lib/browser/runtime-state.d.ts +1 -2
  44. package/dist/lib/browser/runtime-state.js +11 -3
  45. package/dist/lib/browser/service.d.ts +5 -0
  46. package/dist/lib/browser/service.js +32 -4
  47. package/dist/lib/browser/types.d.ts +1 -1
  48. package/dist/lib/browser/upload.d.ts +2 -0
  49. package/dist/lib/browser/upload.js +34 -0
  50. package/dist/lib/cloud/rush.d.ts +2 -1
  51. package/dist/lib/cloud/rush.js +28 -9
  52. package/dist/lib/computer-rpc.d.ts +24 -0
  53. package/dist/lib/computer-rpc.js +263 -0
  54. package/dist/lib/daemon.js +7 -7
  55. package/dist/lib/exec.d.ts +2 -1
  56. package/dist/lib/exec.js +3 -2
  57. package/dist/lib/fs-atomic.d.ts +18 -0
  58. package/dist/lib/fs-atomic.js +76 -0
  59. package/dist/lib/git.js +2 -4
  60. package/dist/lib/help.d.ts +15 -0
  61. package/dist/lib/help.js +41 -0
  62. package/dist/lib/hooks/match.d.ts +1 -0
  63. package/dist/lib/hooks/match.js +57 -12
  64. package/dist/lib/hooks.d.ts +1 -0
  65. package/dist/lib/hooks.js +27 -10
  66. package/dist/lib/import.d.ts +1 -0
  67. package/dist/lib/import.js +7 -0
  68. package/dist/lib/manifest.js +27 -1
  69. package/dist/lib/mcp.d.ts +14 -0
  70. package/dist/lib/mcp.js +79 -14
  71. package/dist/lib/migrate.js +3 -3
  72. package/dist/lib/models.js +3 -1
  73. package/dist/lib/permissions.d.ts +5 -0
  74. package/dist/lib/permissions.js +35 -0
  75. package/dist/lib/plugin-marketplace.d.ts +3 -1
  76. package/dist/lib/plugin-marketplace.js +36 -1
  77. package/dist/lib/plugins.d.ts +19 -1
  78. package/dist/lib/plugins.js +99 -8
  79. package/dist/lib/redact.d.ts +4 -0
  80. package/dist/lib/redact.js +18 -0
  81. package/dist/lib/registry.d.ts +2 -0
  82. package/dist/lib/registry.js +15 -0
  83. package/dist/lib/sandbox.js +15 -5
  84. package/dist/lib/secrets/bundles.d.ts +7 -12
  85. package/dist/lib/secrets/bundles.js +45 -29
  86. package/dist/lib/secrets/index.js +4 -4
  87. package/dist/lib/session/cloud.d.ts +2 -0
  88. package/dist/lib/session/cloud.js +34 -6
  89. package/dist/lib/session/parse.js +7 -2
  90. package/dist/lib/session/render.d.ts +4 -1
  91. package/dist/lib/session/render.js +81 -35
  92. package/dist/lib/shims.d.ts +5 -2
  93. package/dist/lib/shims.js +29 -7
  94. package/dist/lib/state.d.ts +5 -5
  95. package/dist/lib/state.js +43 -13
  96. package/dist/lib/teams/agents.d.ts +1 -1
  97. package/dist/lib/teams/agents.js +2 -2
  98. package/dist/lib/types.d.ts +4 -3
  99. package/dist/lib/types.js +0 -2
  100. package/dist/lib/versions.js +65 -40
  101. package/dist/lib/workflows.d.ts +7 -0
  102. package/dist/lib/workflows.js +42 -1
  103. package/npm-shrinkwrap.json +3256 -0
  104. package/package.json +32 -26
  105. package/scripts/postinstall.js +8 -2
@@ -17,6 +17,7 @@ import { getCacheDir } from '../state.js';
17
17
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
18
18
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
19
  const CLOUD_CACHE_DIR = path.join(getCacheDir(), 'cloud-runs');
20
+ const CLOUD_EXECUTION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
20
21
  function readToken() {
21
22
  if (!fs.existsSync(USER_YAML)) {
22
23
  throw new Error('Not logged in to Rush. Run `rush login` first.');
@@ -45,6 +46,24 @@ function agentToFormat(agent) {
45
46
  return 'rush';
46
47
  return null;
47
48
  }
49
+ export function assertContained(candidate, rootDir) {
50
+ const root = path.resolve(rootDir);
51
+ const resolved = path.resolve(root, candidate);
52
+ if (!resolved.startsWith(root + path.sep)) {
53
+ throw new Error(`Path escapes cloud session cache: ${candidate}`);
54
+ }
55
+ return resolved;
56
+ }
57
+ export function validateCloudExecutionId(executionId) {
58
+ if (!CLOUD_EXECUTION_ID_RE.test(executionId)) {
59
+ throw new Error(`Invalid cloud execution_id: ${JSON.stringify(executionId)}`);
60
+ }
61
+ return executionId;
62
+ }
63
+ function cachePathForExecution(executionId, agent) {
64
+ const id = validateCloudExecutionId(executionId);
65
+ return assertContained(path.join(id, `session.${agent}.jsonl`), CLOUD_CACHE_DIR);
66
+ }
48
67
  /**
49
68
  * List cloud executions the user has captured sessions for. Includes
50
69
  * completed + needs_review + failed; an empty session_path means capture
@@ -64,13 +83,13 @@ export async function discoverCloudSessions(options) {
64
83
  const agent = agentToFormat(row.agent);
65
84
  if (!agent)
66
85
  continue;
67
- const id = row.execution_id;
86
+ const id = validateCloudExecutionId(row.execution_id);
68
87
  const timestamp = row.updated_at || row.created_at || new Date().toISOString();
69
88
  const project = row.repo_owner && row.repo_name ? `${row.repo_owner}/${row.repo_name}` : undefined;
70
89
  // filePath doubles as the sink path for the cached jsonl. parseSession
71
90
  // dispatches on detectAgent which recognizes the `session.<format>.jsonl`
72
91
  // suffix — so the local cache file name must preserve it.
73
- const filePath = path.join(CLOUD_CACHE_DIR, id, `session.${agent}.jsonl`);
92
+ const filePath = cachePathForExecution(id, agent);
74
93
  out.push({
75
94
  id,
76
95
  shortId: id.slice(0, 8),
@@ -91,8 +110,10 @@ export async function discoverCloudSessions(options) {
91
110
  * are immutable once complete). Callers may pass an already-known filePath.
92
111
  */
93
112
  export async function ensureCloudSessionCached(executionId, destPath) {
113
+ const id = validateCloudExecutionId(executionId);
114
+ const callerPath = destPath ? assertContained(destPath, CLOUD_CACHE_DIR) : undefined;
94
115
  const token = readToken();
95
- const res = await api('GET', `/api/v1/cloud-runs/${encodeURIComponent(executionId)}/session.jsonl`, token);
116
+ const res = await api('GET', `/api/v1/cloud-runs/${encodeURIComponent(id)}/session.jsonl`, token);
96
117
  if (!res.ok) {
97
118
  const body = await res.text().catch(() => '');
98
119
  throw new Error(`session.jsonl fetch ${res.status}: ${body.slice(0, 200)}`);
@@ -101,7 +122,7 @@ export async function ensureCloudSessionCached(executionId, destPath) {
101
122
  if (!['claude', 'codex', 'rush'].includes(format)) {
102
123
  throw new Error(`Unknown X-Session-Format on cloud response: "${format}"`);
103
124
  }
104
- const finalPath = destPath ?? path.join(CLOUD_CACHE_DIR, executionId, `session.${format}.jsonl`);
125
+ const finalPath = callerPath ?? cachePathForExecution(id, format);
105
126
  fs.mkdirSync(path.dirname(finalPath), { recursive: true });
106
127
  const body = Buffer.from(await res.arrayBuffer());
107
128
  fs.writeFileSync(finalPath, body);
@@ -109,7 +130,9 @@ export async function ensureCloudSessionCached(executionId, destPath) {
109
130
  }
110
131
  /** True if filePath points into the cloud session cache dir. */
111
132
  export function isCloudSessionPath(filePath) {
112
- return filePath.startsWith(CLOUD_CACHE_DIR);
133
+ const root = path.resolve(CLOUD_CACHE_DIR);
134
+ const resolved = path.resolve(filePath);
135
+ return resolved.startsWith(root + path.sep);
113
136
  }
114
137
  /** Extract execution_id from a cloud cache path. */
115
138
  export function executionIdFromCloudPath(filePath) {
@@ -117,5 +140,10 @@ export function executionIdFromCloudPath(filePath) {
117
140
  return null;
118
141
  const rel = path.relative(CLOUD_CACHE_DIR, filePath);
119
142
  const parts = rel.split(path.sep);
120
- return parts[0] || null;
143
+ try {
144
+ return parts[0] ? validateCloudExecutionId(parts[0]) : null;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
121
149
  }
@@ -6,7 +6,7 @@
6
6
  * objects suitable for rendering, filtering, and summarization.
7
7
  */
8
8
  import * as fs from 'fs';
9
- import { execSync } from 'child_process';
9
+ import { execFileSync } from 'child_process';
10
10
  /**
11
11
  * Largest session file we will load into memory. Above this we throw a clean
12
12
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -668,7 +668,12 @@ export function parseOpenCode(filePath) {
668
668
  WHERE m.session_id = '${sessionId.replace(/'/g, "''")}'
669
669
  ORDER BY m.time_created ASC, p.time_created ASC;
670
670
  `.replace(/\n/g, ' ');
671
- const out = execSync(`sqlite3 -separator '|||' "${dbPath}"`, { encoding: 'utf-8', input: query, stdio: ['pipe', 'pipe', 'ignore'], timeout: 10000 });
671
+ const out = execFileSync('sqlite3', ['-separator', '|||', dbPath, query], {
672
+ encoding: 'utf-8',
673
+ timeout: 10000,
674
+ maxBuffer: 32 * 1024 * 1024,
675
+ stdio: ['ignore', 'pipe', 'ignore'],
676
+ });
672
677
  for (const line of out.split('\n')) {
673
678
  if (!line.trim())
674
679
  continue;
@@ -94,7 +94,10 @@ export declare function filterEvents(events: SessionEvent[], opts: FilterOptions
94
94
  * order so reasoning sits where it actually occurred relative to the assistant
95
95
  * reply.
96
96
  */
97
- export declare function renderConversationMarkdown(events: SessionEvent[]): string;
97
+ export interface RenderConversationMarkdownOptions {
98
+ redact?: boolean;
99
+ }
100
+ export declare function renderConversationMarkdown(events: SessionEvent[], opts?: RenderConversationMarkdownOptions): string;
98
101
  /**
99
102
  * Render session as JSON (normalized events).
100
103
  */
@@ -10,6 +10,7 @@ import chalk from 'chalk';
10
10
  import { summarizeToolUse } from './parse.js';
11
11
  import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
12
12
  import { renderMarkdown } from '../markdown.js';
13
+ import { redactSecrets } from '../redact.js';
13
14
  // ── Path helpers ──────────────────────────────────────────────────────────────
14
15
  /**
15
16
  * Return absPath relative to cwd; fall back to ~/… then absolute.
@@ -405,6 +406,26 @@ function renderFileGroup(lines, groups, absPathMap) {
405
406
  }
406
407
  }
407
408
  }
409
+ // ── Recent activity renderer ──────────────────────────────────────────────────
410
+ /** Render a single Recent Activity line as `<kind-tag> <label>`, colored by kind. */
411
+ function renderActivityLine(item) {
412
+ const MAX = 90;
413
+ const trim = (s) => (s.length <= MAX ? s : s.slice(0, MAX - 1) + '…');
414
+ switch (item.kind) {
415
+ case 'edit': {
416
+ const linked = item.absPath ? linkPath(item.absPath, item.label) : item.label;
417
+ return chalk.cyan('Edit ') + ' ' + linked;
418
+ }
419
+ case 'cmd':
420
+ return chalk.yellow('Bash ') + ' ' + chalk.gray(trim(item.label));
421
+ case 'agent':
422
+ return chalk.magenta('Agent') + ' ' + trim(item.label);
423
+ case 'error':
424
+ return chalk.red('Error') + ' ' + chalk.gray(trim(item.label));
425
+ case 'msg':
426
+ return chalk.green('Msg ') + ' ' + chalk.gray('"' + trim(item.label) + '"');
427
+ }
428
+ }
408
429
  // ── Main summary renderer ─────────────────────────────────────────────────────
409
430
  /**
410
431
  * Render session as an activity summary.
@@ -429,6 +450,7 @@ export function renderSummary(events, cwd) {
429
450
  const subagents = [];
430
451
  // Errors
431
452
  const errors = [];
453
+ const recentActivity = [];
432
454
  // Assistant message count (used to decide whether the session produced any narration)
433
455
  let assistantCount = 0;
434
456
  const isInsideCwd = (p) => !!(cwd && p.startsWith(cwd + '/'));
@@ -451,13 +473,16 @@ export function renderSummary(events, cwd) {
451
473
  }
452
474
  else {
453
475
  (isInsideCwd(p) || !cwd ? filesModifiedAbs : filesModifiedExternal).add(p);
476
+ recentActivity.push({ kind: 'edit', label: relativeToCwd(p, cwd), ts, absPath: p });
454
477
  }
455
478
  }
456
479
  }
457
480
  if (event.command) {
458
481
  const cmd = event.command.replace(/\n/g, ' ').trim();
459
- if (cmd)
482
+ if (cmd) {
460
483
  cmdList.push({ cmd, ts });
484
+ recentActivity.push({ kind: 'cmd', label: cmd, ts });
485
+ }
461
486
  }
462
487
  // Plan items: TodoWrite items + TaskCreate descriptions (project's task tracker)
463
488
  if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
@@ -477,18 +502,26 @@ export function renderSummary(events, cwd) {
477
502
  }
478
503
  // Subagent spawns
479
504
  if ((tool === 'Agent' || tool === 'Task') && (args.description || args.prompt)) {
480
- subagents.push({
481
- description: String(args.description || args.prompt || '').slice(0, 120),
482
- subagentType: String(args.subagent_type || ''),
483
- });
505
+ const description = String(args.description || args.prompt || '').slice(0, 120);
506
+ const subagentType = String(args.subagent_type || '');
507
+ subagents.push({ description, subagentType });
508
+ const typeSuffix = subagentType ? ` (${subagentType})` : '';
509
+ recentActivity.push({ kind: 'agent', label: description + typeSuffix, ts });
484
510
  }
485
511
  }
486
512
  else if (event.type === 'error') {
487
- errors.push({
513
+ const err = {
488
514
  tool: event.tool || 'unknown',
489
515
  cmd: event.args?.command ? String(event.args.command).slice(0, 80) : undefined,
490
516
  content: event.content?.slice(0, 120),
491
- });
517
+ };
518
+ errors.push(err);
519
+ const errLabel = err.cmd
520
+ ? `${err.tool} "${err.cmd.slice(0, 60)}"`
521
+ : err.content
522
+ ? `${err.tool}: ${err.content.slice(0, 60)}`
523
+ : err.tool;
524
+ recentActivity.push({ kind: 'error', label: errLabel, ts });
492
525
  }
493
526
  else if (event.type === 'message') {
494
527
  if (event.role === 'user') {
@@ -504,6 +537,9 @@ export function renderSummary(events, cwd) {
504
537
  else if (event.role === 'assistant' && event.content) {
505
538
  lastAssistantMessage = event.content;
506
539
  assistantCount++;
540
+ const preview = event.content.replace(/\s+/g, ' ').trim().slice(0, 100);
541
+ if (preview)
542
+ recentActivity.push({ kind: 'msg', label: preview, ts });
507
543
  }
508
544
  }
509
545
  else if (event.type === 'attachment') {
@@ -549,7 +585,18 @@ export function renderSummary(events, cwd) {
549
585
  }
550
586
  if (firstUserMessage || attachments.length > 0)
551
587
  lines.push('');
552
- // 2. Plan
588
+ // 2. Recent Activity (first content section — chronological tail of the
589
+ // session so the top of the recap reflects what happened most recently).
590
+ if (recentActivity.length > 0) {
591
+ const RECENT_LIMIT = 7;
592
+ const tail = recentActivity.slice(-RECENT_LIMIT);
593
+ lines.push(chalk.bold('Recent Activity') + chalk.gray(` (last ${tail.length} of ${recentActivity.length})`));
594
+ for (const item of tail) {
595
+ lines.push(' ' + renderActivityLine(item));
596
+ }
597
+ lines.push('');
598
+ }
599
+ // 3. Plan
553
600
  if (todoItems.length > 0 || exitPlanContent || planFilePath) {
554
601
  lines.push(chalk.bold('Plan'));
555
602
  if (planFilePath) {
@@ -569,7 +616,8 @@ export function renderSummary(events, cwd) {
569
616
  }
570
617
  lines.push('');
571
618
  }
572
- // 3. Subagents
619
+ // 4. Subagents (describe attempts — grouped with Plan and Errors above the
620
+ // file/command deltas because they speak to *what was tried*, not the final state).
573
621
  if (subagents.length > 0) {
574
622
  lines.push(chalk.bold('Subagents') + chalk.gray(` (${subagents.length})`));
575
623
  for (const s of subagents) {
@@ -578,14 +626,28 @@ export function renderSummary(events, cwd) {
578
626
  }
579
627
  lines.push('');
580
628
  }
581
- // 4. Modified files
629
+ // 5. Errors (moved up from the bottom: it describes failed attempts, not the
630
+ // session's final state. Sitting at the bottom previously made early errors
631
+ // look recent, which confused readers.)
632
+ if (errors.length > 0) {
633
+ const first = errors[0];
634
+ const firstDesc = first.cmd
635
+ ? `${first.tool} "${first.cmd.slice(0, 60)}"`
636
+ : first.content
637
+ ? `${first.tool}: ${first.content.slice(0, 60)}`
638
+ : first.tool;
639
+ lines.push(chalk.red(chalk.bold('Errors')) +
640
+ chalk.gray(`: ${errors.length} failure${errors.length !== 1 ? 's' : ''} — first: ${firstDesc}`));
641
+ lines.push('');
642
+ }
643
+ // 6. Modified files
582
644
  if (filesModifiedAbs.size > 0) {
583
645
  lines.push(chalk.bold('Modified') + chalk.gray(` (${filesModifiedAbs.size})`));
584
646
  const groups = groupByParentDir(filesModifiedAbs, cwd);
585
647
  renderFileGroup(lines, groups, modifiedAbsMap);
586
648
  lines.push('');
587
649
  }
588
- // 4b. External edits (files edited outside the project root — typically /tmp)
650
+ // 6b. External edits (files edited outside the project root — typically /tmp)
589
651
  // Filter out plan files (already shown in Plan section)
590
652
  const externalNonPlan = [...filesModifiedExternal].filter(p => !(p.includes('.claude/plans/') && p.endsWith('.md')));
591
653
  if (externalNonPlan.length > 0) {
@@ -596,7 +658,7 @@ export function renderSummary(events, cwd) {
596
658
  lines.push(chalk.gray(`External edits (${externalList.length}): ${display.join(', ')}${more}`));
597
659
  lines.push('');
598
660
  }
599
- // 5. Read files
661
+ // 7. Read files
600
662
  if (filesReadAbs.size > 0) {
601
663
  if (filesReadAbs.size <= 5) {
602
664
  lines.push(chalk.bold('Read') + chalk.gray(` (${filesReadAbs.size})`));
@@ -608,20 +670,8 @@ export function renderSummary(events, cwd) {
608
670
  }
609
671
  lines.push('');
610
672
  }
611
- // 6. Commands
673
+ // 8. Commands
612
674
  renderCommandsSection(cmdList, lines);
613
- // 7. Errors
614
- if (errors.length > 0) {
615
- const first = errors[0];
616
- const firstDesc = first.cmd
617
- ? `${first.tool} "${first.cmd.slice(0, 60)}"`
618
- : first.content
619
- ? `${first.tool}: ${first.content.slice(0, 60)}`
620
- : first.tool;
621
- lines.push(chalk.red(chalk.bold('Errors')) +
622
- chalk.gray(`: ${errors.length} failure${errors.length !== 1 ? 's' : ''} — first: ${firstDesc}`));
623
- lines.push('');
624
- }
625
675
  // 9. Final message
626
676
  if (lastAssistantMessage) {
627
677
  const hasActivity = filesModifiedAbs.size > 0 || filesReadAbs.size > 0 || cmdList.length > 0;
@@ -749,15 +799,10 @@ export function filterEvents(events, opts) {
749
799
  const sliced = applyTurnSlice(events, opts);
750
800
  return applyRoleFilter(sliced, opts);
751
801
  }
752
- // ── Conversation renderers ────────────────────────────────────────────────────
753
- /**
754
- * Build the conversation as a single markdown string: user / assistant
755
- * messages, inline thinking blocks, tool calls, and errors. Emitted in event
756
- * order so reasoning sits where it actually occurred relative to the assistant
757
- * reply.
758
- */
759
- export function renderConversationMarkdown(events) {
802
+ export function renderConversationMarkdown(events, opts = {}) {
760
803
  const parts = [];
804
+ const shouldRedact = opts.redact !== false;
805
+ const sanitize = (text) => shouldRedact ? redactSecrets(text) : text;
761
806
  for (const event of events) {
762
807
  if (event.type === 'message') {
763
808
  if (event.role === 'user') {
@@ -774,7 +819,7 @@ export function renderConversationMarkdown(events) {
774
819
  else if (event.type === 'tool_use') {
775
820
  const tool = event.tool || 'unknown';
776
821
  if (event.command) {
777
- parts.push(`### Tool: ${tool}\n\n\`\`\`bash\n${event.command}\n\`\`\``);
822
+ parts.push(`### Tool: ${tool}\n\n\`\`\`bash\n${sanitize(event.command)}\n\`\`\``);
778
823
  }
779
824
  else if (event.path) {
780
825
  parts.push(`### Tool: ${tool}\n\n\`${shortenPathTrace(event.path)}\``);
@@ -786,7 +831,8 @@ export function renderConversationMarkdown(events) {
786
831
  }
787
832
  else if (event.type === 'tool_result') {
788
833
  if (event.content) {
789
- const body = event.content.length > 2000 ? event.content.slice(0, 2000) + '\n…' : event.content;
834
+ const truncated = event.content.length > 2000 ? event.content.slice(0, 2000) + '\n…' : event.content;
835
+ const body = sanitize(truncated);
790
836
  parts.push(`### Tool Result\n\n\`\`\`\n${body}\n\`\`\``);
791
837
  }
792
838
  }
@@ -52,8 +52,11 @@ export interface ConflictInfo {
52
52
  * .oauth_token file on Linux (keychain-less sandbox fallback).
53
53
  * v11 — when no default is set or the configured version is not installed,
54
54
  * interactively propose the latest already-installed version.
55
+ * v12 — helper calls inside generated shims use the absolute agents-cli
56
+ * entrypoint instead of PATH-resolved `agents`.
57
+ * v13 — validate agents.yaml version strings before constructing binary paths.
55
58
  */
56
- export declare const SHIM_SCHEMA_VERSION = 11;
59
+ export declare const SHIM_SCHEMA_VERSION = 13;
57
60
  /**
58
61
  * Generate the full bash shim script for the given agent. The returned string
59
62
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -206,7 +209,7 @@ export declare function getPathShadowingExecutable(agent: AgentId): string | nul
206
209
  * Delete the legacy ~/.agents/shims/<cli> file if it exists, returning whether
207
210
  * anything was removed. Pre-split installs put shims under ~/.agents/shims/;
208
211
  * the new layout uses ~/.agents-system/shims/. The leftover file causes the
209
- * repair-prompt loop reported in RUSH-664 — `getPathShadowingExecutable` flags
212
+ * repair-prompt loop reported in EXAMPLE-664 — `getPathShadowingExecutable` flags
210
213
  * it as a shadow but `addShimsToPath` only edits rc files, never the file
211
214
  * itself. Removing it ends the loop.
212
215
  */
package/dist/lib/shims.js CHANGED
@@ -11,6 +11,7 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import * as os from 'os';
14
+ import { fileURLToPath } from 'url';
14
15
  import { confirm, select } from '@inquirer/prompts';
15
16
  import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
16
17
  export { getShimsDir };
@@ -175,10 +176,19 @@ async function promptConflictStrategy(conflictInfos) {
175
176
  * .oauth_token file on Linux (keychain-less sandbox fallback).
176
177
  * v11 — when no default is set or the configured version is not installed,
177
178
  * interactively propose the latest already-installed version.
179
+ * v12 — helper calls inside generated shims use the absolute agents-cli
180
+ * entrypoint instead of PATH-resolved `agents`.
181
+ * v13 — validate agents.yaml version strings before constructing binary paths.
178
182
  */
179
- export const SHIM_SCHEMA_VERSION = 11;
183
+ export const SHIM_SCHEMA_VERSION = 13;
180
184
  /** Internal marker string used to embed the schema version in shim scripts. */
181
185
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
186
+ function shellQuote(value) {
187
+ return `'${value.replace(/'/g, `'\\''`)}'`;
188
+ }
189
+ function getAgentsBinForGeneratedShim() {
190
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
191
+ }
182
192
  /**
183
193
  * Generate the full bash shim script for the given agent. The returned string
184
194
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -187,6 +197,7 @@ export function generateShimScript(agent) {
187
197
  const agentConfig = AGENTS[agent];
188
198
  const cliCommand = agentConfig.cliCommand;
189
199
  const configDirName = `.${agent}`;
200
+ const agentsBin = shellQuote(getAgentsBinForGeneratedShim());
190
201
  const managedEnv = agent === 'claude'
191
202
  ? `
192
203
  # Claude stores OAuth credentials in the macOS keychain. Scope them to the
@@ -217,7 +228,7 @@ export CODEX_HOME="$VERSION_DIR/home/${configDirName}"
217
228
  # Recompile rules if any rule/preset source has changed since last sync.
218
229
  # Fast-path check (~10-20ms) when nothing changed; full recompile only on
219
230
  # actual diff. Non-blocking failure — if the refresh errors, we still launch.
220
- agents refresh-rules --agent "$AGENT" --agent-version "$VERSION" --quiet 2>/dev/null || true
231
+ "$AGENTS_BIN" refresh-rules --agent "$AGENT" --agent-version "$VERSION" --quiet 2>/dev/null || true
221
232
  `
222
233
  : '';
223
234
  const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
@@ -228,9 +239,15 @@ agents refresh-rules --agent "$AGENT" --agent-version "$VERSION" --quiet 2>/dev/
228
239
 
229
240
  AGENTS_SYSTEM_DIR="$HOME/.agents-system"
230
241
  AGENTS_USER_DIR="$HOME/.agents"
242
+ AGENTS_BIN=${agentsBin}
231
243
  AGENT="${agent}"
232
244
  CLI_COMMAND="${cliCommand}"
233
245
 
246
+ if [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
247
+ echo "agents: agents-cli entrypoint missing or not executable: $AGENTS_BIN" >&2
248
+ exit 127
249
+ fi
250
+
234
251
  # Find project agents.yaml walking up from cwd (skip $HOME/.agents-system/agents.yaml)
235
252
  find_project_version() {
236
253
  local dir="$PWD"
@@ -325,7 +342,7 @@ if [ -z "$VERSION" ]; then
325
342
  read -r _ans </dev/tty
326
343
  case "$_ans" in
327
344
  ""|y|Y)
328
- agents use "$AGENT" "$LATEST" >/dev/null 2>&1
345
+ "$AGENTS_BIN" use "$AGENT" "$LATEST" >/dev/null 2>&1
329
346
  VERSION="$LATEST"
330
347
  VERSION_SOURCE="default"
331
348
  ;;
@@ -345,6 +362,11 @@ if [ -z "$VERSION" ]; then
345
362
  fi
346
363
  fi
347
364
 
365
+ if [[ ! "$VERSION" =~ ^(latest|[A-Za-z0-9._+-]{1,64})$ || "$VERSION" == *..* ]]; then
366
+ echo "agents: invalid version in agents.yaml for $AGENT: $VERSION. Allowed: latest or [A-Za-z0-9._+-]{1,64}" >&2
367
+ exit 1
368
+ fi
369
+
348
370
  VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
349
371
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
350
372
 
@@ -366,7 +388,7 @@ if [ ! -x "$BINARY" ]; then
366
388
  }
367
389
 
368
390
  # Run install in background with spinner
369
- agents add "$AGENT@$VERSION" --yes >/dev/null 2>&1 &
391
+ "$AGENTS_BIN" add "$AGENT@$VERSION" --yes >/dev/null 2>&1 &
370
392
  install_pid=$!
371
393
  spin $install_pid
372
394
  wait $install_pid
@@ -387,7 +409,7 @@ if [ ! -x "$BINARY" ]; then
387
409
  read -r _ans </dev/tty
388
410
  case "$_ans" in
389
411
  ""|y|Y)
390
- agents use "$AGENT" "$LATEST" >/dev/null 2>&1
412
+ "$AGENTS_BIN" use "$AGENT" "$LATEST" >/dev/null 2>&1
391
413
  VERSION="$LATEST"
392
414
  VERSION_DIR="$AGENTS_USER_DIR/.history/versions/$AGENT/$VERSION"
393
415
  BINARY="$VERSION_DIR/node_modules/.bin/$CLI_COMMAND"
@@ -412,7 +434,7 @@ fi
412
434
  # Sync project-scoped resources into version home if a project .agents/ is present
413
435
  PROJECT_AGENTS_DIR=$(find_project_agents_dir)
414
436
  if [ -n "$PROJECT_AGENTS_DIR" ]; then
415
- agents sync --agent "$AGENT" --agent-version "$VERSION" --project-dir "$PROJECT_AGENTS_DIR" --quiet >/dev/null 2>&1
437
+ "$AGENTS_BIN" sync --agent "$AGENT" --agent-version "$VERSION" --project-dir "$PROJECT_AGENTS_DIR" --quiet >/dev/null 2>&1
416
438
  fi
417
439
  ${refreshRulesCall}${managedEnv}
418
440
 
@@ -1144,7 +1166,7 @@ export function getPathShadowingExecutable(agent) {
1144
1166
  * Delete the legacy ~/.agents/shims/<cli> file if it exists, returning whether
1145
1167
  * anything was removed. Pre-split installs put shims under ~/.agents/shims/;
1146
1168
  * the new layout uses ~/.agents-system/shims/. The leftover file causes the
1147
- * repair-prompt loop reported in RUSH-664 — `getPathShadowingExecutable` flags
1169
+ * repair-prompt loop reported in EXAMPLE-664 — `getPathShadowingExecutable` flags
1148
1170
  * it as a shadow but `addShimsToPath` only edits rc files, never the file
1149
1171
  * itself. Removing it ends the loop.
1150
1172
  */
@@ -13,7 +13,7 @@
13
13
  * `agents repo push`.
14
14
  * - ~/.agents/.cache/ — regenerable runtime data (shims, packages, helpers
15
15
  * for daemon/pty, terminals, cloud, drive, browser
16
- * chrome-data, logs, swarmify). Gitignored.
16
+ * chrome-data, logs, companion). Gitignored.
17
17
  *
18
18
  * Resolution precedence for resources: project > user > system.
19
19
  * Every module that needs a path or reads/writes agents.yaml goes through here.
@@ -135,8 +135,8 @@ export declare function getTerminalsDir(): string;
135
135
  export declare function getLogsDir(): string;
136
136
  /** Path to per-process runtime state (~/.agents/.cache/state/). */
137
137
  export declare function getRuntimeStateDir(): string;
138
- /** Path to swarmify-extension scratch (~/.agents/.cache/swarmify/). */
139
- export declare function getSwarmifyDir(): string;
138
+ /** Path to companion-extension scratch (~/.agents/.cache/companion/). */
139
+ export declare function getCompanionDir(): string;
140
140
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
141
141
  export declare function getBrowserRuntimeDir(): string;
142
142
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
@@ -195,8 +195,8 @@ export declare function createDefaultMeta(): Meta;
195
195
  export declare function readMeta(): Meta;
196
196
  /** Serialize and write agents.yaml to the user repo, invalidating the in-memory cache. */
197
197
  export declare function writeMeta(meta: Meta): void;
198
- /** Shallow-merge updates into agents.yaml and return the new state. */
199
- export declare function updateMeta(updates: Partial<Meta>): Meta;
198
+ /** Update agents.yaml under lock and return the new state. */
199
+ export declare function updateMeta(updates: Partial<Meta> | ((meta: Meta) => Meta)): Meta;
200
200
  /** Derive a filesystem-safe local clone path for a package source URL. */
201
201
  export declare function getPackageLocalPath(source: string): string;
202
202
  import type { AgentId, ResourceType, VersionResources, ResourcePattern } from './types.js';
package/dist/lib/state.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * `agents repo push`.
14
14
  * - ~/.agents/.cache/ — regenerable runtime data (shims, packages, helpers
15
15
  * for daemon/pty, terminals, cloud, drive, browser
16
- * chrome-data, logs, swarmify). Gitignored.
16
+ * chrome-data, logs, companion). Gitignored.
17
17
  *
18
18
  * Resolution precedence for resources: project > user > system.
19
19
  * Every module that needs a path or reads/writes agents.yaml goes through here.
@@ -22,6 +22,7 @@ import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import * as os from 'os';
24
24
  import * as yaml from 'yaml';
25
+ import { ensureLockTarget, atomicWriteFileSync, withFileLock } from './fs-atomic.js';
25
26
  import { SEEDED_REGISTRIES } from './types.js';
26
27
  const HOME = process.env.HOME ?? os.homedir();
27
28
  // ─── Root directories ─────────────────────────────────────────────────────────
@@ -73,7 +74,7 @@ const DRIVE_DIR = path.join(CACHE_DIR, 'drive');
73
74
  const TERMINALS_DIR = path.join(CACHE_DIR, 'terminals');
74
75
  const LOGS_DIR = path.join(CACHE_DIR, 'logs');
75
76
  const RUNTIME_STATE_DIR = path.join(CACHE_DIR, 'state');
76
- const SWARMIFY_DIR = path.join(CACHE_DIR, 'swarmify');
77
+ const SWARMIFY_DIR = path.join(CACHE_DIR, 'companion');
77
78
  const BROWSER_RUNTIME_DIR = path.join(CACHE_DIR, 'browser');
78
79
  const HELPERS_DIR = path.join(CACHE_DIR, 'helpers');
79
80
  const DAEMON_DIR = path.join(HELPERS_DIR, 'daemon');
@@ -292,8 +293,8 @@ export function getTerminalsDir() { return TERMINALS_DIR; }
292
293
  export function getLogsDir() { return LOGS_DIR; }
293
294
  /** Path to per-process runtime state (~/.agents/.cache/state/). */
294
295
  export function getRuntimeStateDir() { return RUNTIME_STATE_DIR; }
295
- /** Path to swarmify-extension scratch (~/.agents/.cache/swarmify/). */
296
- export function getSwarmifyDir() { return SWARMIFY_DIR; }
296
+ /** Path to companion-extension scratch (~/.agents/.cache/companion/). */
297
+ export function getCompanionDir() { return SWARMIFY_DIR; }
297
298
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
298
299
  export function getBrowserRuntimeDir() { return BROWSER_RUNTIME_DIR; }
299
300
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
@@ -411,6 +412,34 @@ export function createDefaultMeta() {
411
412
  return {};
412
413
  }
413
414
  let metaCache = null;
415
+ let metaLockDepth = 0;
416
+ function withMetaLock(fn) {
417
+ ensureAgentsDir();
418
+ if (metaLockDepth > 0) {
419
+ metaLockDepth++;
420
+ try {
421
+ return fn();
422
+ }
423
+ finally {
424
+ metaLockDepth--;
425
+ }
426
+ }
427
+ ensureLockTarget(META_FILE, META_HEADER + yaml.stringify(createDefaultMeta()), 0o700);
428
+ return withFileLock(META_FILE, () => {
429
+ metaLockDepth = 1;
430
+ try {
431
+ return fn();
432
+ }
433
+ finally {
434
+ metaLockDepth = 0;
435
+ }
436
+ });
437
+ }
438
+ function writeMetaUnlocked(meta) {
439
+ const content = META_HEADER + yaml.stringify(meta);
440
+ atomicWriteFileSync(META_FILE, content);
441
+ metaCache = null;
442
+ }
414
443
  function applyRegistrySeeds(meta) {
415
444
  const seeded = new Set(meta.seededPresets || []);
416
445
  let changed = false;
@@ -540,17 +569,18 @@ export function readMeta() {
540
569
  }
541
570
  /** Serialize and write agents.yaml to the user repo, invalidating the in-memory cache. */
542
571
  export function writeMeta(meta) {
543
- ensureAgentsDir();
544
- const content = META_HEADER + yaml.stringify(meta);
545
- fs.writeFileSync(META_FILE, content, 'utf-8');
546
- metaCache = null;
572
+ withMetaLock(() => writeMetaUnlocked(meta));
547
573
  }
548
- /** Shallow-merge updates into agents.yaml and return the new state. */
574
+ /** Update agents.yaml under lock and return the new state. */
549
575
  export function updateMeta(updates) {
550
- const meta = readMeta();
551
- const newMeta = { ...meta, ...updates };
552
- writeMeta(newMeta);
553
- return newMeta;
576
+ return withMetaLock(() => {
577
+ const meta = readMeta();
578
+ const newMeta = typeof updates === 'function'
579
+ ? updates(meta)
580
+ : { ...meta, ...updates };
581
+ writeMetaUnlocked(newMeta);
582
+ return newMeta;
583
+ });
554
584
  }
555
585
  /** Derive a filesystem-safe local clone path for a package source URL. */
556
586
  export function getPackageLocalPath(source) {
@@ -36,7 +36,7 @@ export declare function captureProcessStartTime(pid: number): string | null;
36
36
  * model_reasoning_effort override). Mode (plan/edit/full) is a separate knob.
37
37
  */
38
38
  export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
39
- declare const VALID_MODES: readonly ["plan", "edit", "full"];
39
+ declare const VALID_MODES: readonly ["plan", "edit", "full", "auto"];
40
40
  type Mode = typeof VALID_MODES[number];
41
41
  /** Resolve a mode string to a validated Mode, falling back to the given default. */
42
42
  export declare function resolveMode(requestedMode: string | null | undefined, defaultMode?: Mode): Mode;
@@ -183,7 +183,7 @@ When you're done, provide a brief summary of:
183
183
  const CLAUDE_PLAN_MODE_PREFIX = `You are running in HEADLESS PLAN MODE. This mode works like normal plan mode with one exception: you cannot write to ~/.claude/plans/ directory. Instead of writing a plan file, output your complete plan/response as your final message.
184
184
 
185
185
  `;
186
- const VALID_MODES = ['plan', 'edit', 'full'];
186
+ const VALID_MODES = ['plan', 'edit', 'full', 'auto'];
187
187
  function normalizeModeValue(modeValue) {
188
188
  if (!modeValue)
189
189
  return null;
@@ -295,7 +295,7 @@ export function checkCliAvailable(agentType) {
295
295
  return [false, `Unknown agent type: ${agentType}`];
296
296
  }
297
297
  try {
298
- const whichPath = execSync(`which ${executable}`, { encoding: 'utf-8' }).trim();
298
+ const whichPath = execFileSync('which', [executable], { encoding: 'utf-8' }).trim();
299
299
  return [true, whichPath];
300
300
  }
301
301
  catch {