@phnx-labs/agents-cli 1.19.2 → 1.20.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 (103) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +69 -9
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/browser.js +88 -16
  5. package/dist/commands/cli.d.ts +14 -0
  6. package/dist/commands/cli.js +244 -0
  7. package/dist/commands/commands.js +3 -3
  8. package/dist/commands/computer.js +18 -1
  9. package/dist/commands/doctor.d.ts +1 -1
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/commands/exec.js +3 -3
  12. package/dist/commands/factory.d.ts +3 -14
  13. package/dist/commands/factory.js +3 -3
  14. package/dist/commands/hooks.js +3 -3
  15. package/dist/commands/plugins.js +11 -4
  16. package/dist/commands/profiles.js +1 -1
  17. package/dist/commands/prune.js +39 -160
  18. package/dist/commands/pull.js +56 -3
  19. package/dist/commands/routines.js +106 -13
  20. package/dist/commands/secrets.js +5 -7
  21. package/dist/commands/sessions.d.ts +28 -0
  22. package/dist/commands/sessions.js +98 -33
  23. package/dist/commands/setup.d.ts +1 -0
  24. package/dist/commands/setup.js +37 -28
  25. package/dist/commands/skills.js +3 -3
  26. package/dist/commands/teams.js +13 -0
  27. package/dist/commands/versions.d.ts +4 -3
  28. package/dist/commands/versions.js +131 -127
  29. package/dist/commands/view.js +12 -12
  30. package/dist/computer.js +0 -0
  31. package/dist/index.js +34 -6
  32. package/dist/lib/acp/harnesses.js +8 -0
  33. package/dist/lib/agents.js +110 -23
  34. package/dist/lib/browser/cdp.d.ts +8 -1
  35. package/dist/lib/browser/cdp.js +40 -3
  36. package/dist/lib/browser/chrome.d.ts +13 -0
  37. package/dist/lib/browser/chrome.js +42 -3
  38. package/dist/lib/browser/domain-skills.d.ts +51 -0
  39. package/dist/lib/browser/domain-skills.js +157 -0
  40. package/dist/lib/browser/drivers/local.js +45 -4
  41. package/dist/lib/browser/drivers/ssh.js +1 -1
  42. package/dist/lib/browser/ipc.d.ts +8 -1
  43. package/dist/lib/browser/ipc.js +37 -28
  44. package/dist/lib/browser/profiles.d.ts +13 -0
  45. package/dist/lib/browser/profiles.js +41 -1
  46. package/dist/lib/browser/service.d.ts +3 -0
  47. package/dist/lib/browser/service.js +21 -5
  48. package/dist/lib/browser/types.d.ts +7 -0
  49. package/dist/lib/cli-resources.d.ts +109 -0
  50. package/dist/lib/cli-resources.js +255 -0
  51. package/dist/lib/cloud/rush.js +5 -5
  52. package/dist/lib/command-skills.js +0 -2
  53. package/dist/lib/computer-rpc.d.ts +3 -0
  54. package/dist/lib/computer-rpc.js +53 -0
  55. package/dist/lib/daemon.js +20 -0
  56. package/dist/lib/exec.d.ts +3 -2
  57. package/dist/lib/exec.js +44 -9
  58. package/dist/lib/hooks.js +182 -0
  59. package/dist/lib/mcp.js +6 -0
  60. package/dist/lib/migrate.js +1 -1
  61. package/dist/lib/overdue.d.ts +26 -0
  62. package/dist/lib/overdue.js +101 -0
  63. package/dist/lib/permissions.js +5 -1
  64. package/dist/lib/plugin-marketplace.js +1 -1
  65. package/dist/lib/profiles-presets.js +37 -0
  66. package/dist/lib/resources/mcp.js +37 -0
  67. package/dist/lib/resources.d.ts +1 -1
  68. package/dist/lib/rotate.js +10 -4
  69. package/dist/lib/routines-format.d.ts +35 -0
  70. package/dist/lib/routines-format.js +173 -0
  71. package/dist/lib/routines.d.ts +7 -1
  72. package/dist/lib/routines.js +32 -12
  73. package/dist/lib/runner.js +19 -5
  74. package/dist/lib/scheduler.js +8 -1
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/bundles.d.ts +22 -1
  78. package/dist/lib/secrets/bundles.js +234 -36
  79. package/dist/lib/secrets/index.d.ts +6 -11
  80. package/dist/lib/secrets/index.js +107 -87
  81. package/dist/lib/session/active.d.ts +8 -0
  82. package/dist/lib/session/active.js +3 -2
  83. package/dist/lib/session/db.d.ts +0 -4
  84. package/dist/lib/session/db.js +0 -26
  85. package/dist/lib/session/parse.d.ts +1 -0
  86. package/dist/lib/session/parse.js +44 -0
  87. package/dist/lib/session/types.d.ts +1 -1
  88. package/dist/lib/session/types.js +1 -1
  89. package/dist/lib/shims.d.ts +1 -1
  90. package/dist/lib/shims.js +66 -4
  91. package/dist/lib/state.d.ts +0 -1
  92. package/dist/lib/state.js +2 -15
  93. package/dist/lib/teams/agents.js +1 -1
  94. package/dist/lib/teams/parsers.d.ts +1 -1
  95. package/dist/lib/teams/parsers.js +153 -3
  96. package/dist/lib/teams/summarizer.js +18 -2
  97. package/dist/lib/teams/worktree.js +14 -3
  98. package/dist/lib/types.d.ts +6 -3
  99. package/dist/lib/types.js +6 -3
  100. package/dist/lib/versions.d.ts +10 -2
  101. package/dist/lib/versions.js +227 -35
  102. package/package.json +7 -7
  103. package/npm-shrinkwrap.json +0 -3162
@@ -0,0 +1,255 @@
1
+ /**
2
+ * CLI tool resources — declarative manifests for command-line binaries the user
3
+ * wants installed on the host (e.g. higgsfield, gh, glab).
4
+ *
5
+ * A CLI resource is a YAML file under <repo>/cli/<name>.yaml. Resolution follows
6
+ * the same project > user > system > extra-repo precedence as other resources,
7
+ * but unlike skills/commands/hooks, CLI resources are NOT copied into per-agent
8
+ * version homes — they install binaries onto the host PATH. The relationship is
9
+ * "Brewfile-style": declare once in ~/.agents/cli/, install on any new machine.
10
+ */
11
+ import * as fs from 'fs';
12
+ import { execSync, spawnSync } from 'child_process';
13
+ import * as yaml from 'yaml';
14
+ import { listResources, resolveResource } from './resources.js';
15
+ // ─── Parsing ─────────────────────────────────────────────────────────────────
16
+ /**
17
+ * Parse a single CLI manifest from its YAML contents.
18
+ * Returns a manifest on success; throws on schema violations so callers can
19
+ * decide whether to surface or swallow the error per file.
20
+ */
21
+ export function parseCliManifest(contents, opts) {
22
+ const raw = yaml.parse(contents);
23
+ if (!raw || typeof raw !== 'object') {
24
+ throw new Error('manifest must be a YAML object');
25
+ }
26
+ const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : opts.name;
27
+ const description = typeof raw.description === 'string' ? raw.description : undefined;
28
+ const homepage = typeof raw.homepage === 'string' ? raw.homepage : undefined;
29
+ const check = typeof raw.check === 'string' && raw.check.trim()
30
+ ? raw.check.trim()
31
+ : `${name} --version`;
32
+ const postInstall = typeof raw.post_install === 'string' ? raw.post_install : undefined;
33
+ if (!Array.isArray(raw.install) || raw.install.length === 0) {
34
+ throw new Error('install must be a non-empty list of methods');
35
+ }
36
+ const install = raw.install.map((entry, i) => {
37
+ if (!entry || typeof entry !== 'object') {
38
+ throw new Error(`install[${i}] must be an object with one of: npm, brew, script, binary`);
39
+ }
40
+ const e = entry;
41
+ const keys = Object.keys(e).filter((k) => e[k] !== undefined && e[k] !== null);
42
+ if (keys.length !== 1) {
43
+ throw new Error(`install[${i}] must declare exactly one method (got: ${keys.join(', ') || 'none'})`);
44
+ }
45
+ const key = keys[0];
46
+ const value = e[key];
47
+ if (key === 'npm' || key === 'brew' || key === 'script') {
48
+ if (typeof value !== 'string' || !value.trim()) {
49
+ throw new Error(`install[${i}].${key} must be a non-empty string`);
50
+ }
51
+ return { [key]: value.trim() };
52
+ }
53
+ if (key === 'binary') {
54
+ if (!value || typeof value !== 'object') {
55
+ throw new Error(`install[${i}].binary must be a platform map`);
56
+ }
57
+ const binary = {};
58
+ for (const [platform, spec] of Object.entries(value)) {
59
+ if (!spec || typeof spec !== 'object') {
60
+ throw new Error(`install[${i}].binary.${platform} must be an object with a url`);
61
+ }
62
+ const s = spec;
63
+ if (typeof s.url !== 'string' || !s.url.trim()) {
64
+ throw new Error(`install[${i}].binary.${platform}.url must be a non-empty string`);
65
+ }
66
+ binary[platform] = {
67
+ url: s.url.trim(),
68
+ extract: typeof s.extract === 'string' ? s.extract : undefined,
69
+ };
70
+ }
71
+ return { binary };
72
+ }
73
+ throw new Error(`install[${i}] has unknown method "${key}" (expected: npm, brew, script, binary)`);
74
+ });
75
+ return {
76
+ name,
77
+ description,
78
+ homepage,
79
+ check,
80
+ install,
81
+ postInstall,
82
+ source: opts.source,
83
+ path: opts.path,
84
+ };
85
+ }
86
+ /**
87
+ * Discover all CLI manifests resolvable from the current cwd. Returns valid
88
+ * manifests and any parse errors separately so the CLI can show both.
89
+ */
90
+ export function listCliManifests(cwd) {
91
+ const resolved = listResources('cli', cwd);
92
+ const manifests = [];
93
+ const errors = [];
94
+ for (const entry of resolved) {
95
+ if (!entry.path.endsWith('.yaml') && !entry.path.endsWith('.yml'))
96
+ continue;
97
+ try {
98
+ const contents = fs.readFileSync(entry.path, 'utf-8');
99
+ const manifest = parseCliManifest(contents, {
100
+ name: entry.name,
101
+ source: entry.source,
102
+ path: entry.path,
103
+ });
104
+ manifests.push(manifest);
105
+ }
106
+ catch (err) {
107
+ errors.push({ file: entry.path, reason: err.message });
108
+ }
109
+ }
110
+ return { manifests, errors };
111
+ }
112
+ /** Resolve a single CLI manifest by name. Returns null when not declared. */
113
+ export function resolveCliManifest(name, cwd) {
114
+ const resolved = resolveResource('cli', name, cwd);
115
+ if (!resolved)
116
+ return null;
117
+ if (!resolved.path.endsWith('.yaml') && !resolved.path.endsWith('.yml'))
118
+ return null;
119
+ const contents = fs.readFileSync(resolved.path, 'utf-8');
120
+ return parseCliManifest(contents, {
121
+ name: resolved.name,
122
+ source: resolved.source,
123
+ path: resolved.path,
124
+ });
125
+ }
126
+ // ─── Host detection ──────────────────────────────────────────────────────────
127
+ /**
128
+ * Return true if a command resolves on the current PATH. Uses `which` on
129
+ * POSIX hosts; results are cached for the lifetime of the process.
130
+ */
131
+ const cmdExistsCache = new Map();
132
+ export function hasCommand(cmd) {
133
+ if (cmdExistsCache.has(cmd))
134
+ return cmdExistsCache.get(cmd);
135
+ const result = spawnSync('command', ['-v', cmd], { shell: true, stdio: 'ignore' });
136
+ const ok = result.status === 0;
137
+ cmdExistsCache.set(cmd, ok);
138
+ return ok;
139
+ }
140
+ /** Run the manifest's `check` command. Returns true when it exits 0. */
141
+ export function isCliInstalled(manifest) {
142
+ const result = spawnSync(manifest.check, {
143
+ shell: true,
144
+ stdio: 'ignore',
145
+ timeout: 10_000,
146
+ });
147
+ return result.status === 0;
148
+ }
149
+ // ─── Method selection ────────────────────────────────────────────────────────
150
+ /**
151
+ * Pick the first install method whose required host tool is available.
152
+ * Returns null when none of the declared methods can run on this host.
153
+ */
154
+ export function selectInstallMethod(manifest) {
155
+ for (const method of manifest.install) {
156
+ if ('npm' in method && hasCommand('npm'))
157
+ return method;
158
+ if ('brew' in method && hasCommand('brew'))
159
+ return method;
160
+ if ('script' in method && (hasCommand('curl') || hasCommand('wget')))
161
+ return method;
162
+ if ('binary' in method) {
163
+ const key = `${process.platform}-${process.arch}`;
164
+ if (method.binary[key])
165
+ return method;
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ /** Short description of a method for display. */
171
+ export function describeMethod(method) {
172
+ if ('npm' in method)
173
+ return `npm install -g ${method.npm}`;
174
+ if ('brew' in method)
175
+ return `brew install ${method.brew}`;
176
+ if ('script' in method)
177
+ return `curl ${method.script} | sh`;
178
+ const key = `${process.platform}-${process.arch}`;
179
+ const spec = method.binary[key];
180
+ return spec ? `download ${spec.url}` : 'binary download';
181
+ }
182
+ /**
183
+ * Install a single CLI by running its first compatible method. Streams the
184
+ * underlying command's output to the parent terminal so users see brew/npm
185
+ * progress live. Verifies success by re-running `check`.
186
+ */
187
+ export function installCli(manifest, opts = {}) {
188
+ const method = selectInstallMethod(manifest);
189
+ if (!method) {
190
+ return {
191
+ manifest,
192
+ method: null,
193
+ installed: false,
194
+ error: `No compatible install method for this host (${process.platform}-${process.arch}). Declared methods: ${manifest.install.map(describeMethod).join('; ')}`,
195
+ };
196
+ }
197
+ if (opts.dryRun) {
198
+ return { manifest, method, installed: false, output: `[dry-run] would run: ${describeMethod(method)}` };
199
+ }
200
+ const cmd = buildInstallCommand(method);
201
+ try {
202
+ execSync(cmd, { stdio: 'inherit' });
203
+ }
204
+ catch (err) {
205
+ return {
206
+ manifest,
207
+ method,
208
+ installed: false,
209
+ error: `install command failed: ${err.message}`,
210
+ };
211
+ }
212
+ // Re-check; many installers exit 0 but leave the binary off PATH for the
213
+ // current shell (e.g. brew on a fresh install). Trust `check`, not the
214
+ // installer's exit code.
215
+ cmdExistsCache.delete(manifest.name);
216
+ const installed = isCliInstalled(manifest);
217
+ return { manifest, method, installed };
218
+ }
219
+ /**
220
+ * Map a declarative method to a shell command. Centralized so tests and dry-run
221
+ * surface the exact string that would execute.
222
+ */
223
+ export function buildInstallCommand(method) {
224
+ if ('npm' in method)
225
+ return `npm install -g ${method.npm}`;
226
+ if ('brew' in method)
227
+ return `brew install ${method.brew}`;
228
+ if ('script' in method) {
229
+ // Prefer curl when both are present; fall back to wget.
230
+ return hasCommand('curl')
231
+ ? `curl -fsSL ${method.script} | sh`
232
+ : `wget -qO- ${method.script} | sh`;
233
+ }
234
+ const key = `${process.platform}-${process.arch}`;
235
+ const spec = method.binary[key];
236
+ // The downloader is intentionally minimal — binary install is mostly used
237
+ // for pre-built tarballs whose extract path varies per project. We expect
238
+ // the manifest author to document any post-download steps in post_install.
239
+ return spec.extract
240
+ ? `curl -fsSL ${spec.url} -o /tmp/agents-cli-bin.tgz && tar -xzf /tmp/agents-cli-bin.tgz -C /usr/local/bin ${spec.extract}`
241
+ : `curl -fsSL ${spec.url} -o /usr/local/bin/agents-cli-downloaded`;
242
+ }
243
+ /** Convenience: list all manifests + their installed-on-host status. */
244
+ export function listCliStatus(cwd) {
245
+ const { manifests, errors } = listCliManifests(cwd);
246
+ const statuses = manifests.map((manifest) => ({
247
+ manifest,
248
+ installed: isCliInstalled(manifest),
249
+ }));
250
+ return { statuses, errors };
251
+ }
252
+ /** Names of CLIs that are declared but not currently installed on the host. */
253
+ export function getMissingClis(cwd) {
254
+ return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
255
+ }
@@ -16,7 +16,7 @@ import { listInstalledVersions, getVersionHomePath } from '../versions.js';
16
16
  import { getAccountInfo } from '../agents.js';
17
17
  import { loadClaudeOauth } from '../usage.js';
18
18
  import { selectBalancedVersion } from '../rotate.js';
19
- const PROXY_BASE = 'https://api.prix.dev';
19
+ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
20
20
  const PROXY_HOST = new URL(PROXY_BASE).host;
21
21
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
22
22
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
@@ -441,7 +441,7 @@ export class RushCloudProvider {
441
441
  }
442
442
  async status(taskId) {
443
443
  const token = readToken();
444
- const res = await api('GET', `/api/v1/cloud-runs/${taskId}`, token);
444
+ const res = await api('GET', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}`, token);
445
445
  if (!res.ok) {
446
446
  throw new Error(`Failed to get task status (${res.status}).`);
447
447
  }
@@ -487,7 +487,7 @@ export class RushCloudProvider {
487
487
  }
488
488
  async *stream(taskId) {
489
489
  const token = readToken();
490
- const res = await fetch(`${PROXY_BASE}/api/v1/cloud-runs/${taskId}/stream`, {
490
+ const res = await fetch(`${PROXY_BASE}/api/v1/cloud-runs/${encodeURIComponent(taskId)}/stream`, {
491
491
  headers: { 'Authorization': `Bearer ${token}` },
492
492
  });
493
493
  if (!res.ok) {
@@ -497,14 +497,14 @@ export class RushCloudProvider {
497
497
  }
498
498
  async cancel(taskId) {
499
499
  const token = readToken();
500
- const res = await api('DELETE', `/api/v1/cloud-runs/${taskId}`, token);
500
+ const res = await api('DELETE', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}`, token);
501
501
  if (!res.ok) {
502
502
  throw new Error(`Failed to cancel task (${res.status}).`);
503
503
  }
504
504
  }
505
505
  async message(taskId, content) {
506
506
  const token = readToken();
507
- const res = await api('POST', `/api/v1/cloud-runs/${taskId}/message`, token, { content });
507
+ const res = await api('POST', `/api/v1/cloud-runs/${encodeURIComponent(taskId)}/message`, token, { content });
508
508
  if (!res.ok) {
509
509
  throw new Error(`Failed to send message (${res.status}).`);
510
510
  }
@@ -50,8 +50,6 @@ function readSkillCommandMarker(skillMdPath) {
50
50
  }
51
51
  }
52
52
  export function shouldInstallCommandAsSkill(agent, version) {
53
- if (agent !== 'codex')
54
- return false;
55
53
  return !supports(agent, 'commands', version).ok && supports(agent, 'skills', version).ok;
56
54
  }
57
55
  export function commandSkillName(commandName) {
@@ -15,6 +15,9 @@ export declare function resolveLogPath(): string;
15
15
  export declare function resolvePolicyPath(): string;
16
16
  export declare function loadComputerAllowList(): string[];
17
17
  export declare function writeComputerPolicy(allowedBundleIds: string[]): void;
18
+ export declare function resolvePeersPath(): string;
19
+ export declare function loadDefaultPeers(): string[];
20
+ export declare function writeComputerPeers(allowedExecPaths: string[]): void;
18
21
  export declare function resolveHelperExec(): string | null;
19
22
  export declare function resolveHelperApp(): string | null;
20
23
  export declare function openComputerClient(): ComputerClient;
@@ -110,6 +110,59 @@ export function writeComputerPolicy(allowedBundleIds) {
110
110
  const policy = { allow: allowedBundleIds };
111
111
  fs.writeFileSync(resolvePolicyPath(), JSON.stringify(policy, null, 2), { mode: 0o600 });
112
112
  }
113
+ // Peer-auth (F5): the helper reads a list of executable paths it will
114
+ // accept connections from. Anything else — `nc`, `/usr/bin/python3`, a
115
+ // random electron app — gets the socket closed before its first RPC.
116
+ // File mirrors computer-policy.json: JSON, mode 0600, missing/unparseable
117
+ // means deny-everything.
118
+ export function resolvePeersPath() {
119
+ return path.join(getHelpersDir(), 'computer-peers.json');
120
+ }
121
+ // Default peer set: this exact `agents` CLI binary plus Rush.app if it's
122
+ // installed. realpath() the symlink chain so we record the on-disk path
123
+ // the helper will see via proc_pidpath, not the shim path.
124
+ //
125
+ // Why path-based instead of codesign-team-id? The agents CLI is unsigned
126
+ // today (npm distribution), and even if we sign Rush.app the team-id
127
+ // check would need a separate roundtrip. Path is concrete and fast; the
128
+ // daemon already runs as the user so anyone who can swap a binary at
129
+ // these paths can do worse via other means.
130
+ export function loadDefaultPeers() {
131
+ const out = new Set();
132
+ const add = (p) => {
133
+ try {
134
+ out.add(fs.realpathSync(p));
135
+ }
136
+ catch {
137
+ out.add(p);
138
+ }
139
+ };
140
+ // The Node executable currently running the CLI. This is what
141
+ // proc_pidpath() will report when the CLI calls into the daemon.
142
+ if (process.execPath)
143
+ add(process.execPath);
144
+ // Rush.app — the consumer Electron client. Both the helper-binary and
145
+ // the main app binary are possible callers depending on how Rush wires
146
+ // the RPC client.
147
+ const rushCandidates = [
148
+ '/Applications/Rush.app/Contents/MacOS/Rush',
149
+ '/Applications/Rush.app/Contents/MacOS/Electron',
150
+ ];
151
+ for (const p of rushCandidates) {
152
+ if (fs.existsSync(p))
153
+ add(p);
154
+ }
155
+ return [...out].sort();
156
+ }
157
+ // Write the peer-auth allow list. Same mode 0600 + atomic-ish semantics
158
+ // as the policy file. The daemon picks it up at startup and on SIGHUP.
159
+ export function writeComputerPeers(allowedExecPaths) {
160
+ const dir = getHelpersDir();
161
+ if (!fs.existsSync(dir)) {
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ }
164
+ fs.writeFileSync(resolvePeersPath(), JSON.stringify({ allow: allowedExecPaths }, null, 2), { mode: 0o600 });
165
+ }
113
166
  // Resolve the helper executable inside the dist .app bundle. Used by the
114
167
  // stdio fallback and by install-helper to find the source bundle.
115
168
  export function resolveHelperExec() {
@@ -14,6 +14,7 @@ import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { listJobs as listAllJobs } from './routines.js';
15
15
  import { JobScheduler } from './scheduler.js';
16
16
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
17
+ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
17
18
  import { BrowserService } from './browser/service.js';
18
19
  import { BrowserIPCServer } from './browser/ipc.js';
19
20
  const PID_FILE = 'daemon.pid';
@@ -178,6 +179,25 @@ export async function runDaemon() {
178
179
  for (const job of scheduled) {
179
180
  log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
180
181
  }
182
+ // Backlog detection: any enabled recurring job whose most-recent expected
183
+ // fire is older than its most-recent recorded run is overdue. Happens when
184
+ // the laptop was off or the daemon crashed through a scheduled fire.
185
+ // We log it and pop a native notification — the user can review with
186
+ // `agents routines list` and run them with `agents routines catchup`.
187
+ try {
188
+ const overdue = detectOverdueJobs();
189
+ if (overdue.length > 0) {
190
+ log('WARN', `${overdue.length} routine(s) overdue:`);
191
+ for (const job of overdue) {
192
+ const last = job.lastRanAt ? job.lastRanAt.toISOString() : 'never';
193
+ log('WARN', ` ${job.name} -- expected ${job.expectedAt.toISOString()}, last ran ${last}`);
194
+ }
195
+ notifyOverdue(overdue);
196
+ }
197
+ }
198
+ catch (err) {
199
+ log('ERROR', `Overdue detection failed: ${err.message}`);
200
+ }
181
201
  // Before the BrowserService comes up, reap browser + tunnel processes
182
202
  // spawned by previous daemons that are no longer alive. Without this,
183
203
  // a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
@@ -27,8 +27,9 @@ export interface ExecOptions {
27
27
  export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
28
28
  /**
29
29
  * Build the process environment for an agent invocation.
30
- * Pins CLAUDE_CONFIG_DIR for Claude and CODEX_HOME for Codex; strips the
31
- * other agent's env var so it doesn't leak into unrelated invocations.
30
+ * Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
31
+ * for GitHub Copilot; strips the other agents' env vars so they don't leak
32
+ * into unrelated invocations.
32
33
  */
33
34
  export declare function buildExecEnv(options: ExecOptions): NodeJS.ProcessEnv;
34
35
  /** Describes how to translate ExecOptions into CLI arguments for a specific agent. */
package/dist/lib/exec.js CHANGED
@@ -35,8 +35,9 @@ export function parseExecEnv(entries) {
35
35
  }
36
36
  /**
37
37
  * Build the process environment for an agent invocation.
38
- * Pins CLAUDE_CONFIG_DIR for Claude and CODEX_HOME for Codex; strips the
39
- * other agent's env var so it doesn't leak into unrelated invocations.
38
+ * Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
39
+ * for GitHub Copilot; strips the other agents' env vars so they don't leak
40
+ * into unrelated invocations.
40
41
  */
41
42
  export function buildExecEnv(options) {
42
43
  const result = { ...process.env };
@@ -56,6 +57,7 @@ export function buildExecEnv(options) {
56
57
  result.CLAUDE_CONFIG_DIR = path.join(getVersionHomePath('claude', version), '.claude');
57
58
  }
58
59
  delete result.CODEX_HOME;
60
+ delete result.COPILOT_HOME;
59
61
  }
60
62
  else if (options.agent === 'codex') {
61
63
  const cwd = options.cwd || process.cwd();
@@ -67,10 +69,27 @@ export function buildExecEnv(options) {
67
69
  result.CODEX_HOME = path.join(getVersionHomePath('codex', version), '.codex');
68
70
  }
69
71
  delete result.CLAUDE_CONFIG_DIR;
72
+ delete result.COPILOT_HOME;
73
+ }
74
+ else if (options.agent === 'copilot') {
75
+ // Copilot honors COPILOT_HOME (relocates ~/.copilot, including settings,
76
+ // mcp-config.json, sessions, logs). Pin it at the per-version home so
77
+ // version switches isolate MCP servers, auth, and session history.
78
+ const cwd = options.cwd || process.cwd();
79
+ const resolvedVersion = options.version ?? resolveVersion('copilot', cwd);
80
+ const version = options.version
81
+ ? resolvedVersion
82
+ : (resolvedVersion && isVersionInstalled('copilot', resolvedVersion) ? resolvedVersion : null);
83
+ if (version) {
84
+ result.COPILOT_HOME = path.join(getVersionHomePath('copilot', version), '.copilot');
85
+ }
86
+ delete result.CLAUDE_CONFIG_DIR;
87
+ delete result.CODEX_HOME;
70
88
  }
71
89
  else {
72
90
  delete result.CLAUDE_CONFIG_DIR;
73
91
  delete result.CODEX_HOME;
92
+ delete result.COPILOT_HOME;
74
93
  }
75
94
  return {
76
95
  ...result,
@@ -148,14 +167,25 @@ export const AGENT_COMMANDS = {
148
167
  jsonFlags: ['--output-format', 'stream-json'],
149
168
  modelFlag: '--model',
150
169
  },
170
+ // GitHub Copilot CLI (`@github/copilot`, GA 2026-02-25). Flags verified
171
+ // against `copilot --help` from v0.0.413+:
172
+ // -p, --prompt <text> non-interactive one-shot
173
+ // --mode <interactive|plan|autopilot>
174
+ // --allow-all-tools required for non-interactive tool exec
175
+ // --allow-all (alias --yolo) tools + paths + URLs
176
+ // --output-format <text|json> json => JSONL, one object per line
177
+ // --model <model>
178
+ // Plan mode is read-only so it does not need an allow-tools grant; edit/full
179
+ // need at minimum --allow-all-tools so headless runs don't stall on prompts.
151
180
  copilot: {
152
181
  base: ['copilot'],
153
- promptFlag: 'positional',
182
+ promptFlag: '-p',
154
183
  modeFlags: {
155
- plan: [],
156
- edit: [],
157
- full: [],
184
+ plan: ['--mode', 'plan'],
185
+ edit: ['--allow-all-tools'],
186
+ full: ['--allow-all'],
158
187
  },
188
+ jsonFlags: ['--output-format', 'json'],
159
189
  modelFlag: '--model',
160
190
  },
161
191
  amp: {
@@ -197,13 +227,18 @@ export const AGENT_COMMANDS = {
197
227
  },
198
228
  modelFlag: '--model',
199
229
  },
230
+ // Antigravity full mode uses --dangerously-skip-permissions (YOLO).
231
+ // TODO: --output-format json is documented but currently broken upstream
232
+ // ("flags provided but not defined: -output-format"). Track resolution at
233
+ // https://github.com/google-antigravity/antigravity-cli/issues/7 before
234
+ // adding `jsonFlags` here.
200
235
  antigravity: {
201
236
  base: ['agy'],
202
237
  promptFlag: 'positional',
203
238
  modeFlags: {
204
239
  plan: [],
205
240
  edit: [],
206
- full: [],
241
+ full: ['--dangerously-skip-permissions'],
207
242
  },
208
243
  modelFlag: '--model',
209
244
  },
@@ -211,9 +246,9 @@ export const AGENT_COMMANDS = {
211
246
  base: ['grok'],
212
247
  promptFlag: '-p',
213
248
  modeFlags: {
214
- plan: [],
249
+ plan: ['--mode', 'plan'],
215
250
  edit: [],
216
- full: [],
251
+ full: ['--always-approve'],
217
252
  },
218
253
  jsonFlags: ['--output-format', 'streaming-json'],
219
254
  modelFlag: '--model',