aicomputer 0.1.16 → 0.1.18

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.
package/README.md CHANGED
@@ -8,6 +8,9 @@ Agent Computer CLI for creating, opening, and managing computers from the termin
8
8
  npm install -g aicomputer
9
9
  ```
10
10
 
11
+ On macOS and Linux, the npm install and update flow also installs the CLI-managed
12
+ Mutagen binary used by `computer mount`.
13
+
11
14
  Upgrade the installed CLI later with:
12
15
 
13
16
  ```bash
@@ -95,6 +98,7 @@ computer ssh
95
98
  computer ssh my-box
96
99
  computer ssh --setup
97
100
  computer mount
101
+ computer mount --background
98
102
  computer mount status
99
103
  computer agent agents my-box
100
104
  computer agent sessions list my-box
@@ -117,11 +121,20 @@ ssh agentcomputer.ai
117
121
  ssh my-box@agentcomputer.ai
118
122
  ```
119
123
 
120
- Run `computer mount` to start a foreground controller that mirrors all SSH-ready
121
- machine homes under `~/agentcomputer/<handle>` while the command is running.
122
- This uses the same SSH setup as `computer ssh --setup` and requires Mutagen plus
123
- OpenSSH client tools (`ssh` and `scp`) to already be installed locally. The
124
- temporary `~/agentcomputer` root is removed when the command exits.
124
+ Run `computer mount` to start the mount controller in the foreground and mirror
125
+ all SSH-ready machine homes under `~/agentcomputer/<handle>`.
126
+ On macOS, the foreground command also opens Finder to `~/agentcomputer` after
127
+ the controller starts.
128
+
129
+ Run `computer mount --background` to start the same controller detached from
130
+ your terminal. It prints the controller PID immediately so you can inspect it
131
+ later with `computer mount status`.
132
+
133
+ This uses the same SSH setup as `computer ssh --setup`. For npm installs on
134
+ macOS and Linux, the CLI auto-installs its bundled Mutagen copy and will retry
135
+ that install on first mount if npm scripts were skipped. You still need the
136
+ OpenSSH client tools (`ssh` and `scp`) available locally. The temporary
137
+ `~/agentcomputer` root is removed when the command exits.
125
138
 
126
139
  Use `computer agent` to inspect agents on one machine and manage remote sessions. Use `computer acp serve` when you want to expose one remote session through a local ACP bridge.
127
140
 
@@ -1,7 +1,13 @@
1
1
  import {
2
- MOUNT_SERVICE_LABEL,
3
- ensureHandleDirectories,
4
- ensureMountDirectories,
2
+ createHandleSession,
3
+ ensureMutagenSshEnvironment,
4
+ getHandleSessionName,
5
+ isAbortError,
6
+ listOwnedSessions,
7
+ selectPreferredSession,
8
+ terminateSession
9
+ } from "./chunk-F2U4SFJ4.js";
10
+ import {
5
11
  readMountStatusSnapshot,
6
12
  removeMountHandleState,
7
13
  writeMountHandleMeta,
@@ -10,7 +16,7 @@ import {
10
16
 
11
17
  // src/lib/mount-reconcile.ts
12
18
  import { readdir, mkdir, rm } from "fs/promises";
13
- import { join as join3 } from "path";
19
+ import { join as join2 } from "path";
14
20
 
15
21
  // src/lib/config.ts
16
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
@@ -145,8 +151,8 @@ async function readErrorMessage(response) {
145
151
  }
146
152
 
147
153
  // src/lib/computers.ts
148
- async function listComputers() {
149
- const response = await api("/v1/computers");
154
+ async function listComputers(signal) {
155
+ const response = await api("/v1/computers", { signal });
150
156
  return response.computers;
151
157
  }
152
158
  async function getComputerByID(id) {
@@ -166,8 +172,10 @@ async function deleteComputer(computerID) {
166
172
  async function getFilesystemSettings() {
167
173
  return api("/v1/me/filesystem");
168
174
  }
169
- async function getConnectionInfo(computerID) {
170
- return api(`/v1/computers/${computerID}/connection`);
175
+ async function getConnectionInfo(computerID, signal) {
176
+ return api(`/v1/computers/${computerID}/connection`, {
177
+ signal
178
+ });
171
179
  }
172
180
  async function createBrowserAccess(computerID) {
173
181
  return api(`/v1/computers/${computerID}/access/browser`, {
@@ -301,255 +309,6 @@ function describeSSHChoice(computer) {
301
309
  return `${computer.runtime_family} ${timeAgo(computer.updated_at)}`;
302
310
  }
303
311
 
304
- // src/lib/mount-mutagen.ts
305
- import { chmodSync, readFileSync as readFileSync2, symlinkSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
306
- import { spawnSync } from "child_process";
307
- import { basename, join as join2, relative, resolve } from "path";
308
- var SYNC_NAME_PREFIX = "agentcomputer-mount-";
309
- var DEFAULT_IGNORE_PATHS = [
310
- ".codex/tmp",
311
- ".local",
312
- ".ssh/sshd.log"
313
- ];
314
- function ensureMutagenSshEnvironment(config, paths) {
315
- ensureMountDirectories(paths);
316
- const sshPath = resolveCommandPath("ssh");
317
- const scpPath = resolveCommandPath("scp");
318
- writeExecutableLink(paths.sshToolsDir, "ssh", sshPath);
319
- writeExecutableLink(paths.sshToolsDir, "scp", scpPath);
320
- writeExecutableLink(paths.sshToolsDir, basename(sshPath), sshPath);
321
- writeExecutableLink(paths.sshToolsDir, basename(scpPath), scpPath);
322
- }
323
- function getHandleSessionName(handle) {
324
- return `${SYNC_NAME_PREFIX}${handle}`;
325
- }
326
- function createHandleSession(handle, config, paths) {
327
- ensureHandleDirectories(handle, config.rootPath);
328
- const sessionName = getHandleSessionName(handle);
329
- const args = [
330
- "sync",
331
- "create",
332
- join2(paths.rootPath, handle),
333
- `${handle}@${config.alias}:/home/node`,
334
- "--name",
335
- sessionName,
336
- "--label",
337
- `${MOUNT_SERVICE_LABEL}=true`,
338
- "--label",
339
- `${MOUNT_SERVICE_LABEL}.handle=${handle}`,
340
- "--symlink-mode",
341
- "posix-raw"
342
- ];
343
- for (const ignorePath of DEFAULT_IGNORE_PATHS) {
344
- args.push("--ignore", ignorePath);
345
- }
346
- runMutagen(args, config, paths, handle);
347
- runMutagen(["sync", "flush", sessionName, "--skip-wait"], config, paths, handle);
348
- }
349
- function terminateSession(session, config, paths) {
350
- runMutagen(
351
- ["sync", "terminate", session.identifier],
352
- config,
353
- paths,
354
- session.handle,
355
- true
356
- );
357
- }
358
- function listOwnedSessions(config, paths) {
359
- const result = runMutagen(["sync", "list", "-l"], config, paths, "mount", true);
360
- const raw = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
361
- if (!raw) {
362
- return [];
363
- }
364
- if (!result.ok && raw.includes("No synchronization sessions found")) {
365
- return [];
366
- }
367
- const rootPath = resolve(paths.rootPath);
368
- const sessions = parseMutagenSyncList(raw).filter((session) => session.alphaPath).map((session) => {
369
- const alphaPath = resolve(session.alphaPath);
370
- const handle = basename(alphaPath);
371
- const expectedName = getHandleSessionName(handle);
372
- const expectedBeta = `${handle}@${config.alias}:/home/node`;
373
- const owned = alphaPath === rootPath || relative(rootPath, alphaPath) === "" || !relative(rootPath, alphaPath).startsWith("..") && !relative(rootPath, alphaPath).startsWith("../");
374
- if (!owned || !session.identifier) {
375
- return null;
376
- }
377
- return {
378
- identifier: session.identifier,
379
- name: session.name,
380
- handle,
381
- alphaPath,
382
- betaUrl: session.betaUrl,
383
- alphaConnected: session.alphaConnected,
384
- betaConnected: session.betaConnected,
385
- status: session.status,
386
- lastError: session.lastError,
387
- scanProblemCount: session.scanProblemCount,
388
- conflictCount: session.conflictCount,
389
- legacy: session.name !== expectedName || session.betaUrl !== expectedBeta || alphaPath !== resolve(join2(rootPath, handle))
390
- };
391
- }).filter((session) => session !== null);
392
- return sessions.sort((left, right) => left.handle.localeCompare(right.handle));
393
- }
394
- function selectPreferredSession(handle, sessions) {
395
- const expectedName = getHandleSessionName(handle);
396
- const exact = sessions.find((session) => session.name === expectedName);
397
- return exact ?? sessions[0];
398
- }
399
- function parseMutagenSyncList(output) {
400
- const sessions = [];
401
- let current = null;
402
- let section = "";
403
- const finishCurrent = () => {
404
- if (current?.identifier) {
405
- sessions.push(current);
406
- }
407
- current = null;
408
- section = "";
409
- };
410
- for (const line of output.split(/\r?\n/)) {
411
- if (/^-{20,}$/.test(line.trim())) {
412
- finishCurrent();
413
- continue;
414
- }
415
- const trimmed = line.trim();
416
- if (!trimmed) {
417
- continue;
418
- }
419
- if (!current) {
420
- current = {
421
- alphaConnected: false,
422
- betaConnected: false,
423
- scanProblemCount: 0,
424
- conflictCount: 0
425
- };
426
- }
427
- if (trimmed.endsWith(":")) {
428
- switch (trimmed.slice(0, -1)) {
429
- case "Alpha":
430
- case "Beta":
431
- case "Scan problems":
432
- case "Conflicts":
433
- section = trimmed.slice(0, -1);
434
- continue;
435
- default:
436
- continue;
437
- }
438
- }
439
- if (trimmed.startsWith("Name: ")) {
440
- current.name = trimmed.slice("Name: ".length);
441
- continue;
442
- }
443
- if (trimmed.startsWith("Identifier: ")) {
444
- current.identifier = trimmed.slice("Identifier: ".length);
445
- continue;
446
- }
447
- if (trimmed.startsWith("Status: ")) {
448
- current.status = trimmed.slice("Status: ".length);
449
- continue;
450
- }
451
- if (trimmed.startsWith("Last error: ")) {
452
- current.lastError = trimmed.slice("Last error: ".length);
453
- continue;
454
- }
455
- if (section === "Alpha") {
456
- if (trimmed.startsWith("URL: ")) {
457
- current.alphaPath = trimmed.slice("URL: ".length);
458
- continue;
459
- }
460
- if (trimmed.startsWith("Connected: ")) {
461
- current.alphaConnected = parseConnected(trimmed);
462
- continue;
463
- }
464
- }
465
- if (section === "Beta") {
466
- if (trimmed.startsWith("URL: ")) {
467
- current.betaUrl = trimmed.slice("URL: ".length);
468
- continue;
469
- }
470
- if (trimmed.startsWith("Connected: ")) {
471
- current.betaConnected = parseConnected(trimmed);
472
- continue;
473
- }
474
- }
475
- if (section === "Scan problems") {
476
- current.scanProblemCount += 1;
477
- continue;
478
- }
479
- if (section === "Conflicts") {
480
- if (trimmed.startsWith("(alpha)")) {
481
- current.conflictCount += 1;
482
- }
483
- continue;
484
- }
485
- }
486
- finishCurrent();
487
- return sessions;
488
- }
489
- function parseConnected(line) {
490
- return line.slice("Connected: ".length).trim().toLowerCase() === "yes";
491
- }
492
- function runMutagen(args, config, paths, handle, ignoreFailure = false) {
493
- const result = spawnSync("mutagen", args, {
494
- encoding: "utf8",
495
- env: {
496
- ...process.env,
497
- MUTAGEN_SSH_PATH: paths.sshToolsDir,
498
- MUTAGEN_SSH_CONNECT_TIMEOUT: String(config.connectTimeoutSeconds)
499
- }
500
- });
501
- const stdout = result.stdout?.trim() ?? "";
502
- const stderr = result.stderr?.trim() ?? "";
503
- if (result.status !== 0 && !ignoreFailure) {
504
- throw new Error(stderr || stdout || `mutagen failed for ${handle}`);
505
- }
506
- return {
507
- ok: result.status === 0,
508
- stdout,
509
- stderr,
510
- status: result.status
511
- };
512
- }
513
- function resolveCommandPath(command) {
514
- const result = spawnSync("which", [command], { encoding: "utf8" });
515
- if (result.status !== 0) {
516
- throw new Error(`failed to resolve ${command}`);
517
- }
518
- return result.stdout.trim();
519
- }
520
- function writeExecutableLink(directory, name, target) {
521
- const linkPath = join2(directory, name);
522
- try {
523
- unlinkSync(linkPath);
524
- } catch {
525
- }
526
- try {
527
- symlinkSync(target, linkPath);
528
- } catch {
529
- const script = `#!/bin/sh
530
- exec "${escapeShell(target)}" "$@"
531
- `;
532
- writeFileSync2(linkPath, script, { mode: 493 });
533
- chmodSync(linkPath, 493);
534
- return;
535
- }
536
- try {
537
- const stat = readFileSync2(linkPath);
538
- if (!stat) {
539
- throw new Error("empty");
540
- }
541
- } catch {
542
- const script = `#!/bin/sh
543
- exec "${escapeShell(target)}" "$@"
544
- `;
545
- writeFileSync2(linkPath, script, { mode: 493 });
546
- chmodSync(linkPath, 493);
547
- }
548
- }
549
- function escapeShell(value) {
550
- return value.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("$", "\\$").replaceAll('"', '\\"');
551
- }
552
-
553
312
  // src/lib/mount-reconcile.ts
554
313
  function computeMountPlan(input) {
555
314
  const desiredHandles = new Set(input.desired.map((entry) => entry.handle));
@@ -599,14 +358,14 @@ function computeMountPlan(input) {
599
358
  pending: input.pending
600
359
  };
601
360
  }
602
- async function planMountReconcile(config, paths) {
603
- const computers = await listComputers();
361
+ async function planMountReconcile(config, paths, signal) {
362
+ const computers = await listComputers(signal);
604
363
  const desired = [];
605
364
  const pending = [];
606
365
  for (const computer of computers.filter(isSSHSelectable)) {
607
- const mountPath = join3(paths.rootPath, computer.handle);
366
+ const mountPath = join2(paths.rootPath, computer.handle);
608
367
  try {
609
- const info = await getConnectionInfo(computer.id);
368
+ const info = await getConnectionInfo(computer.id, signal);
610
369
  if (!info.connection.ssh_available) {
611
370
  pending.push({
612
371
  handle: computer.handle,
@@ -631,7 +390,8 @@ async function planMountReconcile(config, paths) {
631
390
  ready: true
632
391
  });
633
392
  }
634
- const ownedSessions = listOwnedSessions(config, paths);
393
+ throwIfAborted(signal);
394
+ const ownedSessions = await listOwnedSessions(config, paths, signal);
635
395
  const rootEntries = await listRootHandleDirectories(paths.rootPath);
636
396
  return {
637
397
  plan: computeMountPlan({
@@ -643,30 +403,38 @@ async function planMountReconcile(config, paths) {
643
403
  ownedSessions
644
404
  };
645
405
  }
646
- async function reconcileMounts(config, paths, controllerPid = process.pid) {
406
+ async function reconcileMounts(config, paths, controllerPid = process.pid, signal) {
647
407
  ensureMutagenSshEnvironment(config, paths);
648
- const { plan, ownedSessions } = await planMountReconcile(config, paths);
408
+ throwIfAborted(signal);
409
+ const { plan, ownedSessions } = await planMountReconcile(config, paths, signal);
649
410
  const mounts = [];
650
411
  const issues = [];
651
412
  const ownedByHandle = groupSessionsByHandle(ownedSessions);
652
413
  for (const handle of [...plan.toStop, ...plan.toReset]) {
414
+ throwIfAborted(signal);
653
415
  const sessions = ownedByHandle.get(handle) ?? [];
654
416
  try {
655
- terminateOwnedSessions(sessions, config, paths);
417
+ await terminateOwnedSessions(sessions, config, paths, signal);
656
418
  await removeHandleFromRoot(handle, paths);
657
419
  removeMountHandleState(handle, config.rootPath);
658
420
  } catch (error) {
421
+ if (isAbortError(error)) {
422
+ throw error;
423
+ }
659
424
  issues.push(
660
425
  error instanceof Error ? `${handle}: ${error.message}` : `${handle}: teardown failed`
661
426
  );
662
427
  }
663
428
  }
664
429
  for (const entry of plan.toCreate) {
430
+ throwIfAborted(signal);
665
431
  try {
666
- await ensureMountedHandle(entry, config, paths);
432
+ await ensureMountedHandle(entry, config, paths, signal);
667
433
  const session = selectPreferredSession(
668
434
  entry.handle,
669
- listOwnedSessions(config, paths).filter((candidate) => candidate.handle === entry.handle)
435
+ (await listOwnedSessions(config, paths, signal)).filter(
436
+ (candidate) => candidate.handle === entry.handle
437
+ )
670
438
  );
671
439
  const inspected = inspectSnapshotEntry(session, entry.mountPath);
672
440
  mounts.push(inspected.snapshot);
@@ -674,20 +442,29 @@ async function reconcileMounts(config, paths, controllerPid = process.pid) {
674
442
  issues.push(inspected.issue);
675
443
  }
676
444
  } catch (error) {
445
+ if (isAbortError(error)) {
446
+ throw error;
447
+ }
677
448
  const message = error instanceof Error ? error.message : "sync start failed";
678
449
  mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
679
450
  issues.push(`${entry.handle}: ${message}`);
680
451
  }
681
452
  }
682
- const postCreateSessions = groupSessionsByHandle(listOwnedSessions(config, paths));
453
+ throwIfAborted(signal);
454
+ const postCreateSessions = groupSessionsByHandle(
455
+ await listOwnedSessions(config, paths, signal)
456
+ );
683
457
  for (const entry of plan.toInspect) {
458
+ throwIfAborted(signal);
684
459
  try {
685
460
  const sessions = postCreateSessions.get(entry.handle) ?? [];
686
461
  if (sessions.length === 0) {
687
- await ensureMountedHandle(entry, config, paths);
462
+ await ensureMountedHandle(entry, config, paths, signal);
688
463
  const createdSession = selectPreferredSession(
689
464
  entry.handle,
690
- listOwnedSessions(config, paths).filter((candidate) => candidate.handle === entry.handle)
465
+ (await listOwnedSessions(config, paths, signal)).filter(
466
+ (candidate) => candidate.handle === entry.handle
467
+ )
691
468
  );
692
469
  const inspected2 = inspectSnapshotEntry(createdSession, entry.mountPath);
693
470
  mounts.push(inspected2.snapshot);
@@ -703,6 +480,9 @@ async function reconcileMounts(config, paths, controllerPid = process.pid) {
703
480
  issues.push(inspected.issue);
704
481
  }
705
482
  } catch (error) {
483
+ if (isAbortError(error)) {
484
+ throw error;
485
+ }
706
486
  const message = error instanceof Error ? error.message : "sync inspection failed";
707
487
  mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
708
488
  issues.push(`${entry.handle}: ${message}`);
@@ -718,6 +498,8 @@ async function reconcileMounts(config, paths, controllerPid = process.pid) {
718
498
  updatedAt: now,
719
499
  controllerPid,
720
500
  running: true,
501
+ startupPhase: "ready",
502
+ startupMessage: void 0,
721
503
  lastHealthySyncAt: healthy ? now : previousSnapshot?.lastHealthySyncAt,
722
504
  lastSuccessfulSyncAt: healthy ? now : previousSnapshot?.lastSuccessfulSyncAt,
723
505
  lastIssueAt: issues.length > 0 ? now : previousSnapshot?.lastIssueAt,
@@ -728,9 +510,10 @@ async function reconcileMounts(config, paths, controllerPid = process.pid) {
728
510
  writeMountStatusSnapshot(snapshot, config.rootPath);
729
511
  return snapshot;
730
512
  }
731
- async function teardownManagedSessions(config, paths) {
732
- const ownedSessions = listOwnedSessions(config, paths);
733
- terminateOwnedSessions(ownedSessions, config, paths);
513
+ async function teardownManagedSessions(config, paths, signal) {
514
+ throwIfAborted(signal);
515
+ const ownedSessions = await listOwnedSessions(config, paths, signal);
516
+ await terminateOwnedSessions(ownedSessions, config, paths, signal);
734
517
  const handles = new Set(ownedSessions.map((session) => session.handle));
735
518
  for (const handle of await listKnownHandleStates(paths)) {
736
519
  handles.add(handle);
@@ -743,9 +526,9 @@ async function teardownManagedSessions(config, paths) {
743
526
  }
744
527
  await rm(paths.rootPath, { recursive: true, force: true });
745
528
  }
746
- async function ensureMountedHandle(entry, config, paths) {
529
+ async function ensureMountedHandle(entry, config, paths, signal) {
747
530
  await mkdir(entry.mountPath, { recursive: true });
748
- createHandleSession(entry.handle, config, paths);
531
+ await createHandleSession(entry.handle, config, paths, signal);
749
532
  writeMountHandleMeta(
750
533
  entry.handle,
751
534
  {
@@ -825,13 +608,13 @@ function groupSessionsByHandle(sessions) {
825
608
  }
826
609
  return grouped;
827
610
  }
828
- function terminateOwnedSessions(sessions, config, paths) {
829
- for (const session of sessions) {
830
- terminateSession(session, config, paths);
831
- }
611
+ function terminateOwnedSessions(sessions, config, paths, signal) {
612
+ return Promise.all(
613
+ sessions.map((session) => terminateSession(session, config, paths, signal))
614
+ ).then(() => void 0);
832
615
  }
833
616
  async function removeHandleFromRoot(handle, paths) {
834
- await rm(join3(paths.rootPath, handle), { recursive: true, force: true });
617
+ await rm(join2(paths.rootPath, handle), { recursive: true, force: true });
835
618
  }
836
619
  async function listRootHandleDirectories(rootPath) {
837
620
  try {
@@ -847,6 +630,13 @@ async function listKnownHandleStates(paths) {
847
630
  return [];
848
631
  }
849
632
  }
633
+ function throwIfAborted(signal) {
634
+ if (signal?.aborted) {
635
+ const error = new Error("mount operation cancelled");
636
+ error.name = "AbortError";
637
+ throw error;
638
+ }
639
+ }
850
640
 
851
641
  export {
852
642
  getAPIKey,