@phnx-labs/agents-cli 1.21.3 → 1.22.1

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 (90) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/doctor.js +34 -2
  6. package/dist/commands/exec.d.ts +27 -0
  7. package/dist/commands/exec.js +123 -6
  8. package/dist/commands/models.js +36 -1
  9. package/dist/commands/projects.js +120 -62
  10. package/dist/commands/sessions-backfill.d.ts +32 -0
  11. package/dist/commands/sessions-backfill.js +186 -0
  12. package/dist/commands/sessions.d.ts +17 -1
  13. package/dist/commands/sessions.js +317 -18
  14. package/dist/commands/teams.js +1 -1
  15. package/dist/commands/worktree.d.ts +3 -3
  16. package/dist/commands/worktree.js +35 -4
  17. package/dist/lib/app-bundle-install.d.ts +17 -0
  18. package/dist/lib/app-bundle-install.js +94 -0
  19. package/dist/lib/daemon.d.ts +5 -1
  20. package/dist/lib/daemon.js +63 -14
  21. package/dist/lib/devices/doctor-findings.js +12 -4
  22. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  23. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  24. package/dist/lib/devices/fleet.js +7 -2
  25. package/dist/lib/devices/resolve-target.d.ts +6 -0
  26. package/dist/lib/devices/resolve-target.js +9 -3
  27. package/dist/lib/devices/self-host.d.ts +9 -0
  28. package/dist/lib/devices/self-host.js +61 -0
  29. package/dist/lib/exec.js +39 -8
  30. package/dist/lib/hosts/dispatch.d.ts +12 -0
  31. package/dist/lib/hosts/dispatch.js +23 -6
  32. package/dist/lib/hosts/passthrough.js +8 -6
  33. package/dist/lib/hosts/reconnect.d.ts +38 -0
  34. package/dist/lib/hosts/reconnect.js +85 -4
  35. package/dist/lib/hosts/run-target.js +14 -2
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  38. package/dist/lib/menubar/install-menubar.js +27 -23
  39. package/dist/lib/model-tiers.d.ts +54 -0
  40. package/dist/lib/model-tiers.js +229 -0
  41. package/dist/lib/models.d.ts +3 -0
  42. package/dist/lib/models.js +44 -7
  43. package/dist/lib/pricing/prices.json +16 -1
  44. package/dist/lib/project-focus.d.ts +42 -0
  45. package/dist/lib/project-focus.js +80 -0
  46. package/dist/lib/project-schedule.d.ts +75 -0
  47. package/dist/lib/project-schedule.js +110 -0
  48. package/dist/lib/project-status.d.ts +7 -0
  49. package/dist/lib/project-status.js +9 -0
  50. package/dist/lib/projects.d.ts +11 -2
  51. package/dist/lib/projects.js +57 -9
  52. package/dist/lib/redact.d.ts +2 -0
  53. package/dist/lib/redact.js +22 -0
  54. package/dist/lib/remote-agents-json.d.ts +2 -0
  55. package/dist/lib/remote-agents-json.js +3 -3
  56. package/dist/lib/rotate.d.ts +84 -1
  57. package/dist/lib/rotate.js +155 -5
  58. package/dist/lib/runner.d.ts +4 -2
  59. package/dist/lib/runner.js +13 -4
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/secrets/install-helper.js +28 -31
  63. package/dist/lib/session/bash-command.js +60 -9
  64. package/dist/lib/session/db.d.ts +7 -1
  65. package/dist/lib/session/db.js +301 -32
  66. package/dist/lib/session/discover.d.ts +40 -7
  67. package/dist/lib/session/discover.js +144 -83
  68. package/dist/lib/session/parse.d.ts +8 -1
  69. package/dist/lib/session/parse.js +83 -32
  70. package/dist/lib/session/remote-list.d.ts +71 -0
  71. package/dist/lib/session/remote-list.js +410 -2
  72. package/dist/lib/session/shell-programs.d.ts +15 -0
  73. package/dist/lib/session/shell-programs.js +359 -0
  74. package/dist/lib/session/tool-calls.d.ts +88 -0
  75. package/dist/lib/session/tool-calls.js +612 -0
  76. package/dist/lib/session/tool-index.d.ts +100 -0
  77. package/dist/lib/session/tool-index.js +773 -0
  78. package/dist/lib/session/tool-store.d.ts +15 -0
  79. package/dist/lib/session/tool-store.js +198 -0
  80. package/dist/lib/session/types.d.ts +7 -0
  81. package/dist/lib/state.d.ts +10 -1
  82. package/dist/lib/state.js +11 -2
  83. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  84. package/dist/lib/teams/remoteWorktree.js +3 -4
  85. package/dist/lib/teams/worktree.d.ts +11 -1
  86. package/dist/lib/teams/worktree.js +42 -4
  87. package/dist/lib/types.d.ts +17 -0
  88. package/dist/lib/types.js +17 -0
  89. package/dist/lib/versions.js +69 -22
  90. package/package.json +3 -1
@@ -0,0 +1,15 @@
1
+ import type Database from '../sqlite.js';
2
+ import { type IndexedToolCall } from './tool-calls.js';
3
+ import type { SessionMeta } from './types.js';
4
+ export declare function canonicalToolLedgerPath(filePath: string): string;
5
+ /** Resolve the transcript that actually carries tool events for split-file harnesses. */
6
+ export declare function toolEvidenceSourcePath(filePath: string, agent: string): string;
7
+ /** Remove cached tool evidence when its source transcript no longer exists. */
8
+ export declare function purgeToolCalls(db: Database.Database, sessionId: string): void;
9
+ /** Purge deleted direct children when a transcript directory's stamp changes. */
10
+ export declare function purgeMissingToolCallsInDirectory(db: Database.Database, dirPath: string, currentFilePaths: string[]): number;
11
+ /** Persist one parser batch and its file stamp atomically. */
12
+ export declare function persistToolCalls(db: Database.Database, session: SessionMeta, calls: IndexedToolCall[], sourceStamp: {
13
+ fileMtimeMs: number;
14
+ fileSize: number;
15
+ }, mode?: 'replace' | 'append', maxSessionBytes?: number): void;
@@ -0,0 +1,198 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { TOOL_INDEX_VERSION, TOOL_INDEX_LIMIT_ORDINAL, TOOL_SESSION_EVIDENCE_MAX_BYTES, toolCallEvidenceBytes, toolCallKey, } from './tool-calls.js';
4
+ export function canonicalToolLedgerPath(filePath) {
5
+ let probe = path.resolve(filePath);
6
+ const suffix = [];
7
+ while (true) {
8
+ try {
9
+ return path.join(fs.realpathSync(probe), ...suffix.reverse());
10
+ }
11
+ catch {
12
+ const parent = path.dirname(probe);
13
+ if (parent === probe)
14
+ return path.resolve(filePath);
15
+ suffix.push(path.basename(probe));
16
+ probe = parent;
17
+ }
18
+ }
19
+ }
20
+ /** Resolve the transcript that actually carries tool events for split-file harnesses. */
21
+ export function toolEvidenceSourcePath(filePath, agent) {
22
+ if (agent === 'kimi' && path.basename(filePath) === 'state.json') {
23
+ return path.join(path.dirname(filePath), 'agents', 'main', 'wire.jsonl');
24
+ }
25
+ if (agent === 'grok' && path.basename(filePath) === 'summary.json') {
26
+ return path.join(path.dirname(filePath), 'chat_history.jsonl');
27
+ }
28
+ return filePath;
29
+ }
30
+ function deleteSessionCalls(db, sessionId) {
31
+ const keys = db.prepare(`SELECT call_key FROM tool_calls WHERE session_id = ?`).all(sessionId);
32
+ for (const { call_key } of keys) {
33
+ db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`).run(call_key);
34
+ db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`).run(call_key);
35
+ db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`).run(call_key);
36
+ }
37
+ db.prepare(`DELETE FROM tool_calls WHERE session_id = ?`).run(sessionId);
38
+ }
39
+ /** Remove cached tool evidence when its source transcript no longer exists. */
40
+ export function purgeToolCalls(db, sessionId) {
41
+ deleteSessionCalls(db, sessionId);
42
+ db.prepare(`DELETE FROM tool_scan_ledger WHERE session_id = ?`).run(sessionId);
43
+ }
44
+ /** Purge deleted direct children when a transcript directory's stamp changes. */
45
+ export function purgeMissingToolCallsInDirectory(db, dirPath, currentFilePaths) {
46
+ const normalizedDir = path.normalize(dirPath);
47
+ const prefix = normalizedDir.endsWith(path.sep) ? normalizedDir : normalizedDir + path.sep;
48
+ const present = new Set(currentFilePaths.map((filePath) => path.normalize(filePath)));
49
+ const rows = db.prepare(`
50
+ SELECT s.id, s.file_path, s.agent
51
+ FROM sessions s
52
+ WHERE substr(s.file_path, 1, ?) = ?
53
+ `).all(prefix.length, prefix);
54
+ let purged = 0;
55
+ for (const row of rows) {
56
+ const filePath = path.normalize(row.file_path);
57
+ if (path.dirname(filePath) !== normalizedDir || present.has(filePath))
58
+ continue;
59
+ purgeToolCalls(db, row.id);
60
+ purged++;
61
+ }
62
+ return purged;
63
+ }
64
+ /** Persist one parser batch and its file stamp atomically. */
65
+ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replace', maxSessionBytes = TOOL_SESSION_EVIDENCE_MAX_BYTES) {
66
+ const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
67
+ const insertCall = db.prepare(`
68
+ INSERT INTO tool_calls (
69
+ call_key, session_id, ordinal, source_call_id, timestamp, tool, input,
70
+ outcome, exit_code, status_code, error_code, output, error, parse_error
71
+ , evidence_bytes
72
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(session_id, ordinal) DO UPDATE SET
74
+ source_call_id = excluded.source_call_id,
75
+ timestamp = excluded.timestamp,
76
+ tool = excluded.tool,
77
+ input = excluded.input,
78
+ outcome = excluded.outcome,
79
+ exit_code = excluded.exit_code,
80
+ status_code = excluded.status_code,
81
+ error_code = excluded.error_code,
82
+ output = excluded.output,
83
+ error = excluded.error,
84
+ parse_error = excluded.parse_error,
85
+ evidence_bytes = excluded.evidence_bytes
86
+ `);
87
+ const insertProgram = db.prepare(`INSERT OR IGNORE INTO tool_call_programs (call_key, program) VALUES (?, ?)`);
88
+ const insertOccurrence = db.prepare(`
89
+ INSERT INTO tool_program_occurrences (call_key, occurrence_ordinal, program, role)
90
+ VALUES (?, ?, ?, ?)
91
+ `);
92
+ const insertText = db.prepare(`INSERT INTO tool_call_text (call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?)`);
93
+ const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
94
+ const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
95
+ const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`);
96
+ const deleteCall = db.prepare(`DELETE FROM tool_calls WHERE call_key = ?`);
97
+ const writeLedger = db.prepare(`
98
+ INSERT INTO tool_scan_ledger (
99
+ session_id, file_path, file_mtime_ms, file_size, extractor_version, indexed_at, call_count,
100
+ evidence_bytes
101
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
102
+ ON CONFLICT(session_id) DO UPDATE SET
103
+ file_path = excluded.file_path,
104
+ file_mtime_ms = excluded.file_mtime_ms,
105
+ file_size = excluded.file_size,
106
+ extractor_version = excluded.extractor_version,
107
+ indexed_at = excluded.indexed_at,
108
+ call_count = excluded.call_count,
109
+ evidence_bytes = excluded.evidence_bytes
110
+ `);
111
+ const txn = db.transaction(() => {
112
+ if (mode === 'replace')
113
+ deleteSessionCalls(db, session.id);
114
+ const ledgerPath = canonicalToolLedgerPath(sourcePath);
115
+ const priorLedger = mode === 'append'
116
+ ? db.prepare(`SELECT call_count, evidence_bytes FROM tool_scan_ledger WHERE session_id = ?`).get(session.id)
117
+ : undefined;
118
+ if (mode === 'append' && !priorLedger) {
119
+ throw new Error('Append tool evidence requires an existing tool ledger row.');
120
+ }
121
+ if (mode === 'append' && calls.length === 0) {
122
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes);
123
+ return;
124
+ }
125
+ const limitKey = toolCallKey(session.id, TOOL_INDEX_LIMIT_ORDINAL);
126
+ const existingSize = db.prepare(`
127
+ SELECT evidence_bytes FROM tool_calls WHERE session_id = ? AND ordinal = ?
128
+ `);
129
+ const priorLimit = mode === 'append'
130
+ ? existingSize.get(session.id, TOOL_INDEX_LIMIT_ORDINAL)
131
+ : undefined;
132
+ deletePrograms.run(limitKey);
133
+ deleteOccurrences.run(limitKey);
134
+ deleteText.run(limitKey);
135
+ deleteCall.run(limitKey);
136
+ const existingRows = mode === 'append'
137
+ ? calls.map((call) => ({
138
+ ordinal: call.ordinal,
139
+ bytes: existingSize.get(session.id, call.ordinal)?.evidence_bytes ?? 0,
140
+ }))
141
+ : [];
142
+ const existingByOrdinal = new Map(existingRows.map((row) => [row.ordinal, row.bytes]));
143
+ let storedBytes = priorLedger?.evidence_bytes ?? 0;
144
+ let count = priorLedger?.call_count ?? 0;
145
+ if (priorLimit) {
146
+ storedBytes -= priorLimit.evidence_bytes;
147
+ count--;
148
+ }
149
+ const limitCall = {
150
+ ordinal: TOOL_INDEX_LIMIT_ORDINAL,
151
+ timestamp: session.timestamp,
152
+ tool: 'index_limit',
153
+ programs: [],
154
+ programOccurrences: [],
155
+ input: `Tool evidence stopped at the ${Math.floor(maxSessionBytes / 1024 / 1024)} MiB per-session payload limit.`,
156
+ outcome: 'unknown',
157
+ parseError: 'Additional tool calls were not indexed for this session.',
158
+ };
159
+ const limitBytes = toolCallEvidenceBytes(limitCall);
160
+ const ordinaryLimit = Math.max(0, maxSessionBytes - limitBytes);
161
+ const accepted = [];
162
+ let truncated = false;
163
+ for (const call of [...calls].sort((a, b) => a.ordinal - b.ordinal)) {
164
+ const replacingBytes = existingByOrdinal.get(call.ordinal) ?? 0;
165
+ const nextBytes = storedBytes - replacingBytes + toolCallEvidenceBytes(call);
166
+ if (nextBytes > ordinaryLimit) {
167
+ truncated = true;
168
+ break;
169
+ }
170
+ storedBytes = nextBytes;
171
+ accepted.push(call);
172
+ if (!existingByOrdinal.has(call.ordinal) || replacingBytes === 0)
173
+ count++;
174
+ }
175
+ if (truncated) {
176
+ if (storedBytes + limitBytes <= maxSessionBytes) {
177
+ accepted.push(limitCall);
178
+ storedBytes += limitBytes;
179
+ count++;
180
+ }
181
+ }
182
+ for (const call of accepted) {
183
+ const key = toolCallKey(session.id, call.ordinal);
184
+ insertCall.run(key, session.id, call.ordinal, call.sourceCallId ?? null, call.timestamp, call.tool, call.input, call.outcome, call.exitCode ?? null, call.statusCode ?? null, call.errorCode ?? null, call.output ?? null, call.error ?? null, call.parseError ?? null, toolCallEvidenceBytes(call));
185
+ deletePrograms.run(key);
186
+ deleteOccurrences.run(key);
187
+ deleteText.run(key);
188
+ for (const program of call.programs)
189
+ insertProgram.run(key, program);
190
+ call.programOccurrences.forEach((occurrence, occurrenceOrdinal) => {
191
+ insertOccurrence.run(key, occurrenceOrdinal, occurrence.program, occurrence.role);
192
+ });
193
+ insertText.run(key, call.tool, call.input, call.output ?? '', call.error ?? '');
194
+ }
195
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes);
196
+ });
197
+ txn();
198
+ }
@@ -24,10 +24,17 @@ export interface SessionEvent {
24
24
  role?: 'user' | 'assistant';
25
25
  content?: string;
26
26
  tool?: string;
27
+ /** Harness-native call identity, used to correlate concurrent results. */
28
+ callId?: string;
27
29
  args?: Record<string, any>;
28
30
  path?: string;
29
31
  command?: string;
30
32
  success?: boolean;
33
+ /** Structured harness outcome; never inferred from free-text output. */
34
+ outcome?: 'ok' | 'error' | 'unknown';
35
+ exitCode?: number;
36
+ statusCode?: number;
37
+ errorCode?: string;
31
38
  output?: string;
32
39
  /** Internal: marks tool_use events from local commands */
33
40
  _local?: boolean;
@@ -272,7 +272,16 @@ export declare function getCompanionDir(): string;
272
272
  export declare function getBrowserRuntimeDir(): string;
273
273
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
274
274
  export declare function getHelpersDir(): string;
275
- /** Path to scheduler daemon scratch (~/.agents/.cache/helpers/daemon/). */
275
+ /**
276
+ * Path to scheduler daemon scratch (~/.agents/.cache/helpers/daemon/) — holds
277
+ * the daemon pid file, heartbeat, start lock, and log. AGENTS_DAEMON_DIR
278
+ * redirects it to a fork-private temp so daemon tests (which write pid/heartbeat
279
+ * files and acquire the real start lock) can never clobber a live daemon's state
280
+ * on a dev machine — the surgical mirror of AGENTS_DEVICES_DIR /
281
+ * AGENTS_HOOK_SHIMS_DIR, leaving HOME untouched. Read at CALL time (daemon.ts
282
+ * resolves every path helper through this), so tests/setup.ts can set it before
283
+ * the daemon module is exercised. Never set in production code.
284
+ */
276
285
  export declare function getDaemonDir(): string;
277
286
  /** Path to PTY server scratch (~/.agents/.cache/helpers/pty/). */
278
287
  export declare function getPtyDir(): string;
package/dist/lib/state.js CHANGED
@@ -579,8 +579,17 @@ export function getCompanionDir() { return COMPANION_CACHE_DIR; }
579
579
  export function getBrowserRuntimeDir() { return BROWSER_RUNTIME_DIR; }
580
580
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
581
581
  export function getHelpersDir() { return HELPERS_DIR; }
582
- /** Path to scheduler daemon scratch (~/.agents/.cache/helpers/daemon/). */
583
- export function getDaemonDir() { return DAEMON_DIR; }
582
+ /**
583
+ * Path to scheduler daemon scratch (~/.agents/.cache/helpers/daemon/) — holds
584
+ * the daemon pid file, heartbeat, start lock, and log. AGENTS_DAEMON_DIR
585
+ * redirects it to a fork-private temp so daemon tests (which write pid/heartbeat
586
+ * files and acquire the real start lock) can never clobber a live daemon's state
587
+ * on a dev machine — the surgical mirror of AGENTS_DEVICES_DIR /
588
+ * AGENTS_HOOK_SHIMS_DIR, leaving HOME untouched. Read at CALL time (daemon.ts
589
+ * resolves every path helper through this), so tests/setup.ts can set it before
590
+ * the daemon module is exercised. Never set in production code.
591
+ */
592
+ export function getDaemonDir() { return process.env.AGENTS_DAEMON_DIR ?? DAEMON_DIR; }
584
593
  /** Path to PTY server scratch (~/.agents/.cache/helpers/pty/). */
585
594
  export function getPtyDir() { return PTY_DIR; }
586
595
  /** Path to tmux scratch (~/.agents/.cache/helpers/tmux/) — shared server socket + per-session meta JSONs. */
@@ -26,10 +26,9 @@ export declare function remoteDefaultBranch(target: string, repoPath: string): s
26
26
  * Create a worktree on the host for a teammate, branched off the freshly-fetched
27
27
  * default branch. Returns the absolute worktree path on the host.
28
28
  *
29
- * Mirrors local `createWorktree`, with two deliberate differences: it fetches
30
- * origin first (the orchestrator can't reach into the host's tree to do it), and
31
- * it bases the branch on `origin/<default>` rather than local `HEAD` so a stale
32
- * checkout on the host can't fork the teammate off old code.
29
+ * Same base policy as local `createWorktree`: fetch origin, then
30
+ * `worktree add -b … origin/<default>` so a stale checkout can't fork the
31
+ * teammate off old code.
33
32
  */
34
33
  export declare function createRemoteWorktree(target: string, repoPath: string, worktreeName: string): string;
35
34
  /**
@@ -87,10 +87,9 @@ export function remoteDefaultBranch(target, repoPath) {
87
87
  * Create a worktree on the host for a teammate, branched off the freshly-fetched
88
88
  * default branch. Returns the absolute worktree path on the host.
89
89
  *
90
- * Mirrors local `createWorktree`, with two deliberate differences: it fetches
91
- * origin first (the orchestrator can't reach into the host's tree to do it), and
92
- * it bases the branch on `origin/<default>` rather than local `HEAD` so a stale
93
- * checkout on the host can't fork the teammate off old code.
90
+ * Same base policy as local `createWorktree`: fetch origin, then
91
+ * `worktree add -b … origin/<default>` so a stale checkout can't fork the
92
+ * teammate off old code.
94
93
  */
95
94
  export function createRemoteWorktree(target, repoPath, worktreeName) {
96
95
  assertValidSshTarget(target);
@@ -22,7 +22,17 @@ export declare const DEFAULT_DIFF_MAX_BYTES = 200000;
22
22
  */
23
23
  export declare function gitDiff(worktreePath: string, maxBytes?: number): Promise<string>;
24
24
  /**
25
- * Create a new git worktree for a teammate.
25
+ * Resolve the repo's default branch name (origin/HEAD → `main`/`master`/…).
26
+ * Refreshes origin/HEAD first so a repo cloned before the default was set resolves.
27
+ * Falls back to `main` when origin/HEAD isn't set.
28
+ */
29
+ export declare function localDefaultBranch(gitRoot: string): Promise<string>;
30
+ /**
31
+ * Create a new git worktree for a teammate, branched off the freshly-fetched
32
+ * default branch. Fetches `origin` first and bases the branch on
33
+ * `origin/<default>` so a stale local checkout cannot fork teammates off old
34
+ * code. Matches `createRemoteWorktree` — local and remote isolation share one
35
+ * base policy.
26
36
  *
27
37
  * @param repoDir - Directory inside the git repository
28
38
  * @param worktreeName - Name for the worktree (used in path and branch)
@@ -77,7 +77,34 @@ export async function gitDiff(worktreePath, maxBytes = DEFAULT_DIFF_MAX_BYTES) {
77
77
  }
78
78
  }
79
79
  /**
80
- * Create a new git worktree for a teammate.
80
+ * Resolve the repo's default branch name (origin/HEAD → `main`/`master`/…).
81
+ * Refreshes origin/HEAD first so a repo cloned before the default was set resolves.
82
+ * Falls back to `main` when origin/HEAD isn't set.
83
+ */
84
+ export async function localDefaultBranch(gitRoot) {
85
+ try {
86
+ await execFileAsync('git', ['remote', 'set-head', 'origin', '--auto'], { cwd: gitRoot });
87
+ }
88
+ catch {
89
+ // Offline / no origin — fall through to symbolic-ref or main.
90
+ }
91
+ try {
92
+ const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: gitRoot });
93
+ const base = stdout.trim().replace(/^origin\//, '');
94
+ if (base)
95
+ return base;
96
+ }
97
+ catch {
98
+ // no origin/HEAD
99
+ }
100
+ return 'main';
101
+ }
102
+ /**
103
+ * Create a new git worktree for a teammate, branched off the freshly-fetched
104
+ * default branch. Fetches `origin` first and bases the branch on
105
+ * `origin/<default>` so a stale local checkout cannot fork teammates off old
106
+ * code. Matches `createRemoteWorktree` — local and remote isolation share one
107
+ * base policy.
81
108
  *
82
109
  * @param repoDir - Directory inside the git repository
83
110
  * @param worktreeName - Name for the worktree (used in path and branch)
@@ -90,10 +117,21 @@ export async function createWorktree(repoDir, worktreeName) {
90
117
  const gitRoot = await getGitRoot(repoDir);
91
118
  const worktreePath = safeJoin(path.join(gitRoot, '.agents', 'worktrees'), worktreeName);
92
119
  const branchName = `agents/${worktreeName}`;
120
+ const base = await localDefaultBranch(gitRoot);
93
121
  await fs.mkdir(path.dirname(worktreePath), { recursive: true });
94
- await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath, 'HEAD'], {
95
- cwd: gitRoot,
96
- });
122
+ // Fetch first so origin/<default> is current. Fail loud on network errors —
123
+ // silent fallback to HEAD is how swarms write on a days-stale base and only
124
+ // discover it at merge time.
125
+ try {
126
+ await execFileAsync('git', ['fetch', 'origin'], { cwd: gitRoot });
127
+ }
128
+ catch (err) {
129
+ const detail = (err?.stderr || err?.message || String(err)).toString().trim();
130
+ throw new Error(`createWorktree: git fetch origin failed in ${gitRoot}` +
131
+ (detail ? `: ${detail}` : '') +
132
+ `. Cannot base a teammate worktree on a stale remote-tracking ref.`);
133
+ }
134
+ await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath, `origin/${base}`], { cwd: gitRoot });
97
135
  return worktreePath;
98
136
  }
99
137
  /**
@@ -11,6 +11,23 @@ import type { FeedBroadcastConfig } from './feed-broadcast.js';
11
11
  export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes';
12
12
  /** How `agents run <agent>` chooses an installed version when none is pinned. */
13
13
  export type RunStrategy = 'pinned' | 'available' | 'balanced';
14
+ /**
15
+ * Reserved `<agent>` keyword for `agents run auto` — full-auto dispatch:
16
+ * host (14d launch affinity) → harness (best-account headroom, weighted) →
17
+ * account (balanced). Lives in lib (not commands/) because both the run
18
+ * command (exec.ts) and the host dispatch layer (hosts/dispatch.ts, which
19
+ * arms the chain-hop guard for remote `run auto`) must agree on it.
20
+ */
21
+ export declare const RUN_AUTO_KEYWORD = "auto";
22
+ /**
23
+ * Env var a host dispatcher exports into the remote SHELL when it dispatches
24
+ * `agents run auto`: tells the remote CLI its host layer is already resolved,
25
+ * so it must not re-run affinity and chain-hop to a third host. It rides the
26
+ * shell-export prelude (hosts/dispatch.ts `remoteRunShellPrelude`) because
27
+ * `--env` flags only reach the spawned AGENT's env — the remote CLI's own
28
+ * process.env (which exec.ts `runAutoDefaultsToAffinity` reads) never sees them.
29
+ */
30
+ export declare const RUN_AUTO_HOST_RESOLVED_ENV = "AGENTS_RUN_AUTO_HOST_RESOLVED";
14
31
  /** Per-agent run strategy config. */
15
32
  export interface AgentRunConfig {
16
33
  strategy?: RunStrategy;
package/dist/lib/types.js CHANGED
@@ -5,6 +5,23 @@
5
5
  * configuration schemas, resource tracking, registry types, and permission
6
6
  * formats for each supported agent.
7
7
  */
8
+ /**
9
+ * Reserved `<agent>` keyword for `agents run auto` — full-auto dispatch:
10
+ * host (14d launch affinity) → harness (best-account headroom, weighted) →
11
+ * account (balanced). Lives in lib (not commands/) because both the run
12
+ * command (exec.ts) and the host dispatch layer (hosts/dispatch.ts, which
13
+ * arms the chain-hop guard for remote `run auto`) must agree on it.
14
+ */
15
+ export const RUN_AUTO_KEYWORD = 'auto';
16
+ /**
17
+ * Env var a host dispatcher exports into the remote SHELL when it dispatches
18
+ * `agents run auto`: tells the remote CLI its host layer is already resolved,
19
+ * so it must not re-run affinity and chain-hop to a third host. It rides the
20
+ * shell-export prelude (hosts/dispatch.ts `remoteRunShellPrelude`) because
21
+ * `--env` flags only reach the spawned AGENT's env — the remote CLI's own
22
+ * process.env (which exec.ts `runAutoDefaultsToAffinity` reads) never sees them.
23
+ */
24
+ export const RUN_AUTO_HOST_RESOLVED_ENV = 'AGENTS_RUN_AUTO_HOST_RESOLVED';
8
25
  /** Every canonical mode in declaration order. Useful for iteration / validation. */
9
26
  export const ALL_MODES = ['plan', 'edit', 'auto', 'skip'];
10
27
  /** Canonical system repo cloned into ~/.agents/.system/. */
@@ -1267,34 +1267,23 @@ export function isVersionIsolated(agent, version) {
1267
1267
  return fs.existsSync(getIsolatedMarkerPath(agent, version));
1268
1268
  }
1269
1269
  /**
1270
- * Grok's official installer writes into ~/.grok/downloads, which (because we
1271
- * symlink ~/.grok to the active version home) resolves to the PREVIOUS default
1272
- * home during `agents add grok@<new>`. Move the freshly-downloaded binary and
1273
- * its generic platform copy into the target version's isolated home so
1274
- * `listInstalledVersions` and the shim resolve the right binary.
1270
+ * Move any `grok-<installedVersion>-...` binary (and its generic platform
1271
+ * copy) found directly under `sourceDownloads` into `targetDownloads`.
1272
+ * Returns true if the versioned binary was moved.
1275
1273
  */
1276
- function relocateGrokBinaryToVersionHome(installedVersion) {
1277
- const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
1278
- let sourceDownloads;
1279
- try {
1280
- sourceDownloads = path.join(fs.readlinkSync(hostGrokLink), 'downloads');
1281
- }
1282
- catch {
1283
- sourceDownloads = path.join(hostGrokLink, 'downloads');
1284
- }
1285
- const targetDownloads = path.join(getVersionHomePath('grok', installedVersion), agentConfigDirName('grok'), 'downloads');
1274
+ function tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion) {
1286
1275
  if (!fs.existsSync(sourceDownloads))
1287
- return;
1276
+ return false;
1288
1277
  if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
1289
- return;
1278
+ return false;
1290
1279
  const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
1291
1280
  if (entries.length === 0)
1292
- return;
1293
- fs.mkdirSync(targetDownloads, { recursive: true });
1281
+ return false;
1294
1282
  const escapedVersion = installedVersion.replace(/\./g, '\\.');
1295
1283
  const versionedPattern = new RegExp(`^grok-${escapedVersion}-`);
1296
1284
  const movedPaths = [];
1297
- // Move the versioned binary first.
1285
+ // Move the versioned binary first — only create the target dir once we
1286
+ // know there is something to move into it.
1298
1287
  for (const entry of entries) {
1299
1288
  if (!versionedPattern.test(entry))
1300
1289
  continue;
@@ -1303,6 +1292,7 @@ function relocateGrokBinaryToVersionHome(installedVersion) {
1303
1292
  if (fs.existsSync(dst))
1304
1293
  continue;
1305
1294
  try {
1295
+ fs.mkdirSync(targetDownloads, { recursive: true });
1306
1296
  fs.renameSync(src, dst);
1307
1297
  movedPaths.push(dst);
1308
1298
  }
@@ -1311,7 +1301,7 @@ function relocateGrokBinaryToVersionHome(installedVersion) {
1311
1301
  }
1312
1302
  }
1313
1303
  if (movedPaths.length === 0)
1314
- return;
1304
+ return false;
1315
1305
  // The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
1316
1306
  // that is a copy of the versioned binary. Move it too if its size matches.
1317
1307
  const movedSize = fs.statSync(movedPaths[0]).size;
@@ -1331,6 +1321,42 @@ function relocateGrokBinaryToVersionHome(installedVersion) {
1331
1321
  /* ignore per-file failures */
1332
1322
  }
1333
1323
  }
1324
+ return true;
1325
+ }
1326
+ /**
1327
+ * Grok's official installer writes into ~/.grok/downloads, which (because we
1328
+ * symlink ~/.grok to the active version home) resolves to the PREVIOUS default
1329
+ * home during `agents add grok@<new>`. Move the freshly-downloaded binary and
1330
+ * its generic platform copy into the target version's isolated home so
1331
+ * `listInstalledVersions` and the shim resolve the right binary.
1332
+ *
1333
+ * Self-healing: if `~/.grok`'s current target has nothing matching (e.g. a
1334
+ * PAST install mislabeled `installedVersion` as the literal string 'latest',
1335
+ * whose regex never matched the real filename, stranding the binary in
1336
+ * whatever version was default at the time), sweep every other installed
1337
+ * grok version home for the missing file before giving up.
1338
+ */
1339
+ function relocateGrokBinaryToVersionHome(installedVersion) {
1340
+ const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
1341
+ let sourceDownloads;
1342
+ try {
1343
+ sourceDownloads = path.join(fs.readlinkSync(hostGrokLink), 'downloads');
1344
+ }
1345
+ catch {
1346
+ sourceDownloads = path.join(hostGrokLink, 'downloads');
1347
+ }
1348
+ const targetDownloads = path.join(getVersionHomePath('grok', installedVersion), agentConfigDirName('grok'), 'downloads');
1349
+ if (tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion))
1350
+ return;
1351
+ for (const version of listInstalledVersions('grok')) {
1352
+ if (version === installedVersion)
1353
+ continue;
1354
+ const candidate = path.join(getVersionHomePath('grok', version), agentConfigDirName('grok'), 'downloads');
1355
+ if (path.resolve(candidate) === path.resolve(sourceDownloads))
1356
+ continue; // already tried
1357
+ if (tryMoveGrokDownloads(candidate, targetDownloads, installedVersion))
1358
+ return;
1359
+ }
1334
1360
  }
1335
1361
  /**
1336
1362
  * Install a specific version of an agent.
@@ -1376,7 +1402,28 @@ export async function installVersion(agent, version, onProgress, opts) {
1376
1402
  await execAsync(script, { timeout: 120000 });
1377
1403
  }
1378
1404
  if (version === 'latest') {
1379
- installedVersion = await getCliVersionFromPath(agent) || version;
1405
+ // A freshly-installed self-updating binary (grok, …) can take a
1406
+ // moment to become resolvable on PATH right after the installer
1407
+ // exits — retry briefly rather than silently falling back to the
1408
+ // literal string 'latest'. That fallback used to corrupt version
1409
+ // bookkeeping downstream: it creates a bogus `versions/<agent>/latest/`
1410
+ // dir, and for grok specifically defeats relocateGrokBinaryToVersionHome
1411
+ // (its exact-filename regex can never match `grok-latest-...`), which
1412
+ // permanently strands the real downloaded binary in the PREVIOUS
1413
+ // default's downloads dir.
1414
+ let probed = await getCliVersionFromPath(agent);
1415
+ for (let attempt = 0; !probed && attempt < 2; attempt++) {
1416
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1417
+ probed = await getCliVersionFromPath(agent);
1418
+ }
1419
+ if (!probed) {
1420
+ return {
1421
+ success: false,
1422
+ installedVersion: version,
1423
+ error: `${agentConfig.name} installed but its version could not be determined after several attempts.`,
1424
+ };
1425
+ }
1426
+ installedVersion = probed;
1380
1427
  // Fold any stale literal `latest` dir from an earlier probe-failed
1381
1428
  // install into the real version so it stops shadowing `agents view`.
1382
1429
  await reconcileStaleLatestDir(agent, installedVersion);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.21.3",
3
+ "version": "1.22.1",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -89,6 +89,7 @@
89
89
  "@types/proper-lockfile": "4.1.4",
90
90
  "@xterm/headless": "6.0.0",
91
91
  "@zed-industries/agent-client-protocol": "0.4.5",
92
+ "acorn": "8.18.0",
92
93
  "age-encryption": "0.3.0",
93
94
  "chalk": "5.6.2",
94
95
  "commander": "15.0.0",
@@ -101,6 +102,7 @@
101
102
  "shlex": "3.0.0",
102
103
  "simple-git": "3.36.0",
103
104
  "smol-toml": "1.7.0",
105
+ "unbash": "4.0.5",
104
106
  "ws": "^8.21.0",
105
107
  "yaml": "2.9.0"
106
108
  },