@phnx-labs/agents-cli 1.22.24 → 1.22.25

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 (85) hide show
  1. package/CHANGELOG.md +167 -1
  2. package/README.md +14 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.js +9 -5
  5. package/dist/commands/doctor.d.ts +24 -0
  6. package/dist/commands/doctor.js +100 -9
  7. package/dist/commands/exec.js +19 -16
  8. package/dist/commands/feed.d.ts +5 -0
  9. package/dist/commands/feed.js +21 -1
  10. package/dist/commands/focus.js +2 -2
  11. package/dist/commands/menubar.js +8 -0
  12. package/dist/commands/routines.d.ts +3 -0
  13. package/dist/commands/routines.js +70 -47
  14. package/dist/commands/run-cloud.js +1 -1
  15. package/dist/commands/sessions-browser.d.ts +1 -1
  16. package/dist/commands/sessions-browser.js +27 -7
  17. package/dist/commands/sessions-resume.js +3 -2
  18. package/dist/commands/sessions.d.ts +13 -1
  19. package/dist/commands/sessions.js +24 -2
  20. package/dist/commands/setup-watchdog.js +5 -10
  21. package/dist/commands/setup.js +1 -1
  22. package/dist/commands/teams.js +3 -2
  23. package/dist/commands/watchdog.d.ts +3 -4
  24. package/dist/commands/watchdog.js +26 -66
  25. package/dist/index.js +36 -1
  26. package/dist/lib/agents.js +126 -21
  27. package/dist/lib/cloud/cursor.d.ts +79 -0
  28. package/dist/lib/cloud/cursor.js +228 -0
  29. package/dist/lib/cloud/registry.js +2 -0
  30. package/dist/lib/cloud/types.d.ts +7 -2
  31. package/dist/lib/cloud/types.js +14 -0
  32. package/dist/lib/crabbox/cli.d.ts +2 -2
  33. package/dist/lib/crabbox/config.d.ts +7 -8
  34. package/dist/lib/crabbox/config.js +14 -14
  35. package/dist/lib/crabbox/lease.d.ts +11 -4
  36. package/dist/lib/crabbox/lease.js +40 -8
  37. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  38. package/dist/lib/crabbox/setup-copy.js +17 -1
  39. package/dist/lib/daemon.js +27 -1
  40. package/dist/lib/device-config.js +7 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +40 -1
  43. package/dist/lib/events.d.ts +9 -0
  44. package/dist/lib/events.js +58 -0
  45. package/dist/lib/exec.d.ts +3 -3
  46. package/dist/lib/exec.js +24 -10
  47. package/dist/lib/feed-outcome.d.ts +3 -0
  48. package/dist/lib/feed-outcome.js +18 -10
  49. package/dist/lib/feed.d.ts +4 -0
  50. package/dist/lib/hosts/passthrough.d.ts +21 -0
  51. package/dist/lib/hosts/passthrough.js +39 -12
  52. package/dist/lib/mcp.js +5 -1
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/menubar/snapshot.d.ts +15 -0
  56. package/dist/lib/menubar/snapshot.js +40 -0
  57. package/dist/lib/plugins.js +13 -1
  58. package/dist/lib/resources/mcp.js +3 -0
  59. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  60. package/dist/lib/routine-process-cleanup.js +73 -0
  61. package/dist/lib/runner.js +5 -5
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +1 -20
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +13 -0
  67. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  68. package/dist/lib/secrets/rc-hygiene.js +14 -1
  69. package/dist/lib/session/active.d.ts +4 -0
  70. package/dist/lib/session/db.js +8 -2
  71. package/dist/lib/session/remote-list.d.ts +2 -0
  72. package/dist/lib/session/remote-list.js +1 -0
  73. package/dist/lib/session/session-cache.d.ts +4 -4
  74. package/dist/lib/session/session-cache.js +4 -4
  75. package/dist/lib/shims.js +21 -1
  76. package/dist/lib/signin-badge.js +2 -0
  77. package/dist/lib/startup/command-registry.d.ts +15 -0
  78. package/dist/lib/startup/command-registry.js +42 -0
  79. package/dist/lib/teams/agents.js +1 -1
  80. package/dist/lib/teams/parsers.d.ts +1 -1
  81. package/dist/lib/types.d.ts +1 -1
  82. package/dist/lib/versions.js +16 -1
  83. package/dist/lib/watchdog/service.d.ts +17 -0
  84. package/dist/lib/watchdog/service.js +39 -0
  85. package/package.json +1 -1
@@ -0,0 +1,228 @@
1
+ /** Cursor Cloud Agents provider backed by the public v1 REST API. */
2
+ import { normalizeProviderStatus, resolveDispatchRepos } from './types.js';
3
+ import { readAndResolveBundleEnv } from '../secrets/bundles.js';
4
+ export const CURSOR_API_BASE_URL = 'https://api.cursor.com/v1';
5
+ const KEY_NAME = 'CURSOR_API_KEY';
6
+ /** Translate unified dispatch options into Cursor's documented v1 create shape. */
7
+ export function buildCursorCreateBody(options) {
8
+ const repos = resolveDispatchRepos(options);
9
+ const body = { prompt: { text: options.prompt } };
10
+ if (options.images?.length) {
11
+ body.prompt.images = options.images.map(({ data, mimeType }) => ({ data, mimeType }));
12
+ }
13
+ if (options.model)
14
+ body.model = { id: options.model };
15
+ if (repos.length) {
16
+ body.repos = repos.map((url) => ({ url, ...(options.branch ? { startingRef: options.branch } : {}) }));
17
+ body.autoCreatePR = true;
18
+ }
19
+ if (options.env && Object.keys(options.env).length)
20
+ body.envVars = options.env;
21
+ return body;
22
+ }
23
+ /** Map one Cursor agent/run pair into the provider-neutral task shape. */
24
+ export function parseCursorTask(agent, run, prompt = '') {
25
+ const branch = run.git?.branches?.[0];
26
+ return {
27
+ id: agent.id,
28
+ provider: 'cursor',
29
+ status: normalizeProviderStatus('cursor', run.status),
30
+ agent: 'cursor',
31
+ prompt: prompt || agent.name || '',
32
+ repo: agent.repos?.[0]?.url,
33
+ repos: agent.repos?.map((repo) => repo.url),
34
+ branch: branch?.branch,
35
+ prUrl: branch?.prUrl,
36
+ createdAt: agent.createdAt,
37
+ updatedAt: run.updatedAt,
38
+ summary: run.result,
39
+ };
40
+ }
41
+ /** Parse one complete SSE frame into a shared cloud event. */
42
+ export function parseCursorSseFrame(frame) {
43
+ let eventName = 'message';
44
+ const data = [];
45
+ for (const line of frame.split(/\r?\n/)) {
46
+ if (line.startsWith('event:'))
47
+ eventName = line.slice(6).trim();
48
+ else if (line.startsWith('data:'))
49
+ data.push(line.slice(5).trimStart());
50
+ }
51
+ if (!data.length)
52
+ return undefined;
53
+ const raw = data.join('\n');
54
+ let value = raw;
55
+ try {
56
+ value = JSON.parse(raw);
57
+ }
58
+ catch { /* Cursor may send plain text. */ }
59
+ const obj = typeof value === 'object' && value !== null ? value : {};
60
+ const timestamp = typeof obj.timestamp === 'string' ? obj.timestamp : undefined;
61
+ if (eventName === 'status') {
62
+ const status = String(obj.status ?? obj.data ?? raw);
63
+ return { type: 'status', status: normalizeProviderStatus('cursor', status), timestamp };
64
+ }
65
+ if (eventName === 'assistant') {
66
+ return { type: 'text', content: String(obj.text ?? obj.content ?? obj.data ?? raw), timestamp };
67
+ }
68
+ if (eventName === 'thinking') {
69
+ return { type: 'thinking', content: String(obj.text ?? obj.content ?? obj.data ?? raw), timestamp };
70
+ }
71
+ if (eventName === 'tool_call') {
72
+ if (obj.status === 'completed') {
73
+ return { type: 'tool_result', tool: String(obj.name ?? 'tool'), output: obj.result ?? obj, timestamp };
74
+ }
75
+ return { type: 'tool_use', tool: String(obj.name ?? 'tool'), input: obj.args ?? obj, timestamp };
76
+ }
77
+ if (eventName === 'result' || eventName === 'done') {
78
+ const status = normalizeProviderStatus('cursor', String(obj.status ?? 'FINISHED'));
79
+ return { type: 'done', status, summary: typeof obj.text === 'string' ? obj.text : undefined, timestamp };
80
+ }
81
+ if (eventName === 'error') {
82
+ return { type: 'error', message: String(obj.message ?? raw), timestamp };
83
+ }
84
+ return { type: 'unknown', name: eventName, data: raw, timestamp };
85
+ }
86
+ /** Turn Cursor's structured API errors into actionable provider errors. */
87
+ export function cursorApiError(action, status, text) {
88
+ let parsed = {};
89
+ try {
90
+ parsed = JSON.parse(text);
91
+ }
92
+ catch { /* preserve raw body */ }
93
+ const detail = typeof parsed.error === 'object' ? parsed.error : undefined;
94
+ const code = detail?.code;
95
+ const message = detail?.message ?? (typeof parsed.error === 'string' ? parsed.error : text.slice(0, 500));
96
+ if (code === 'plan_required') {
97
+ return new Error(`Cursor Cloud Agents requires a paid Cursor plan; this API key belongs to a plan without Cloud Agents access. ${message}`);
98
+ }
99
+ if (status === 401 || code === 'unauthorized' || code === 'api_key_not_found') {
100
+ return new Error(`Cursor Cloud authentication failed (${status}${code ? ` ${code}` : ''}). Check CURSOR_API_KEY in the configured agents secrets bundle. ${message}`);
101
+ }
102
+ return new Error(`Cursor Cloud ${action} failed (${status}${code ? ` ${code}` : ''}): ${message}`);
103
+ }
104
+ export class CursorCloudProvider {
105
+ id = 'cursor';
106
+ name = 'Cursor Cloud Agents';
107
+ secretsBundle;
108
+ constructor(config) {
109
+ this.secretsBundle = config?.secretsBundle;
110
+ }
111
+ hasKeySource() {
112
+ return Boolean(this.secretsBundle || process.env[KEY_NAME]);
113
+ }
114
+ resolveApiKey() {
115
+ if (this.secretsBundle) {
116
+ try {
117
+ const { env } = readAndResolveBundleEnv(this.secretsBundle, { caller: 'cloud:cursor', agentOnly: true });
118
+ if (env[KEY_NAME])
119
+ return env[KEY_NAME];
120
+ throw new Error(`Secrets bundle '${this.secretsBundle}' has no ${KEY_NAME}. Add one: agents secrets add ${this.secretsBundle} ${KEY_NAME}`);
121
+ }
122
+ catch (err) {
123
+ throw new Error(`Could not read Cursor API key from bundle '${this.secretsBundle}': ${err.message}`);
124
+ }
125
+ }
126
+ if (process.env[KEY_NAME])
127
+ return process.env[KEY_NAME];
128
+ throw new Error(`Cursor cloud needs an API key. Set cloud.providers.cursor.secretsBundle in ~/.agents/agents.yaml after running: agents secrets add cursor ${KEY_NAME}`);
129
+ }
130
+ capabilities() {
131
+ const available = this.hasKeySource();
132
+ return {
133
+ available,
134
+ dispatch: available,
135
+ status: available,
136
+ list: available,
137
+ stream: available,
138
+ cancel: available,
139
+ message: available,
140
+ multiRepo: true,
141
+ skills: false,
142
+ images: true,
143
+ };
144
+ }
145
+ async request(path, init) {
146
+ const apiKey = this.resolveApiKey();
147
+ let response;
148
+ try {
149
+ response = await fetch(`${CURSOR_API_BASE_URL}${path}`, {
150
+ ...init,
151
+ headers: {
152
+ authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`,
153
+ ...(init?.body ? { 'content-type': 'application/json' } : {}),
154
+ ...init?.headers,
155
+ },
156
+ });
157
+ }
158
+ catch (err) {
159
+ throw new Error(`Cursor Cloud request failed (network): ${err.message}`);
160
+ }
161
+ if (!response.ok)
162
+ throw cursorApiError(init?.method ?? 'request', response.status, await response.text());
163
+ return response;
164
+ }
165
+ async latestRun(agent) {
166
+ if (!agent.latestRunId)
167
+ throw new Error(`Cursor Cloud agent ${agent.id} has no runs.`);
168
+ return this.request(`/agents/${encodeURIComponent(agent.id)}/runs/${encodeURIComponent(agent.latestRunId)}`)
169
+ .then((response) => response.json());
170
+ }
171
+ async dispatch(options) {
172
+ const response = await this.request('/agents', { method: 'POST', body: JSON.stringify(buildCursorCreateBody(options)) });
173
+ const payload = await response.json();
174
+ return parseCursorTask(payload.agent, payload.run, options.prompt);
175
+ }
176
+ async status(taskId) {
177
+ const agent = await this.request(`/agents/${encodeURIComponent(taskId)}`).then((response) => response.json());
178
+ return parseCursorTask(agent, await this.latestRun(agent));
179
+ }
180
+ async list(filter) {
181
+ const { items } = await this.request('/agents?limit=100').then((response) => response.json());
182
+ const tasks = await Promise.all(items.filter((agent) => agent.latestRunId).map(async (agent) => parseCursorTask(agent, await this.latestRun(agent))));
183
+ return filter?.status ? tasks.filter((task) => task.status === filter.status) : tasks;
184
+ }
185
+ async *stream(taskId) {
186
+ const agent = await this.request(`/agents/${encodeURIComponent(taskId)}`).then((response) => response.json());
187
+ if (!agent.latestRunId)
188
+ throw new Error(`Cursor Cloud agent ${taskId} has no run to stream.`);
189
+ const response = await this.request(`/agents/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(agent.latestRunId)}/stream`, {
190
+ headers: { accept: 'text/event-stream' },
191
+ });
192
+ if (!response.body)
193
+ throw new Error(`Cursor Cloud stream for ${taskId} returned no response body.`);
194
+ const reader = response.body.getReader();
195
+ const decoder = new TextDecoder();
196
+ let buffer = '';
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+ buffer += decoder.decode(value, { stream: !done });
200
+ const frames = buffer.split(/\r?\n\r?\n/);
201
+ buffer = frames.pop() ?? '';
202
+ for (const frame of frames) {
203
+ const event = parseCursorSseFrame(frame);
204
+ if (event)
205
+ yield event;
206
+ }
207
+ if (done)
208
+ break;
209
+ }
210
+ if (buffer.trim()) {
211
+ const event = parseCursorSseFrame(buffer);
212
+ if (event)
213
+ yield event;
214
+ }
215
+ }
216
+ async cancel(taskId) {
217
+ const agent = await this.request(`/agents/${encodeURIComponent(taskId)}`).then((response) => response.json());
218
+ if (!agent.latestRunId)
219
+ throw new Error(`Cursor Cloud agent ${taskId} has no run to cancel.`);
220
+ await this.request(`/agents/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(agent.latestRunId)}/cancel`, { method: 'POST' });
221
+ }
222
+ async message(taskId, content) {
223
+ await this.request(`/agents/${encodeURIComponent(taskId)}/runs`, {
224
+ method: 'POST',
225
+ body: JSON.stringify({ prompt: { text: content } }),
226
+ });
227
+ }
228
+ }
@@ -11,6 +11,7 @@ import { RushCloudProvider } from './rush.js';
11
11
  import { CodexCloudProvider } from './codex.js';
12
12
  import { FactoryCloudProvider } from './factory.js';
13
13
  import { AntigravityCloudProvider } from './antigravity.js';
14
+ import { CursorCloudProvider } from './cursor.js';
14
15
  import { HostCloudProvider } from './host.js';
15
16
  import { getUserAgentsDir } from '../state.js';
16
17
  import { AGENTS } from '../agents.js';
@@ -44,6 +45,7 @@ function initProviders() {
44
45
  providers.set('codex', new CodexCloudProvider(config.providers?.codex));
45
46
  providers.set('factory', new FactoryCloudProvider(config.providers?.factory));
46
47
  providers.set('antigravity', new AntigravityCloudProvider(config.providers?.antigravity));
48
+ providers.set('cursor', new CursorCloudProvider(config.providers?.cursor));
47
49
  // Your own machines (agents hosts / agents devices) over SSH. No agent
48
50
  // auto-routes here — it's always an explicit --provider host / --host choice.
49
51
  providers.set('host', new HostCloudProvider());
@@ -14,13 +14,14 @@ import type { JobTrigger } from '../routines.js';
14
14
  * - `codex` — OpenAI Codex Cloud (`codex cloud exec`)
15
15
  * - `factory` — Factory Droid Computers (`droid computer ssh` + remote `droid exec`)
16
16
  * - `antigravity` — Google Gemini Managed Agents (Interactions API)
17
+ * - `cursor` — Cursor Cloud Agents REST API
17
18
  * - `host` — a machine you own (`agents hosts` / `agents devices`), over SSH
18
19
  *
19
20
  * Agents route to their native cloud automatically (see `cloudProvider` in the
20
21
  * agent registry); `--provider` overrides. Nothing auto-routes to `host` — it
21
22
  * is always an explicit `--provider host` (or `--host <name>`) choice.
22
23
  */
23
- export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity' | 'host';
24
+ export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity' | 'cursor' | 'host';
24
25
  /**
25
26
  * Lifecycle state of a cloud-dispatched task.
26
27
  *
@@ -30,7 +31,7 @@ export type CloudProviderId = 'rush' | 'codex' | 'factory' | 'antigravity' | 'ho
30
31
  */
31
32
  export type CloudTaskStatus = 'queued' | 'allocating' | 'running' | 'idle' | 'input_required' | 'completed' | 'failed' | 'cancelled';
32
33
  /** Cloud backends whose wire status is normalized by `normalizeProviderStatus`. */
33
- export type StatusNormalizingProvider = 'rush' | 'codex' | 'antigravity';
34
+ export type StatusNormalizingProvider = 'rush' | 'codex' | 'antigravity' | 'cursor';
34
35
  /**
35
36
  * Normalize a provider's raw wire status into the canonical `CloudTaskStatus`.
36
37
  *
@@ -302,6 +303,10 @@ export interface CloudProviderConfig {
302
303
  secretsBundle?: string;
303
304
  model?: string;
304
305
  };
306
+ /** Cursor Cloud Agents API. The API key remains in the named secrets bundle. */
307
+ cursor?: {
308
+ secretsBundle?: string;
309
+ };
305
310
  }
306
311
  /** Top-level `cloud` section of agents.yaml. */
307
312
  export interface CloudConfig {
@@ -31,6 +31,20 @@ export function normalizeProviderStatus(provider, wireStatus) {
31
31
  return normalizeCodexStatus(wireStatus ?? '');
32
32
  case 'antigravity':
33
33
  return normalizeAntigravityStatus(wireStatus);
34
+ case 'cursor':
35
+ return normalizeCursorStatus(wireStatus ?? '');
36
+ }
37
+ }
38
+ /** Cursor Cloud run status → canonical enum. Unknown non-terminal states remain running. */
39
+ function normalizeCursorStatus(s) {
40
+ switch (s.toUpperCase()) {
41
+ case 'CREATING': return 'queued';
42
+ case 'RUNNING': return 'running';
43
+ case 'FINISHED': return 'completed';
44
+ case 'ERROR':
45
+ case 'EXPIRED': return 'failed';
46
+ case 'CANCELLED': return 'cancelled';
47
+ default: return 'running';
34
48
  }
35
49
  }
36
50
  /** Rush Factory Floor status → canonical enum. Default `running`; no `queued`. */
@@ -124,8 +124,8 @@ export declare function crabboxFind(slug: string, opts?: CrabboxOptions): Crabbo
124
124
  export declare function crabboxStatusReady(slug: string, opts?: CrabboxOptions): boolean;
125
125
  export interface PoolMatchOptions {
126
126
  /**
127
- * Profile label this run would warm a box with (from the repo's
128
- * `.crabbox.yaml`; see config.ts). Both sides normalize an unset profile to
127
+ * Lease pool label (shared default, or `.crabbox.yaml leaseProfile:` opt-in;
128
+ * see config.ts). Both sides normalize an unset profile to
129
129
  * DEFAULT_CRABBOX_PROFILE, so profile-less runs match unlabeled boxes.
130
130
  */
131
131
  profile?: string;
@@ -2,10 +2,9 @@
2
2
  * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
3
  *
4
4
  * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
- * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
- * reuse check matches on the SAME profile the warmup would have used — a reused
7
- * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
- * `pick_ready_box` resolves the pool the same way).
5
+ * the box's `profile` label). `agents run --lease` deliberately does NOT inherit
6
+ * that repo/CI label: leases share the default pool unless `leaseProfile:` opts
7
+ * the repo into a dedicated hot box.
9
8
  */
10
9
  /**
11
10
  * The profile label a box warm pool shares. Matches sandbox.sh's
@@ -14,8 +13,8 @@
14
13
  */
15
14
  export declare const DEFAULT_CRABBOX_PROFILE = "default";
16
15
  /**
17
- * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
18
- * file is missing, unreadable, or has no profile key (crabbox then applies its
19
- * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
16
+ * Pool label for `agents run --lease`. The generic `profile:` key remains owned
17
+ * by repo sandbox/CI scripts; only the explicit `leaseProfile:` key opts lease
18
+ * runs out of the shared default pool.
20
19
  */
21
- export declare function readCrabboxRepoProfile(repoRoot: string): string | undefined;
20
+ export declare function readCrabboxLeaseProfile(repoRoot: string): string;
@@ -2,10 +2,9 @@
2
2
  * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
3
  *
4
4
  * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
- * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
- * reuse check matches on the SAME profile the warmup would have used — a reused
7
- * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
- * `pick_ready_box` resolves the pool the same way).
5
+ * the box's `profile` label). `agents run --lease` deliberately does NOT inherit
6
+ * that repo/CI label: leases share the default pool unless `leaseProfile:` opts
7
+ * the repo into a dedicated hot box.
9
8
  */
10
9
  import * as fs from 'fs';
11
10
  import * as path from 'path';
@@ -16,12 +15,7 @@ import * as yaml from 'yaml';
16
15
  * with no `profile` label both normalize here, so they still match each other.
17
16
  */
18
17
  export const DEFAULT_CRABBOX_PROFILE = 'default';
19
- /**
20
- * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
21
- * file is missing, unreadable, or has no profile key (crabbox then applies its
22
- * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
23
- */
24
- export function readCrabboxRepoProfile(repoRoot) {
18
+ function readCrabboxRepoConfig(repoRoot) {
25
19
  let raw;
26
20
  try {
27
21
  raw = fs.readFileSync(path.join(repoRoot, '.crabbox.yaml'), 'utf-8');
@@ -36,8 +30,14 @@ export function readCrabboxRepoProfile(repoRoot) {
36
30
  catch {
37
31
  return undefined;
38
32
  }
39
- if (!parsed || typeof parsed !== 'object')
40
- return undefined;
41
- const profile = parsed.profile;
42
- return typeof profile === 'string' && profile.length > 0 ? profile : undefined;
33
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
34
+ }
35
+ /**
36
+ * Pool label for `agents run --lease`. The generic `profile:` key remains owned
37
+ * by repo sandbox/CI scripts; only the explicit `leaseProfile:` key opts lease
38
+ * runs out of the shared default pool.
39
+ */
40
+ export function readCrabboxLeaseProfile(repoRoot) {
41
+ const profile = readCrabboxRepoConfig(repoRoot)?.leaseProfile;
42
+ return typeof profile === 'string' && profile.length > 0 ? profile : DEFAULT_CRABBOX_PROFILE;
43
43
  }
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
5
  * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
- * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
- * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
- * always leases a new, torn-down box). The whole box-side sequence rides a
9
- * single `--script-stdin` body so the token contents never touch argv.
6
+ * Acquisition is reuse-first against the shared warm pool (a ready box carrying
7
+ * the lease profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). Concurrent callers run from separate
9
+ * `~/workspaces/<repo>-<run>` directories on that box. The whole box-side
10
+ * sequence rides a single `--script-stdin` body so token contents never touch argv.
10
11
  *
11
12
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
12
13
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -54,6 +55,8 @@ export interface LeaseRunOptions {
54
55
  backend?: string;
55
56
  boxClass?: string;
56
57
  profile?: string;
58
+ /** Per-run directory under ~/workspaces; isolates concurrent runs on one box. */
59
+ workspaceId?: string;
57
60
  /** Runtimes to install on the box. */
58
61
  runtimes: AgentId[];
59
62
  /** Runtime credentials to copy; defaults to `runtimes`. */
@@ -112,6 +115,10 @@ export interface LeaseRunResult {
112
115
  exitCode: number | null;
113
116
  toreDown: boolean;
114
117
  }
118
+ /** Build a shell-safe, collision-resistant workspace id for one lease run. */
119
+ export declare function leaseWorkspaceId(repoRoot: string, startedAtMs?: number, pid?: number): string;
120
+ /** Isolated box-home path for one run, relative to the shared box user's home. */
121
+ export declare function leaseHomeDir(workspaceId: string): string;
115
122
  /**
116
123
  * Build the single bootstrap script run on the box: ensure agents-cli, install
117
124
  * the picked runtime CLIs, write their credentials, run the agent, then shred
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
5
  * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
- * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
- * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
- * always leases a new, torn-down box). The whole box-side sequence rides a
9
- * single `--script-stdin` body so the token contents never touch argv.
6
+ * Acquisition is reuse-first against the shared warm pool (a ready box carrying
7
+ * the lease profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). Concurrent callers run from separate
9
+ * `~/workspaces/<repo>-<run>` directories on that box. The whole box-side
10
+ * sequence rides a single `--script-stdin` body so token contents never touch argv.
10
11
  *
11
12
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
12
13
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -38,6 +39,16 @@ import { DEFAULT_CRABBOX_PROFILE } from './config.js';
38
39
  function q(s) {
39
40
  return "'" + s.replace(/'/g, "'\\''") + "'";
40
41
  }
42
+ /** Build a shell-safe, collision-resistant workspace id for one lease run. */
43
+ export function leaseWorkspaceId(repoRoot, startedAtMs = Date.now(), pid = process.pid) {
44
+ const repo = repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? 'repo';
45
+ const safeRepo = repo.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repo';
46
+ return `${safeRepo}-${startedAtMs.toString(36)}-${pid.toString(36)}`;
47
+ }
48
+ /** Isolated box-home path for one run, relative to the shared box user's home. */
49
+ export function leaseHomeDir(workspaceId) {
50
+ return `lease-homes/${workspaceId}`;
51
+ }
41
52
  function profileRemotePath(name) {
42
53
  return `.agents/profiles/${name}.yml`;
43
54
  }
@@ -124,15 +135,35 @@ export function buildBootstrapScript(opts) {
124
135
  // them as a structured step stream — they never appear as setup noise.
125
136
  const step = (name) => `echo ${q(leasePhaseSentinel(name))}`;
126
137
  const copySetup = opts.copySetup !== false; // default TRUE
138
+ const workspace = opts.workspaceId
139
+ ? [
140
+ 'BOX_HOME="$HOME"',
141
+ 'REPO_DIR="$(pwd)"',
142
+ `WORKSPACE_DIR="$BOX_HOME"/${q(`workspaces/${opts.workspaceId}`)}`,
143
+ 'mkdir -p "$WORKSPACE_DIR"',
144
+ 'rsync -a --delete --exclude=node_modules --exclude=.agents/worktrees "$REPO_DIR/" "$WORKSPACE_DIR/"',
145
+ 'cd "$WORKSPACE_DIR"',
146
+ ].join('\n')
147
+ : '';
148
+ const isolatedHome = opts.workspaceId
149
+ ? [
150
+ `export HOME="$BOX_HOME"/${q(leaseHomeDir(opts.workspaceId))}`,
151
+ 'mkdir -p "$HOME/.agents"',
152
+ 'ln -sfn "$BOX_HOME/.agents/.system" "$HOME/.agents/.system"',
153
+ 'export PATH="$BOX_HOME/.local/bin:$PATH"',
154
+ ].join('\n')
155
+ : '';
127
156
  return [
128
157
  'set -uo pipefail',
129
158
  // crabbox has finished its workspace resync by the time this script runs;
130
159
  // the sync sentinel marks the transition out of that (crabbox-driven) phase.
131
160
  step('sync'),
161
+ workspace,
132
162
  // Only meaningful on a tailnet lease — the box already joined during warmup.
133
163
  opts.netMode === 'tailscale' ? step('joined-tailnet') : '',
134
164
  step('install'),
135
165
  ENSURE_AGENTS_CLI,
166
+ isolatedHome,
136
167
  step('runtime'),
137
168
  installRuntimes,
138
169
  step('creds'),
@@ -287,6 +318,7 @@ export async function leaseAndRun(opts) {
287
318
  secretsBundle: opts.secretsBundle,
288
319
  onData: opts.onData,
289
320
  refresh: false,
321
+ remoteDir: opts.workspaceId ? `${leaseHomeDir(opts.workspaceId)}/.agents/` : undefined,
290
322
  });
291
323
  }
292
324
  catch {
@@ -303,10 +335,10 @@ export async function leaseAndRun(opts) {
303
335
  });
304
336
  }
305
337
  finally {
306
- // Always attempt teardown (bounds credential lifetime to the run) unless the
307
- // caller explicitly asked to keep the box or the box was reused (an explicit
308
- // --box target or a warm pool box both outlive this run).
309
- if (!opts.keep && !reused) {
338
+ // Normal --lease establishes or reuses the warm pool, so the box outlives
339
+ // this run; credentials are still shredded inside the script above. Only
340
+ // --fresh requests the old one-shot lifecycle and tears its new box down.
341
+ if (!opts.keep && opts.fresh && !reused) {
310
342
  opts.onPhase?.({ kind: 'teardown' });
311
343
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
312
344
  }
@@ -31,6 +31,11 @@ export interface CopySetupOptions {
31
31
  secretsBundle?: string;
32
32
  /** Override the local `~/.agents` dir (defaults to `getUserAgentsDir()`). */
33
33
  userAgentsDir?: string;
34
+ /**
35
+ * Destination relative to the box user's home. Lease runs set this to their
36
+ * isolated home; other callers retain the historical `~/.agents/` target.
37
+ */
38
+ remoteDir?: string;
34
39
  /** Receives combined stdout/stderr of the rsync + refresh, if set. */
35
40
  onData?: (chunk: string) => void;
36
41
  /**
@@ -109,9 +109,25 @@ export async function copySetupToBox(opts) {
109
109
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-setup-copy-'));
110
110
  const listPath = path.join(tmp, 'files.lst');
111
111
  try {
112
+ if (opts.remoteDir) {
113
+ if (!/^[a-zA-Z0-9._/-]+$/.test(opts.remoteDir) || opts.remoteDir.startsWith('/') || opts.remoteDir.split('/').includes('..')) {
114
+ throw new Error(`remote setup directory must stay under the box home: ${opts.remoteDir}`);
115
+ }
116
+ const remoteDir = opts.remoteDir.replace(/\/$/, '');
117
+ const prepareArgs = [
118
+ ...sshArgv.slice(1),
119
+ 'bash',
120
+ '-lc',
121
+ `mkdir -p "$HOME"/${JSON.stringify(remoteDir)}`,
122
+ ];
123
+ const prepareExitCode = await runStreaming('ssh', prepareArgs, env, opts.onData);
124
+ if (prepareExitCode !== 0) {
125
+ return { files, pushExitCode: prepareExitCode, refreshExitCode: null };
126
+ }
127
+ }
112
128
  // NUL-separated list, matching `buildSetupRsyncArgs`'s `--from0`.
113
129
  fs.writeFileSync(listPath, files.join('\0'), 'utf-8');
114
- const rsyncArgs = buildSetupRsyncArgs({ rsh, host, filesFrom: listPath, source: dir });
130
+ const rsyncArgs = buildSetupRsyncArgs({ rsh, host, filesFrom: listPath, source: dir, remoteDir: opts.remoteDir });
115
131
  const pushExitCode = await runStreaming('rsync', rsyncArgs, env, opts.onData);
116
132
  let refreshExitCode = null;
117
133
  if (pushExitCode === 0 && opts.refresh !== false) {
@@ -24,7 +24,9 @@ import { BrowserService } from './browser/service.js';
24
24
  import { BrowserIPCServer } from './browser/ipc.js';
25
25
  import { redactSecrets } from './redact.js';
26
26
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
27
- import { isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
27
+ import { getConfigValue, isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
28
+ import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
29
+ import { runWatchdogPass } from './watchdog/service.js';
28
30
  const PID_FILE = 'daemon.pid';
29
31
  const LOCK_FILE = 'daemon.lock';
30
32
  const LOG_FILE = 'logs.jsonl';
@@ -34,6 +36,7 @@ const LOG_ROTATE_COUNT = 3;
34
36
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
35
37
  const SYSTEMD_UNIT = 'agents-daemon.service';
36
38
  const MONITOR_TICK_MS = 60_000;
39
+ const WATCHDOG_TICK_MS = 3 * 60_000;
37
40
  /**
38
41
  * How often to re-scan for missed fires. Deliberately slower than the monitor
39
42
  * tick: detection walks a week of cron occurrences per routine
@@ -572,6 +575,24 @@ export async function runDaemon() {
572
575
  }
573
576
  if (schedulerEnabledAtBoot)
574
577
  bootScheduler();
578
+ let watchdogInFlight = false;
579
+ const runDaemonWatchdog = async () => {
580
+ if (watchdogInFlight || getConfigValue('watchdog.enabled').value !== true)
581
+ return;
582
+ watchdogInFlight = true;
583
+ try {
584
+ const result = await runWatchdogPass({ nudge: true });
585
+ log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
586
+ }
587
+ catch (err) {
588
+ log('ERROR', `watchdog tick failed: ${err.message}`);
589
+ }
590
+ finally {
591
+ watchdogInFlight = false;
592
+ }
593
+ };
594
+ const watchdogInterval = setInterval(() => { void runDaemonWatchdog(); }, WATCHDOG_TICK_MS);
595
+ const watchdogKickoff = setTimeout(() => { void runDaemonWatchdog(); }, 30_000);
575
596
  // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
576
597
  // daemon, same dispatch seam — a monitor is a routine whose trigger is a
577
598
  // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
@@ -682,6 +703,9 @@ export async function runDaemon() {
682
703
  const monitorInterval = setInterval(() => {
683
704
  writeHeartbeat();
684
705
  monitorRunningJobs();
706
+ const reaped = reapTerminalRoutineProcesses();
707
+ if (reaped.length > 0)
708
+ log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
685
709
  }, MONITOR_TICK_MS);
686
710
  // Resource safety check: heal gaps between what DotAgents repos define and
687
711
  // what's actually installed in each agent home — the slow rot that nothing
@@ -1066,6 +1090,8 @@ export async function runDaemon() {
1066
1090
  monitorEngine.stop();
1067
1091
  await browserIPC.stop();
1068
1092
  clearInterval(monitorInterval);
1093
+ clearInterval(watchdogInterval);
1094
+ clearTimeout(watchdogKickoff);
1069
1095
  clearInterval(healInterval);
1070
1096
  clearTimeout(healKickoff);
1071
1097
  clearInterval(autoDispatchInterval);
@@ -67,6 +67,13 @@ export const CONFIG_KEYS = [
67
67
  type: 'bool',
68
68
  description: 'Whether the routines scheduler (daemon) may fire on this device.',
69
69
  },
70
+ {
71
+ name: 'watchdog.enabled',
72
+ yamlKey: 'watchdogEnabled',
73
+ scope: 'device',
74
+ type: 'bool',
75
+ description: 'Whether the daemon runs the watchdog pass on this device.',
76
+ },
70
77
  {
71
78
  name: 'browser.remote-control',
72
79
  yamlKey: 'browserRemoteControl',