@phnx-labs/agents-cli 1.18.6 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +13 -2
  2. package/README.md +22 -20
  3. package/dist/commands/browser.js +25 -2
  4. package/dist/commands/cloud.js +3 -3
  5. package/dist/commands/computer.d.ts +6 -0
  6. package/dist/commands/computer.js +477 -0
  7. package/dist/commands/doctor.js +19 -17
  8. package/dist/commands/exec.js +37 -59
  9. package/dist/commands/factory.js +12 -5
  10. package/dist/commands/import.js +6 -1
  11. package/dist/commands/mcp.js +9 -4
  12. package/dist/commands/packages.d.ts +3 -0
  13. package/dist/commands/packages.js +20 -12
  14. package/dist/commands/permissions.d.ts +2 -0
  15. package/dist/commands/permissions.js +20 -1
  16. package/dist/commands/plugins.d.ts +2 -0
  17. package/dist/commands/plugins.js +23 -4
  18. package/dist/commands/profiles.js +1 -1
  19. package/dist/commands/pty.js +126 -112
  20. package/dist/commands/pull.js +29 -25
  21. package/dist/commands/repo.js +24 -26
  22. package/dist/commands/routines.js +29 -26
  23. package/dist/commands/secrets.js +66 -73
  24. package/dist/commands/sessions-tail.js +21 -22
  25. package/dist/commands/sessions.js +36 -68
  26. package/dist/commands/setup.js +20 -24
  27. package/dist/commands/teams.js +30 -39
  28. package/dist/commands/versions.js +60 -68
  29. package/dist/commands/worktree.d.ts +20 -0
  30. package/dist/commands/worktree.js +242 -0
  31. package/dist/computer.d.ts +2 -0
  32. package/dist/computer.js +7 -0
  33. package/dist/index.js +70 -26
  34. package/dist/lib/agents.d.ts +4 -1
  35. package/dist/lib/agents.js +23 -5
  36. package/dist/lib/browser/cdp.d.ts +15 -1
  37. package/dist/lib/browser/cdp.js +77 -8
  38. package/dist/lib/browser/chrome.js +17 -24
  39. package/dist/lib/browser/drivers/ssh.d.ts +1 -0
  40. package/dist/lib/browser/drivers/ssh.js +20 -8
  41. package/dist/lib/browser/ipc.js +38 -5
  42. package/dist/lib/browser/profiles.js +34 -2
  43. package/dist/lib/browser/runtime-state.d.ts +1 -2
  44. package/dist/lib/browser/runtime-state.js +11 -3
  45. package/dist/lib/browser/service.d.ts +5 -0
  46. package/dist/lib/browser/service.js +32 -4
  47. package/dist/lib/browser/types.d.ts +1 -1
  48. package/dist/lib/browser/upload.d.ts +2 -0
  49. package/dist/lib/browser/upload.js +34 -0
  50. package/dist/lib/cloud/rush.d.ts +2 -1
  51. package/dist/lib/cloud/rush.js +28 -9
  52. package/dist/lib/computer-rpc.d.ts +24 -0
  53. package/dist/lib/computer-rpc.js +263 -0
  54. package/dist/lib/daemon.js +7 -7
  55. package/dist/lib/exec.d.ts +2 -1
  56. package/dist/lib/exec.js +3 -2
  57. package/dist/lib/fs-atomic.d.ts +18 -0
  58. package/dist/lib/fs-atomic.js +76 -0
  59. package/dist/lib/git.js +2 -4
  60. package/dist/lib/help.d.ts +15 -0
  61. package/dist/lib/help.js +41 -0
  62. package/dist/lib/hooks/match.d.ts +1 -0
  63. package/dist/lib/hooks/match.js +57 -12
  64. package/dist/lib/hooks.d.ts +1 -0
  65. package/dist/lib/hooks.js +27 -10
  66. package/dist/lib/import.d.ts +1 -0
  67. package/dist/lib/import.js +7 -0
  68. package/dist/lib/manifest.js +27 -1
  69. package/dist/lib/mcp.d.ts +14 -0
  70. package/dist/lib/mcp.js +79 -14
  71. package/dist/lib/migrate.js +3 -3
  72. package/dist/lib/models.js +3 -1
  73. package/dist/lib/permissions.d.ts +5 -0
  74. package/dist/lib/permissions.js +35 -0
  75. package/dist/lib/plugin-marketplace.d.ts +3 -1
  76. package/dist/lib/plugin-marketplace.js +36 -1
  77. package/dist/lib/plugins.d.ts +19 -1
  78. package/dist/lib/plugins.js +99 -8
  79. package/dist/lib/redact.d.ts +4 -0
  80. package/dist/lib/redact.js +18 -0
  81. package/dist/lib/registry.d.ts +2 -0
  82. package/dist/lib/registry.js +15 -0
  83. package/dist/lib/sandbox.js +15 -5
  84. package/dist/lib/secrets/bundles.d.ts +7 -12
  85. package/dist/lib/secrets/bundles.js +45 -29
  86. package/dist/lib/secrets/index.js +4 -4
  87. package/dist/lib/session/cloud.d.ts +2 -0
  88. package/dist/lib/session/cloud.js +34 -6
  89. package/dist/lib/session/parse.js +7 -2
  90. package/dist/lib/session/render.d.ts +4 -1
  91. package/dist/lib/session/render.js +81 -35
  92. package/dist/lib/shims.d.ts +5 -2
  93. package/dist/lib/shims.js +29 -7
  94. package/dist/lib/state.d.ts +5 -5
  95. package/dist/lib/state.js +43 -13
  96. package/dist/lib/teams/agents.js +1 -1
  97. package/dist/lib/types.d.ts +4 -3
  98. package/dist/lib/types.js +0 -2
  99. package/dist/lib/versions.js +65 -40
  100. package/dist/lib/workflows.d.ts +7 -0
  101. package/dist/lib/workflows.js +42 -1
  102. package/npm-shrinkwrap.json +3256 -0
  103. package/package.json +32 -26
  104. package/scripts/postinstall.js +8 -2
@@ -7,14 +7,34 @@ import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js
7
7
  import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
8
8
  import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
9
9
  import { connectLocal } from './drivers/local.js';
10
- import { connectSSH } from './drivers/ssh.js';
10
+ import { connectSSH, shellQuote } from './drivers/ssh.js';
11
11
  import { clearProfileRuntime } from './runtime-state.js';
12
12
  import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
13
13
  import { getRefs, resolveRefToCoords } from './refs.js';
14
14
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
15
15
  import { typeEditorText } from './editor.js';
16
- import { detectUploadPattern, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
16
+ import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
17
17
  import { emit } from '../events.js';
18
+ function isPathInside(candidate, dir) {
19
+ const rel = path.relative(dir, candidate);
20
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
21
+ }
22
+ export function resolveScreenshotOutputPath(outputPath, automaticPath) {
23
+ if (!outputPath)
24
+ return automaticPath;
25
+ const runtimeDir = getBrowserRuntimeDir();
26
+ fs.mkdirSync(runtimeDir, { recursive: true });
27
+ const runtimeReal = fs.realpathSync(runtimeDir);
28
+ const requested = path.resolve(outputPath);
29
+ const parent = path.dirname(requested);
30
+ fs.mkdirSync(parent, { recursive: true });
31
+ const parentReal = fs.realpathSync(parent);
32
+ const resolved = path.join(parentReal, path.basename(requested));
33
+ if (!isPathInside(resolved, runtimeReal)) {
34
+ return automaticPath;
35
+ }
36
+ return resolved;
37
+ }
18
38
  /**
19
39
  * Read width/height from a JPEG buffer by walking SOF markers. Returns null
20
40
  * if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
@@ -151,6 +171,10 @@ async function execSSH(host, cmd) {
151
171
  });
152
172
  return stdout;
153
173
  }
174
+ export function readNewestMatchingRemoteFileCommand(dir, prefix, tailLines) {
175
+ const glob = `${shellQuote(dir)}/${prefix}*.jsonl`;
176
+ return `latest=$(ls -1t ${glob} 2>/dev/null | head -1); if [ -n "$latest" ]; then tail -n ${tailLines} "$latest"; fi`;
177
+ }
154
178
  export function readNewestMatchingFile(dir, prefix, tailLines) {
155
179
  let entries;
156
180
  try {
@@ -619,7 +643,8 @@ export class BrowserService {
619
643
  extension = 'jpg';
620
644
  }
621
645
  const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
622
- const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.${extension}`);
646
+ const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
647
+ const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
623
648
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
624
649
  await fs.promises.writeFile(finalPath, buffer);
625
650
  const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
@@ -920,6 +945,9 @@ export class BrowserService {
920
945
  }
921
946
  return { mode: resolved };
922
947
  }
948
+ stageUpload(source) {
949
+ return { path: stageUploadFile(source) };
950
+ }
923
951
  async status(profileName) {
924
952
  const seen = new Set();
925
953
  const statuses = [];
@@ -1194,7 +1222,7 @@ export class BrowserService {
1194
1222
  if (!prefix)
1195
1223
  return '';
1196
1224
  if (profile.logHost) {
1197
- return execSSH(profile.logHost, `ls -1t ${logDir}/${prefix}*.jsonl 2>/dev/null | head -1 | xargs -r tail -n ${tailN}`);
1225
+ return execSSH(profile.logHost, readNewestMatchingRemoteFileCommand(logDir, prefix, tailN));
1198
1226
  }
1199
1227
  return readNewestMatchingFile(logDir, prefix, tailN);
1200
1228
  }));
@@ -43,7 +43,7 @@ export interface BrowserProfile {
43
43
  };
44
44
  /** Directory holding source-side JSONL logs (e.g. ~/.rush/logs). */
45
45
  logDir?: string;
46
- /** Optional SSH host where logDir lives, e.g. "muqsit@mac-mini". */
46
+ /** Optional SSH host where logDir lives, e.g. "user@mac-mini". */
47
47
  logHost?: string;
48
48
  }
49
49
  /** Parsed form of `BrowserProfile.targetFilter`. */
@@ -27,6 +27,8 @@ import { type RefNode } from './refs.js';
27
27
  export interface UploadOptions {
28
28
  files: string[];
29
29
  }
30
+ export declare function getUploadStagingDir(): string;
31
+ export declare function stageUploadFile(source: string): string;
30
32
  /** Pattern A — direct file input via `DOM.setFileInputFiles`. */
31
33
  export declare function uploadToFileInput(cdp: CDPClient, sessionId: string, backendNodeId: number, files: string[]): Promise<void>;
32
34
  /** Pattern B — synthetic drag-drop onto a target node. */
@@ -1,7 +1,28 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { randomBytes } from 'crypto';
3
4
  import { clickAtCoords } from './input.js';
5
+ import { getBrowserRuntimeDir } from './profiles.js';
4
6
  import { resolveRefToCoords } from './refs.js';
7
+ export function getUploadStagingDir() {
8
+ return path.join(getBrowserRuntimeDir(), 'uploads');
9
+ }
10
+ export function stageUploadFile(source) {
11
+ if (!path.isAbsolute(source)) {
12
+ throw new Error(`upload-stage: source path must be absolute: ${source}`);
13
+ }
14
+ const resolvedSource = fs.realpathSync(source);
15
+ const stat = fs.statSync(resolvedSource);
16
+ if (!stat.isFile()) {
17
+ throw new Error(`upload-stage: source path is not a file: ${source}`);
18
+ }
19
+ const stagingDir = getUploadStagingDir();
20
+ fs.mkdirSync(stagingDir, { recursive: true, mode: 0o700 });
21
+ fs.chmodSync(stagingDir, 0o700);
22
+ const stagedPath = path.join(stagingDir, `${randomBytes(8).toString('hex')}${path.extname(source)}`);
23
+ fs.copyFileSync(resolvedSource, stagedPath);
24
+ return stagedPath;
25
+ }
5
26
  const RESOLVE_FILE_INPUT_FN = `(function() {
6
27
  const start = this;
7
28
  // Walk multiple paths to find an <input type=file>:
@@ -191,6 +212,10 @@ function validateFiles(files) {
191
212
  if (!files || files.length === 0) {
192
213
  throw new Error('At least one file path is required');
193
214
  }
215
+ const stagingDir = getUploadStagingDir();
216
+ fs.mkdirSync(stagingDir, { recursive: true, mode: 0o700 });
217
+ fs.chmodSync(stagingDir, 0o700);
218
+ const resolvedStagingDir = fs.realpathSync(stagingDir);
194
219
  for (const f of files) {
195
220
  if (!path.isAbsolute(f)) {
196
221
  throw new Error(`Upload path must be absolute: ${f}`);
@@ -198,8 +223,17 @@ function validateFiles(files) {
198
223
  if (!fs.existsSync(f)) {
199
224
  throw new Error(`File not found: ${f}`);
200
225
  }
226
+ const resolvedFile = fs.realpathSync(f);
227
+ if (!isPathInside(resolvedFile, resolvedStagingDir)) {
228
+ throw new Error(`upload: path ${f} is outside the upload staging directory ${stagingDir}. ` +
229
+ `Stage files via 'agents browser upload-stage <path>' first.`);
230
+ }
201
231
  }
202
232
  }
233
+ function isPathInside(candidate, dir) {
234
+ const rel = path.relative(dir, candidate);
235
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
236
+ }
203
237
  const MIME_BY_EXT = {
204
238
  '.png': 'image/png',
205
239
  '.jpg': 'image/jpeg',
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities } from './types.js';
8
8
  export declare const RUSH_CONSENT_PATH: string;
9
- export declare function hasRushUploadConsent(opts?: DispatchOptions): boolean;
9
+ export declare function hasRushUploadConsent(accountFingerprint: string, opts?: DispatchOptions, consentPath?: string): boolean;
10
10
  /** One version's entry in the account manifest sent on every dispatch. */
11
11
  export interface AccountManifestEntry {
12
12
  version: string;
@@ -44,6 +44,7 @@ export declare function buildAccountManifest(strategy?: string): Promise<Account
44
44
  * never upload tokens for versions the server hasn't asked about.
45
45
  */
46
46
  export declare function buildAccountTokensPayload(versions: string[]): Promise<AccountTokenEntry[]>;
47
+ export declare function accountTokensFingerprint(tokens: AccountTokenEntry[]): string;
47
48
  /**
48
49
  * Build the POST body for /api/v1/cloud-runs. Exported so tests can verify
49
50
  * the back-compat shape (singular fields + repos[]) without needing real
@@ -17,24 +17,38 @@ import { getAccountInfo } from '../agents.js';
17
17
  import { loadClaudeOauth } from '../usage.js';
18
18
  import { selectBalancedVersion } from '../rotate.js';
19
19
  const PROXY_BASE = 'https://api.prix.dev';
20
+ const PROXY_HOST = new URL(PROXY_BASE).host;
20
21
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
21
22
  // Persistent consent record for uploading Claude OAuth blobs to Rush Cloud.
22
23
  // Created on first explicit consent (env var or flag); subsequent dispatches
23
24
  // see it and proceed without re-prompting.
24
25
  export const RUSH_CONSENT_PATH = path.join(getCloudDir(), 'rush-consent.json');
25
26
  const RUSH_CONSENT_ENV = 'AGENTS_RUSH_UPLOAD_TOKENS';
26
- export function hasRushUploadConsent(opts) {
27
+ export function hasRushUploadConsent(accountFingerprint, opts, consentPath = RUSH_CONSENT_PATH) {
27
28
  if (process.env[RUSH_CONSENT_ENV] === '1')
28
29
  return true;
29
30
  const po = opts?.providerOptions;
30
31
  if (po?.uploadAccountTokens === true)
31
32
  return true;
32
- return fs.existsSync(RUSH_CONSENT_PATH);
33
+ try {
34
+ if (!fs.existsSync(consentPath))
35
+ return false;
36
+ const body = JSON.parse(fs.readFileSync(consentPath, 'utf-8'));
37
+ return body.host === PROXY_HOST && body.account_fingerprint === accountFingerprint;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
33
42
  }
34
- function recordRushUploadConsent(grantedBy) {
43
+ function recordRushUploadConsent(grantedBy, accountFingerprint) {
35
44
  try {
36
45
  fs.mkdirSync(path.dirname(RUSH_CONSENT_PATH), { recursive: true });
37
- const body = { granted_at: new Date().toISOString(), granted_by: grantedBy };
46
+ const body = {
47
+ granted_at: new Date().toISOString(),
48
+ granted_by: grantedBy,
49
+ host: PROXY_HOST,
50
+ account_fingerprint: accountFingerprint,
51
+ };
38
52
  fs.writeFileSync(RUSH_CONSENT_PATH, JSON.stringify(body, null, 2), { mode: 0o600 });
39
53
  }
40
54
  catch {
@@ -107,7 +121,7 @@ async function findInstallation(token, owner, repo) {
107
121
  }
108
122
  }
109
123
  }
110
- throw new Error(`No GitHub App installation found for ${owner}/${repo}. Install the Rush GitHub App at https://github.com/apps/prix-cloud.`);
124
+ throw new Error(`No GitHub App installation found for ${owner}/${repo}. Install the Rush GitHub App at https://github.com/apps/cloud-bot.`);
111
125
  }
112
126
  /** sha256 → hex. */
113
127
  function sha256(input) {
@@ -239,6 +253,10 @@ export async function buildAccountTokensPayload(versions) {
239
253
  }
240
254
  return out;
241
255
  }
256
+ export function accountTokensFingerprint(tokens) {
257
+ const canonical = [...tokens].sort((a, b) => a.version.localeCompare(b.version));
258
+ return sha256(JSON.stringify(canonical));
259
+ }
242
260
  /**
243
261
  * Build the POST body for /api/v1/cloud-runs. Exported so tests can verify
244
262
  * the back-compat shape (singular fields + repos[]) without needing real
@@ -360,11 +378,13 @@ export class RushCloudProvider {
360
378
  const errBody = await res.clone().text();
361
379
  const promptCode = parsePromptCode(errBody);
362
380
  if (promptCode === 'NEW_ACCOUNT' || promptCode === 'TOKEN_ROTATED') {
381
+ const accountTokens = await buildAccountTokensPayload(accountManifest.versions.map((v) => v.version));
382
+ const accountFingerprint = accountTokensFingerprint(accountTokens);
363
383
  // Refuse to silently exfiltrate Claude OAuth credentials. The retry
364
384
  // path below reads accessToken+refreshToken from every installed
365
385
  // Claude version and POSTs them to api.prix.dev. That's an explicit
366
386
  // data-flow decision the user has to opt into.
367
- if (!hasRushUploadConsent(options)) {
387
+ if (!hasRushUploadConsent(accountFingerprint, options)) {
368
388
  throw new Error([
369
389
  `Rush Cloud asked to sync your Claude credentials (reason: ${promptCode.toLowerCase()}).`,
370
390
  `This would upload accessToken + refreshToken from every installed Claude version`,
@@ -383,8 +403,7 @@ export class RushCloudProvider {
383
403
  const grantedBy = process.env[RUSH_CONSENT_ENV] === '1' ? 'env'
384
404
  : options.providerOptions?.uploadAccountTokens === true ? 'flag'
385
405
  : 'manual';
386
- process.stderr.write(`[rush] uploading Claude OAuth credentials to ${PROXY_BASE} (reason: ${promptCode.toLowerCase()}, consent: ${grantedBy})\n`);
387
- const accountTokens = await buildAccountTokensPayload(accountManifest.versions.map((v) => v.version));
406
+ process.stderr.write(`[rush] uploading ${accountTokens.length} account token(s) to ${PROXY_HOST}\n`);
388
407
  const retryBody = buildDispatchBody({
389
408
  agent: options.agent,
390
409
  prompt: options.prompt,
@@ -397,7 +416,7 @@ export class RushCloudProvider {
397
416
  // Persist consent on first successful upload so we don't re-prompt
398
417
  // every time tokens rotate.
399
418
  if (res.ok && grantedBy !== 'manual') {
400
- recordRushUploadConsent(grantedBy);
419
+ recordRushUploadConsent(grantedBy, accountFingerprint);
401
420
  }
402
421
  }
403
422
  }
@@ -0,0 +1,24 @@
1
+ export interface RPCResponse {
2
+ id: number | null;
3
+ result?: Record<string, unknown>;
4
+ error?: {
5
+ code: string;
6
+ message: string;
7
+ };
8
+ }
9
+ export interface ComputerClient {
10
+ call(method: string, params?: Record<string, unknown>): Promise<RPCResponse>;
11
+ close(): Promise<void>;
12
+ }
13
+ export declare function resolveSocketPath(): string;
14
+ export declare function resolveLogPath(): string;
15
+ export declare function resolvePolicyPath(): string;
16
+ export declare function loadComputerAllowList(): string[];
17
+ export declare function writeComputerPolicy(allowedBundleIds: string[]): void;
18
+ export declare function resolveHelperExec(): string | null;
19
+ export declare function resolveHelperApp(): string | null;
20
+ export declare function openComputerClient(): ComputerClient;
21
+ export declare function describeTransport(): {
22
+ kind: 'socket' | 'stdio' | 'none';
23
+ path: string | null;
24
+ };
@@ -0,0 +1,263 @@
1
+ // JSON-RPC client for the computer-helper.
2
+ //
3
+ // Two transports, picked at runtime:
4
+ // - socket: ~/.agents/.cache/helpers/computer.sock (or COMPUTER_HELPER_SOCKET
5
+ // env) when the launchd daemon is installed and listening. Sub-50ms per
6
+ // call. Path is internal scratch — sibling of browser.sock.
7
+ // - stdio: spawn the helper binary as a child process per call. Used in
8
+ // dev (no install-helper) and as a fallback. The legacy probe.py and
9
+ // drive-capcut.py scripts in rush/agents still use this shape.
10
+ //
11
+ // Both transports share the same line-delimited JSON-RPC wire format:
12
+ // in: {"id":N,"method":"...","params":{...}}
13
+ // out: {"id":N,"result":{...}} or {"id":N,"error":{"code":"...","message":"..."}}
14
+ import { spawn } from 'child_process';
15
+ import { createConnection } from 'net';
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from './state.js';
20
+ // Resolve the socket path used by the launchd-managed daemon. Internal
21
+ // scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
22
+ // matching browser.sock.
23
+ export function resolveSocketPath() {
24
+ const envPath = process.env.COMPUTER_HELPER_SOCKET;
25
+ if (envPath && envPath.length > 0)
26
+ return envPath;
27
+ return path.join(getHelpersDir(), 'computer.sock');
28
+ }
29
+ // Default log path for the launchd-managed daemon. Lives in the cache/logs/
30
+ // bucket (matches the scheduler daemon's logs.jsonl convention).
31
+ export function resolveLogPath() {
32
+ return path.join(getLogsDir(), 'computer-helper.log');
33
+ }
34
+ // Policy file the helper reads at startup and on SIGHUP. Sibling of
35
+ // computer.sock under ~/.agents/.cache/helpers/. Allow-list of bare bundle
36
+ // ids (e.g. "com.apple.mail"), derived from Computer(...) patterns in
37
+ // ~/.agents/permissions/groups/.
38
+ export function resolvePolicyPath() {
39
+ return path.join(getHelpersDir(), 'computer-policy.json');
40
+ }
41
+ // Walk all permission group YAMLs (user dir wins on name collision) and
42
+ // collect Computer(<bundle-id>) patterns from each group's `allow:` list.
43
+ // Returns distinct bundle ids. Line-by-line regex extraction matches
44
+ // buildPermissionsFromGroups: YAML parsers stumble on the nested quotes in
45
+ // some rule values, but the strict pattern below catches our shape cleanly.
46
+ export function loadComputerAllowList() {
47
+ const seenFiles = new Set();
48
+ const allowed = new Set();
49
+ for (const baseDir of [getUserPermissionsDir(), getPermissionsDir()]) {
50
+ const groupsDir = path.join(baseDir, 'groups');
51
+ if (!fs.existsSync(groupsDir))
52
+ continue;
53
+ let entries;
54
+ try {
55
+ entries = fs.readdirSync(groupsDir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ for (const entry of entries) {
61
+ if (!entry.isFile())
62
+ continue;
63
+ if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml'))
64
+ continue;
65
+ // User dir wins on filename collision.
66
+ const stem = entry.name.replace(/\.(yaml|yml)$/, '');
67
+ if (seenFiles.has(stem))
68
+ continue;
69
+ seenFiles.add(stem);
70
+ const filePath = path.join(groupsDir, entry.name);
71
+ let content;
72
+ try {
73
+ content = fs.readFileSync(filePath, 'utf-8');
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ // Strict regex: optional whitespace, dash, quoted Computer(<id>).
79
+ // Only honors `allow:` lines — `deny:` Computer patterns would be a
80
+ // contradiction (everything is deny-by-default already).
81
+ let inAllow = false;
82
+ for (const rawLine of content.split('\n')) {
83
+ const line = rawLine.replace(/\r$/, '');
84
+ const sectionMatch = line.match(/^(\w+)\s*:\s*$/);
85
+ if (sectionMatch) {
86
+ inAllow = sectionMatch[1] === 'allow';
87
+ continue;
88
+ }
89
+ if (!inAllow)
90
+ continue;
91
+ const ruleMatch = line.match(/^\s*-\s*"Computer\(([^)]+)\)"\s*$/);
92
+ if (ruleMatch) {
93
+ const bundleId = ruleMatch[1].trim();
94
+ if (bundleId.length > 0)
95
+ allowed.add(bundleId);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ return [...allowed].sort();
101
+ }
102
+ // Write the policy file the helper reads at startup and on SIGHUP.
103
+ // Mode 0600 — same lockdown as the socket (lives in the user-owned cache
104
+ // dir, but be explicit).
105
+ export function writeComputerPolicy(allowedBundleIds) {
106
+ const dir = getHelpersDir();
107
+ if (!fs.existsSync(dir)) {
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ }
110
+ const policy = { allow: allowedBundleIds };
111
+ fs.writeFileSync(resolvePolicyPath(), JSON.stringify(policy, null, 2), { mode: 0o600 });
112
+ }
113
+ // Resolve the helper executable inside the dist .app bundle. Used by the
114
+ // stdio fallback and by install-helper to find the source bundle.
115
+ export function resolveHelperExec() {
116
+ const here = path.dirname(fileURLToPath(import.meta.url));
117
+ const candidates = [
118
+ // Local build (running from the agents-cli checkout).
119
+ path.resolve(here, '..', '..', 'packages', 'computer-helper', 'dist', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
120
+ // Bundled with the npm package (later: CDN download lands here).
121
+ path.resolve(here, '..', 'computer-helper', 'ComputerHelper.app', 'Contents', 'MacOS', 'ComputerHelper'),
122
+ ];
123
+ for (const c of candidates) {
124
+ if (fs.existsSync(c))
125
+ return c;
126
+ }
127
+ return null;
128
+ }
129
+ // Resolve the dist .app bundle directory (not the inner exec).
130
+ export function resolveHelperApp() {
131
+ const exec = resolveHelperExec();
132
+ if (!exec)
133
+ return null;
134
+ // exec = <bundle>/Contents/MacOS/ComputerHelper
135
+ return path.resolve(exec, '..', '..', '..');
136
+ }
137
+ // Pick the best transport. If the socket exists, use it. Otherwise fall
138
+ // back to spawning the helper as a subprocess (legacy path).
139
+ export function openComputerClient() {
140
+ const sockPath = resolveSocketPath();
141
+ if (fs.existsSync(sockPath)) {
142
+ return new SocketClient(sockPath);
143
+ }
144
+ const helperExec = resolveHelperExec();
145
+ if (!helperExec) {
146
+ throw new Error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
147
+ }
148
+ return new StdioClient(helperExec);
149
+ }
150
+ // Shared waiter map + line parser. Both transports plug their reader into
151
+ // `handleChunk` and their writer into `send`.
152
+ class BaseClient {
153
+ buf = '';
154
+ waiters = new Map();
155
+ nextId = 1;
156
+ closed = false;
157
+ handleChunk(chunk) {
158
+ this.buf += chunk;
159
+ let nl;
160
+ while ((nl = this.buf.indexOf('\n')) !== -1) {
161
+ const line = this.buf.slice(0, nl);
162
+ this.buf = this.buf.slice(nl + 1);
163
+ if (!line)
164
+ continue;
165
+ try {
166
+ const obj = JSON.parse(line);
167
+ const id = typeof obj.id === 'number' ? obj.id : null;
168
+ if (id !== null && this.waiters.has(id)) {
169
+ const resolve = this.waiters.get(id);
170
+ this.waiters.delete(id);
171
+ resolve(obj);
172
+ }
173
+ }
174
+ catch {
175
+ // Drop garbage; helper diagnostics go to stderr / log file.
176
+ }
177
+ }
178
+ }
179
+ failPending(code, message) {
180
+ for (const [id, resolve] of this.waiters) {
181
+ resolve({ id, error: { code, message } });
182
+ }
183
+ this.waiters.clear();
184
+ }
185
+ async call(method, params) {
186
+ if (this.closed) {
187
+ return { id: null, error: { code: 'helper_exited', message: 'helper not running' } };
188
+ }
189
+ const id = this.nextId++;
190
+ const payload = JSON.stringify({ id, method, params: params ?? {} }) + '\n';
191
+ return new Promise((resolve) => {
192
+ this.waiters.set(id, resolve);
193
+ this.send(payload);
194
+ });
195
+ }
196
+ }
197
+ class SocketClient extends BaseClient {
198
+ sock;
199
+ constructor(socketPath) {
200
+ super();
201
+ this.sock = createConnection({ path: socketPath });
202
+ this.sock.setEncoding('utf8');
203
+ this.sock.on('data', (chunk) => this.handleChunk(chunk));
204
+ this.sock.on('error', (err) => {
205
+ this.closed = true;
206
+ this.failPending('socket_error', err.message);
207
+ });
208
+ this.sock.on('close', () => {
209
+ this.closed = true;
210
+ this.failPending('helper_exited', 'socket closed before reply');
211
+ });
212
+ }
213
+ send(payload) {
214
+ this.sock.write(payload);
215
+ }
216
+ async close() {
217
+ if (this.closed)
218
+ return;
219
+ this.sock.end();
220
+ await new Promise((resolve) => {
221
+ if (this.closed)
222
+ return resolve();
223
+ this.sock.on('close', () => resolve());
224
+ });
225
+ }
226
+ }
227
+ class StdioClient extends BaseClient {
228
+ proc;
229
+ constructor(helperPath) {
230
+ super();
231
+ this.proc = spawn(helperPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
232
+ this.proc.stdout.setEncoding('utf8');
233
+ this.proc.stdout.on('data', (chunk) => this.handleChunk(chunk));
234
+ this.proc.on('exit', () => {
235
+ this.closed = true;
236
+ this.failPending('helper_exited', 'helper exited before reply');
237
+ });
238
+ }
239
+ send(payload) {
240
+ this.proc.stdin.write(payload);
241
+ }
242
+ async close() {
243
+ if (this.closed)
244
+ return;
245
+ this.proc.stdin.end();
246
+ await new Promise((resolve) => {
247
+ if (this.closed)
248
+ return resolve();
249
+ this.proc.on('exit', () => resolve());
250
+ });
251
+ }
252
+ }
253
+ // Describe which transport is currently in use. Useful for diagnostics
254
+ // like `agents computer status`.
255
+ export function describeTransport() {
256
+ const sockPath = resolveSocketPath();
257
+ if (fs.existsSync(sockPath))
258
+ return { kind: 'socket', path: sockPath };
259
+ const exec = resolveHelperExec();
260
+ if (exec)
261
+ return { kind: 'stdio', path: exec };
262
+ return { kind: 'none', path: null };
263
+ }
@@ -6,7 +6,7 @@
6
6
  * (macOS), systemd (Linux), or as a plain detached process. PID tracking,
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
- import { spawn, execSync, execFileSync } from 'child_process';
9
+ import { spawn, execFileSync } from 'child_process';
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
@@ -286,7 +286,7 @@ function getAgentsBinPath() {
286
286
  if (argv1 && fs.existsSync(argv1))
287
287
  return argv1;
288
288
  try {
289
- return execSync('which agents', { encoding: 'utf-8' }).trim();
289
+ return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
290
290
  }
291
291
  catch {
292
292
  return 'agents';
@@ -348,9 +348,9 @@ function startDaemonLocked() {
348
348
  fs.mkdirSync(unitDir, { recursive: true });
349
349
  }
350
350
  fs.writeFileSync(unitPath, generateSystemdUnit(), 'utf-8');
351
- execSync('systemctl --user daemon-reload', { encoding: 'utf-8' });
352
- execSync(`systemctl --user enable ${SYSTEMD_UNIT}`, { encoding: 'utf-8' });
353
- execSync(`systemctl --user start ${SYSTEMD_UNIT}`, { encoding: 'utf-8' });
351
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
352
+ execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
353
+ execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
354
354
  const pid = waitForPid(3000);
355
355
  return { pid, method: 'systemd' };
356
356
  }
@@ -402,8 +402,8 @@ export function stopDaemon() {
402
402
  }
403
403
  if (platform === 'linux') {
404
404
  try {
405
- execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { encoding: 'utf-8' });
406
- execSync(`systemctl --user disable ${SYSTEMD_UNIT}`, { encoding: 'utf-8' });
405
+ execFileSync('systemctl', ['--user', 'stop', SYSTEMD_UNIT], { encoding: 'utf-8' });
406
+ execFileSync('systemctl', ['--user', 'disable', SYSTEMD_UNIT], { encoding: 'utf-8' });
407
407
  }
408
408
  catch (err) {
409
409
  if (process.env.AGENTS_DEBUG) {
@@ -1,6 +1,6 @@
1
1
  import type { AgentId } from './types.js';
2
2
  /** Agent execution modes controlling tool access and autonomy level. */
3
- export type ExecMode = 'plan' | 'edit' | 'full';
3
+ export type ExecMode = 'plan' | 'edit' | 'full' | 'auto';
4
4
  /** Reasoning effort levels passed to agents that support them. 'auto' defers to the agent's default. */
5
5
  export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
6
6
  /** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
@@ -39,6 +39,7 @@ export interface AgentCommandTemplate {
39
39
  plan: string[];
40
40
  edit: string[];
41
41
  full: string[];
42
+ auto?: string[];
42
43
  };
43
44
  jsonFlags?: string[];
44
45
  modelFlag?: string;
package/dist/lib/exec.js CHANGED
@@ -86,6 +86,7 @@ export const AGENT_COMMANDS = {
86
86
  plan: ['--permission-mode', 'plan'],
87
87
  edit: ['--permission-mode', 'acceptEdits'],
88
88
  full: ['--dangerously-skip-permissions'],
89
+ auto: ['--permission-mode', 'auto'],
89
90
  },
90
91
  jsonFlags: ['--output-format', 'stream-json', '--verbose'],
91
92
  modelFlag: '--model',
@@ -226,8 +227,8 @@ export function buildExecCommand(options) {
226
227
  }
227
228
  }
228
229
  }
229
- // Add mode flags
230
- const modeFlags = template.modeFlags[options.mode];
230
+ // Add mode flags. 'auto' is only defined for claude; other agents fall back to edit flags.
231
+ const modeFlags = template.modeFlags[options.mode] ?? template.modeFlags.edit;
231
232
  cmd.push(...modeFlags);
232
233
  // Add print/headless flags only when a prompt is provided. Without a prompt
233
234
  // the caller wants an interactive REPL -- passing --print would immediately
@@ -0,0 +1,18 @@
1
+ export declare function sleepSync(ms: number): void;
2
+ /**
3
+ * Ensures the target file (and its parent directory) exist so proper-lockfile
4
+ * can create a sibling .lock directory. Created with flag 'wx' so concurrent
5
+ * creation races are safe (EEXIST is swallowed).
6
+ */
7
+ export declare function ensureLockTarget(filePath: string, initialContent?: string, dirMode?: number): void;
8
+ /**
9
+ * Writes content to filePath via a temp file + rename so readers never see a
10
+ * partial write. On POSIX, rename(2) is atomic.
11
+ */
12
+ export declare function atomicWriteFileSync(filePath: string, content: string): void;
13
+ /**
14
+ * Acquires an exclusive proper-lockfile lock on filePath, runs fn, then
15
+ * releases the lock. Retries up to LOCK_RETRIES times with linear back-off.
16
+ * Breaks stale locks older than LOCK_STALE_MS.
17
+ */
18
+ export declare function withFileLock<T>(filePath: string, fn: () => T): T;