aicomputer 0.1.15 → 0.1.16

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
@@ -120,7 +120,8 @@ ssh my-box@agentcomputer.ai
120
120
  Run `computer mount` to start a foreground controller that mirrors all SSH-ready
121
121
  machine homes under `~/agentcomputer/<handle>` while the command is running.
122
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.
123
+ OpenSSH client tools (`ssh` and `scp`) to already be installed locally. The
124
+ temporary `~/agentcomputer` root is removed when the command exits.
124
125
 
125
126
  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.
126
127
 
@@ -1,15 +1,15 @@
1
1
  import {
2
+ MOUNT_SERVICE_LABEL,
2
3
  ensureHandleDirectories,
3
4
  ensureMountDirectories,
4
- getMountHandlePaths,
5
5
  readMountStatusSnapshot,
6
6
  removeMountHandleState,
7
7
  writeMountHandleMeta,
8
8
  writeMountStatusSnapshot
9
- } from "./chunk-5JVJROSI.js";
9
+ } from "./chunk-KXLTHWW3.js";
10
10
 
11
11
  // src/lib/mount-reconcile.ts
12
- import { readdir, mkdir, rename, rm } from "fs/promises";
12
+ import { readdir, mkdir, rm } from "fs/promises";
13
13
  import { join as join3 } from "path";
14
14
 
15
15
  // src/lib/config.ts
@@ -248,16 +248,6 @@ function timeAgo(dateStr) {
248
248
  const days = Math.floor(hours / 24);
249
249
  return `${days}d ago`;
250
250
  }
251
- function minuteSecondAgo(dateStr) {
252
- const timestamp = new Date(dateStr).getTime();
253
- if (!Number.isFinite(timestamp)) {
254
- return "unknown";
255
- }
256
- const totalSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1e3));
257
- const totalMinutes = Math.floor(totalSeconds / 60);
258
- const seconds = totalSeconds % 60;
259
- return `${totalMinutes}:${String(seconds).padStart(2, "0")} ago`;
260
- }
261
251
  function formatStatus(status) {
262
252
  switch (status) {
263
253
  case "running":
@@ -312,9 +302,15 @@ function describeSSHChoice(computer) {
312
302
  }
313
303
 
314
304
  // src/lib/mount-mutagen.ts
315
- import { chmodSync, existsSync as existsSync2, readFileSync as readFileSync2, symlinkSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
305
+ import { chmodSync, readFileSync as readFileSync2, symlinkSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
316
306
  import { spawnSync } from "child_process";
317
- import { basename, join as join2 } from "path";
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
+ ];
318
314
  function ensureMutagenSshEnvironment(config, paths) {
319
315
  ensureMountDirectories(paths);
320
316
  const sshPath = resolveCommandPath("ssh");
@@ -324,47 +320,174 @@ function ensureMutagenSshEnvironment(config, paths) {
324
320
  writeExecutableLink(paths.sshToolsDir, basename(sshPath), sshPath);
325
321
  writeExecutableLink(paths.sshToolsDir, basename(scpPath), scpPath);
326
322
  }
327
- function writeHandleProjectFile(handle, config, paths) {
328
- const handlePaths = ensureHandleDirectories(handle, config.rootPath);
329
- const content = [
330
- "sync:",
331
- " defaults:",
332
- " flushOnCreate: true",
333
- " mirror:",
334
- ` alpha: ${yamlString(join2(paths.rootPath, handle))}`,
335
- ` beta: ${yamlString(`${handle}@${config.alias}:/home/node`)}`,
336
- ""
337
- ].join("\n");
338
- writeFileSync2(handlePaths.projectFilePath, content, { mode: 384 });
339
- return handlePaths.projectFilePath;
340
- }
341
- function startHandleProject(handle, config, paths) {
342
- const handlePaths = getMountHandlePaths(handle, config.rootPath);
343
- runMutagen(["project", "start", "-f", handlePaths.projectFilePath], config, paths, handle);
344
- runMutagen(["project", "flush", "-f", handlePaths.projectFilePath], config, paths, handle);
345
- }
346
- function terminateHandleProject(handle, config, paths) {
347
- const handlePaths = getMountHandlePaths(handle, config.rootPath);
348
- if (!existsSync2(handlePaths.projectFilePath)) {
349
- return;
350
- }
351
- runMutagen(["project", "terminate", "-f", handlePaths.projectFilePath], config, paths, handle, true);
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
+ );
352
357
  }
353
- function inspectHandleProject(handle, config, paths) {
354
- const handlePaths = getMountHandlePaths(handle, config.rootPath);
355
- if (!existsSync2(handlePaths.projectFilePath)) {
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
+ }
356
377
  return {
357
- ok: false,
358
- stdout: "",
359
- stderr: "project file missing"
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))
360
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
+ }
361
485
  }
362
- return runMutagen(["project", "list", "-f", handlePaths.projectFilePath], config, paths, handle, true);
486
+ finishCurrent();
487
+ return sessions;
363
488
  }
364
- function cleanupKnownHandleProjects(config, paths, handles) {
365
- for (const handle of handles) {
366
- terminateHandleProject(handle, config, paths);
367
- }
489
+ function parseConnected(line) {
490
+ return line.slice("Connected: ".length).trim().toLowerCase() === "yes";
368
491
  }
369
492
  function runMutagen(args, config, paths, handle, ignoreFailure = false) {
370
493
  const result = spawnSync("mutagen", args, {
@@ -383,7 +506,8 @@ function runMutagen(args, config, paths, handle, ignoreFailure = false) {
383
506
  return {
384
507
  ok: result.status === 0,
385
508
  stdout,
386
- stderr
509
+ stderr,
510
+ status: result.status
387
511
  };
388
512
  }
389
513
  function resolveCommandPath(command) {
@@ -422,35 +546,41 @@ exec "${escapeShell(target)}" "$@"
422
546
  chmodSync(linkPath, 493);
423
547
  }
424
548
  }
425
- function yamlString(value) {
426
- return JSON.stringify(value);
427
- }
428
549
  function escapeShell(value) {
429
550
  return value.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("$", "\\$").replaceAll('"', '\\"');
430
551
  }
431
552
 
432
553
  // src/lib/mount-reconcile.ts
433
554
  function computeMountPlan(input) {
434
- const previousByHandle = new Map(
435
- (input.previousSnapshot?.mounts ?? []).map((mount) => [mount.handle, mount])
436
- );
437
- const desiredHandles = new Set(
438
- [...input.desired, ...input.pending].map((entry) => entry.handle)
439
- );
440
- const toStart = [];
441
- const toCheck = [];
442
- for (const entry of input.desired) {
443
- const previous = previousByHandle.get(entry.handle);
444
- if (previous?.state === "mounted") {
445
- toCheck.push(entry);
555
+ const desiredHandles = new Set(input.desired.map((entry) => entry.handle));
556
+ const sessionGroups = /* @__PURE__ */ new Map();
557
+ for (const session of input.ownedSessions) {
558
+ const group = sessionGroups.get(session.handle);
559
+ if (group) {
560
+ group.push(session);
446
561
  } else {
447
- toStart.push(entry);
562
+ sessionGroups.set(session.handle, [session]);
563
+ }
564
+ }
565
+ const toCreate = [];
566
+ const toInspect = [];
567
+ const toReset = /* @__PURE__ */ new Set();
568
+ for (const entry of input.desired) {
569
+ const sessions = sessionGroups.get(entry.handle) ?? [];
570
+ const reusable = sessions.length === 1 && !sessions[0].legacy;
571
+ if (reusable) {
572
+ toInspect.push(entry);
573
+ continue;
448
574
  }
575
+ if (sessions.length > 0) {
576
+ toReset.add(entry.handle);
577
+ }
578
+ toCreate.push(entry);
449
579
  }
450
580
  const toStop = /* @__PURE__ */ new Set();
451
- for (const mount of input.previousSnapshot?.mounts ?? []) {
452
- if (!desiredHandles.has(mount.handle)) {
453
- toStop.add(mount.handle);
581
+ for (const session of input.ownedSessions) {
582
+ if (!desiredHandles.has(session.handle)) {
583
+ toStop.add(session.handle);
454
584
  }
455
585
  }
456
586
  for (const entry of input.rootEntries) {
@@ -458,9 +588,13 @@ function computeMountPlan(input) {
458
588
  toStop.add(entry);
459
589
  }
460
590
  }
591
+ for (const handle of toReset) {
592
+ toStop.delete(handle);
593
+ }
461
594
  return {
462
- toStart,
463
- toCheck,
595
+ toCreate,
596
+ toInspect,
597
+ toReset: Array.from(toReset).sort(),
464
598
  toStop: Array.from(toStop).sort(),
465
599
  pending: input.pending
466
600
  };
@@ -497,52 +631,81 @@ async function planMountReconcile(config, paths) {
497
631
  ready: true
498
632
  });
499
633
  }
500
- const previousSnapshot = readMountStatusSnapshot(config.rootPath);
634
+ const ownedSessions = listOwnedSessions(config, paths);
501
635
  const rootEntries = await listRootHandleDirectories(paths.rootPath);
502
- return computeMountPlan({
503
- desired,
504
- pending,
505
- previousSnapshot,
506
- rootEntries
507
- });
636
+ return {
637
+ plan: computeMountPlan({
638
+ desired,
639
+ pending,
640
+ ownedSessions,
641
+ rootEntries
642
+ }),
643
+ ownedSessions
644
+ };
508
645
  }
509
646
  async function reconcileMounts(config, paths, controllerPid = process.pid) {
510
647
  ensureMutagenSshEnvironment(config, paths);
511
- const plan = await planMountReconcile(config, paths);
648
+ const { plan, ownedSessions } = await planMountReconcile(config, paths);
512
649
  const mounts = [];
513
- const errors = [];
514
- for (const handle of plan.toStop) {
650
+ const issues = [];
651
+ const ownedByHandle = groupSessionsByHandle(ownedSessions);
652
+ for (const handle of [...plan.toStop, ...plan.toReset]) {
653
+ const sessions = ownedByHandle.get(handle) ?? [];
515
654
  try {
516
- terminateHandleProject(handle, config, paths);
517
- await moveHandleOutOfRoot(handle, paths);
655
+ terminateOwnedSessions(sessions, config, paths);
656
+ await removeHandleFromRoot(handle, paths);
518
657
  removeMountHandleState(handle, config.rootPath);
519
658
  } catch (error) {
520
- errors.push(
659
+ issues.push(
521
660
  error instanceof Error ? `${handle}: ${error.message}` : `${handle}: teardown failed`
522
661
  );
523
662
  }
524
663
  }
525
- for (const entry of plan.toCheck) {
664
+ for (const entry of plan.toCreate) {
526
665
  try {
527
- const inspection = inspectHandleProject(entry.handle, config, paths);
528
- if (!inspection.ok) {
529
- await ensureMountedHandle(entry, config, paths);
666
+ await ensureMountedHandle(entry, config, paths);
667
+ const session = selectPreferredSession(
668
+ entry.handle,
669
+ listOwnedSessions(config, paths).filter((candidate) => candidate.handle === entry.handle)
670
+ );
671
+ const inspected = inspectSnapshotEntry(session, entry.mountPath);
672
+ mounts.push(inspected.snapshot);
673
+ if (inspected.issue) {
674
+ issues.push(inspected.issue);
530
675
  }
531
- mounts.push(snapshotEntry(entry.handle, entry.mountPath, "mounted"));
532
676
  } catch (error) {
533
- const message = error instanceof Error ? error.message : "sync check failed";
677
+ const message = error instanceof Error ? error.message : "sync start failed";
534
678
  mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
535
- errors.push(`${entry.handle}: ${message}`);
679
+ issues.push(`${entry.handle}: ${message}`);
536
680
  }
537
681
  }
538
- for (const entry of plan.toStart) {
682
+ const postCreateSessions = groupSessionsByHandle(listOwnedSessions(config, paths));
683
+ for (const entry of plan.toInspect) {
539
684
  try {
540
- await ensureMountedHandle(entry, config, paths);
541
- mounts.push(snapshotEntry(entry.handle, entry.mountPath, "mounted"));
685
+ const sessions = postCreateSessions.get(entry.handle) ?? [];
686
+ if (sessions.length === 0) {
687
+ await ensureMountedHandle(entry, config, paths);
688
+ const createdSession = selectPreferredSession(
689
+ entry.handle,
690
+ listOwnedSessions(config, paths).filter((candidate) => candidate.handle === entry.handle)
691
+ );
692
+ const inspected2 = inspectSnapshotEntry(createdSession, entry.mountPath);
693
+ mounts.push(inspected2.snapshot);
694
+ if (inspected2.issue) {
695
+ issues.push(inspected2.issue);
696
+ }
697
+ continue;
698
+ }
699
+ const session = selectPreferredSession(entry.handle, sessions);
700
+ const inspected = inspectSnapshotEntry(session, entry.mountPath);
701
+ mounts.push(inspected.snapshot);
702
+ if (inspected.issue) {
703
+ issues.push(inspected.issue);
704
+ }
542
705
  } catch (error) {
543
- const message = error instanceof Error ? error.message : "sync start failed";
706
+ const message = error instanceof Error ? error.message : "sync inspection failed";
544
707
  mounts.push(snapshotEntry(entry.handle, entry.mountPath, "error", message));
545
- errors.push(`${entry.handle}: ${message}`);
708
+ issues.push(`${entry.handle}: ${message}`);
546
709
  }
547
710
  }
548
711
  for (const entry of plan.pending) {
@@ -550,47 +713,93 @@ async function reconcileMounts(config, paths, controllerPid = process.pid) {
550
713
  }
551
714
  const now = (/* @__PURE__ */ new Date()).toISOString();
552
715
  const previousSnapshot = readMountStatusSnapshot(config.rootPath);
716
+ const healthy = mounts.every((mount) => mount.state === "mounted" || mount.state === "pending");
553
717
  const snapshot = {
554
718
  updatedAt: now,
555
719
  controllerPid,
556
720
  running: true,
557
- lastSuccessfulSyncAt: errors.length === 0 ? now : previousSnapshot?.lastSuccessfulSyncAt,
558
- lastError: errors[0],
721
+ lastHealthySyncAt: healthy ? now : previousSnapshot?.lastHealthySyncAt,
722
+ lastSuccessfulSyncAt: healthy ? now : previousSnapshot?.lastSuccessfulSyncAt,
723
+ lastIssueAt: issues.length > 0 ? now : previousSnapshot?.lastIssueAt,
724
+ lastIssue: issues[0],
725
+ lastError: mounts.some((mount) => mount.state === "error") ? issues[0] : void 0,
559
726
  mounts: sortSnapshots(mounts)
560
727
  };
561
728
  writeMountStatusSnapshot(snapshot, config.rootPath);
562
729
  return snapshot;
563
730
  }
564
731
  async function teardownManagedSessions(config, paths) {
565
- const handles = await listKnownHandleStates(paths);
566
- cleanupKnownHandleProjects(config, paths, handles);
732
+ const ownedSessions = listOwnedSessions(config, paths);
733
+ terminateOwnedSessions(ownedSessions, config, paths);
734
+ const handles = new Set(ownedSessions.map((session) => session.handle));
735
+ for (const handle of await listKnownHandleStates(paths)) {
736
+ handles.add(handle);
737
+ }
738
+ for (const handle of await listRootHandleDirectories(paths.rootPath)) {
739
+ handles.add(handle);
740
+ }
567
741
  for (const handle of handles) {
568
742
  removeMountHandleState(handle, config.rootPath);
569
743
  }
744
+ await rm(paths.rootPath, { recursive: true, force: true });
570
745
  }
571
746
  async function ensureMountedHandle(entry, config, paths) {
572
747
  await mkdir(entry.mountPath, { recursive: true });
573
- ensureHandleDirectories(entry.handle, config.rootPath);
574
- writeHandleProjectFile(entry.handle, config, paths);
575
- startHandleProject(entry.handle, config, paths);
748
+ createHandleSession(entry.handle, config, paths);
576
749
  writeMountHandleMeta(
577
750
  entry.handle,
578
751
  {
579
752
  handle: entry.handle,
753
+ sessionName: getHandleSessionName(entry.handle),
580
754
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
581
755
  lastStartedAt: (/* @__PURE__ */ new Date()).toISOString()
582
756
  },
583
757
  config.rootPath
584
758
  );
585
759
  }
586
- async function moveHandleOutOfRoot(handle, paths) {
587
- const sourcePath = join3(paths.rootPath, handle);
588
- const stalePath = join3(paths.staleDir, `${handle}-${Date.now()}`);
589
- try {
590
- await rename(sourcePath, stalePath);
591
- } catch {
592
- await rm(sourcePath, { recursive: true, force: true });
760
+ function inspectSnapshotEntry(session, mountPath) {
761
+ const status = (session.status ?? "").toLowerCase();
762
+ const problemParts = [];
763
+ if (session.conflictCount > 0) {
764
+ problemParts.push(
765
+ `${session.conflictCount} conflict${session.conflictCount === 1 ? "" : "s"}`
766
+ );
767
+ }
768
+ if (session.scanProblemCount > 0) {
769
+ problemParts.push(
770
+ `${session.scanProblemCount} scan problem${session.scanProblemCount === 1 ? "" : "s"}`
771
+ );
772
+ }
773
+ if (problemParts.length > 0) {
774
+ const message = problemParts.join(", ");
775
+ return {
776
+ snapshot: snapshotEntry(session.handle, mountPath, "degraded", message),
777
+ issue: `${session.handle}: ${message}`
778
+ };
779
+ }
780
+ if (!session.alphaConnected) {
781
+ const message = session.lastError ?? "local sync endpoint disconnected";
782
+ return {
783
+ snapshot: snapshotEntry(session.handle, mountPath, "error", message),
784
+ issue: `${session.handle}: ${message}`
785
+ };
786
+ }
787
+ if (!session.betaConnected || status.includes("connecting") || status.includes("reconnect")) {
788
+ const message = session.lastError ?? "reconnecting to remote machine";
789
+ return {
790
+ snapshot: snapshotEntry(session.handle, mountPath, "reconnecting", message),
791
+ issue: `${session.handle}: ${message}`
792
+ };
593
793
  }
794
+ if (session.lastError && !status.includes("watching")) {
795
+ return {
796
+ snapshot: snapshotEntry(session.handle, mountPath, "degraded", session.lastError),
797
+ issue: `${session.handle}: ${session.lastError}`
798
+ };
799
+ }
800
+ return {
801
+ snapshot: snapshotEntry(session.handle, mountPath, "mounted")
802
+ };
594
803
  }
595
804
  function snapshotEntry(handle, mountPath, state, message) {
596
805
  return {
@@ -604,6 +813,26 @@ function snapshotEntry(handle, mountPath, state, message) {
604
813
  function sortSnapshots(mounts) {
605
814
  return mounts.slice().sort((left, right) => left.handle.localeCompare(right.handle));
606
815
  }
816
+ function groupSessionsByHandle(sessions) {
817
+ const grouped = /* @__PURE__ */ new Map();
818
+ for (const session of sessions) {
819
+ const group = grouped.get(session.handle);
820
+ if (group) {
821
+ group.push(session);
822
+ } else {
823
+ grouped.set(session.handle, [session]);
824
+ }
825
+ }
826
+ return grouped;
827
+ }
828
+ function terminateOwnedSessions(sessions, config, paths) {
829
+ for (const session of sessions) {
830
+ terminateSession(session, config, paths);
831
+ }
832
+ }
833
+ async function removeHandleFromRoot(handle, paths) {
834
+ await rm(join3(paths.rootPath, handle), { recursive: true, force: true });
835
+ }
607
836
  async function listRootHandleDirectories(rootPath) {
608
837
  try {
609
838
  return (await readdir(rootPath, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
@@ -645,7 +874,6 @@ export {
645
874
  vncURL,
646
875
  padEnd,
647
876
  timeAgo,
648
- minuteSecondAgo,
649
877
  formatStatus,
650
878
  promptForSSHComputer,
651
879
  computeMountPlan,
@@ -35,7 +35,6 @@ function getMountHandlePaths(handle, rootPath = getDefaultMountRoot()) {
35
35
  const stateDir = join(paths.handlesDir, handle);
36
36
  return {
37
37
  stateDir,
38
- projectFilePath: join(stateDir, "mutagen.yml"),
39
38
  metaPath: join(stateDir, "meta.json"),
40
39
  sshToolsDir: join(stateDir, "ssh-tools")
41
40
  };
@@ -53,7 +52,6 @@ function defaultMountServiceConfig() {
53
52
  function ensureMountDirectories(paths) {
54
53
  for (const directory of [
55
54
  paths.stateDir,
56
- paths.rootPath,
57
55
  paths.handlesDir,
58
56
  paths.sshToolsDir,
59
57
  paths.staleDir
package/dist/index.js CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  hasEnvAPIKey,
24
24
  listComputers,
25
25
  listPublishedPorts,
26
- minuteSecondAgo,
27
26
  padEnd,
28
27
  promptForSSHComputer,
29
28
  publishPort,
@@ -34,7 +33,7 @@ import {
34
33
  timeAgo,
35
34
  vncURL,
36
35
  webURL
37
- } from "./chunk-KQQUR2YX.js";
36
+ } from "./chunk-5IEWKH52.js";
38
37
  import {
39
38
  defaultMountServiceConfig,
40
39
  ensureMountDirectories,
@@ -46,7 +45,7 @@ import {
46
45
  writeMountConfig,
47
46
  writeMountControllerLock,
48
47
  writeMountStatusSnapshot
49
- } from "./chunk-5JVJROSI.js";
48
+ } from "./chunk-KXLTHWW3.js";
50
49
 
51
50
  // src/index.ts
52
51
  import { Command as Command14 } from "commander";
@@ -4046,7 +4045,10 @@ async function runMountDaemon(config) {
4046
4045
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4047
4046
  controllerPid: process.pid,
4048
4047
  running: true,
4048
+ lastHealthySyncAt: previous?.lastHealthySyncAt,
4049
4049
  lastSuccessfulSyncAt: previous?.lastSuccessfulSyncAt,
4050
+ lastIssueAt: (/* @__PURE__ */ new Date()).toISOString(),
4051
+ lastIssue: error instanceof Error ? error.message : "mount reconcile failed",
4050
4052
  lastError: error instanceof Error ? error.message : "mount reconcile failed",
4051
4053
  mounts: previous?.mounts ?? []
4052
4054
  },
@@ -4094,7 +4096,10 @@ async function runMountDaemon(config) {
4094
4096
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4095
4097
  controllerPid: previous?.controllerPid,
4096
4098
  running: false,
4099
+ lastHealthySyncAt: previous?.lastHealthySyncAt,
4097
4100
  lastSuccessfulSyncAt: previous?.lastSuccessfulSyncAt,
4101
+ lastIssueAt: previous?.lastIssueAt,
4102
+ lastIssue: void 0,
4098
4103
  lastError: void 0,
4099
4104
  mounts: []
4100
4105
  },
@@ -4197,24 +4202,24 @@ var mountCommand = new Command10("mount").description("Mirror SSH-ready machines
4197
4202
  console.log(` ${chalk8.dim("Root")} ${config.rootPath}`);
4198
4203
  console.log(` ${chalk8.dim("Alias")} ${config.alias}`);
4199
4204
  console.log(
4200
- ` ${chalk8.dim("Updated")} ${snapshot?.updatedAt ? minuteSecondAgo(snapshot.updatedAt) : chalk8.dim("never")}`
4205
+ ` ${chalk8.dim("Updated")} ${snapshot?.updatedAt ? timeAgo(snapshot.updatedAt) : chalk8.dim("never")}`
4201
4206
  );
4202
4207
  console.log(
4203
- ` ${chalk8.dim("Last sync")} ${snapshot?.lastSuccessfulSyncAt ? minuteSecondAgo(snapshot.lastSuccessfulSyncAt) : chalk8.dim("never")}`
4208
+ ` ${chalk8.dim("Healthy")} ${snapshot?.lastHealthySyncAt ? timeAgo(snapshot.lastHealthySyncAt) : chalk8.dim("never")}`
4204
4209
  );
4205
4210
  if (controller.running && snapshot?.mounts.length) {
4206
4211
  console.log();
4207
4212
  for (const mount of snapshot.mounts) {
4208
- const state = mount.state === "mounted" ? chalk8.green(mount.state) : mount.state === "pending" ? chalk8.yellow(mount.state) : chalk8.red(mount.state);
4213
+ const state = mount.state === "mounted" ? chalk8.green(mount.state) : mount.state === "reconnecting" ? chalk8.cyan(mount.state) : mount.state === "degraded" ? chalk8.yellow(mount.state) : mount.state === "pending" ? chalk8.yellow(mount.state) : chalk8.red(mount.state);
4209
4214
  console.log(` ${chalk8.white(mount.handle)} ${state} ${chalk8.dim(mount.mountPath)}`);
4210
4215
  if (mount.message) {
4211
4216
  console.log(` ${chalk8.dim(mount.message)}`);
4212
4217
  }
4213
4218
  }
4214
4219
  }
4215
- if (snapshot?.lastError) {
4220
+ if (snapshot?.lastIssue) {
4216
4221
  console.log();
4217
- console.log(` ${chalk8.dim("Last error")} ${chalk8.red(snapshot.lastError)}`);
4222
+ console.log(` ${chalk8.dim("Last issue")} ${chalk8.yellow(snapshot.lastIssue)}`);
4218
4223
  }
4219
4224
  console.log();
4220
4225
  })
@@ -10,7 +10,7 @@ interface MountServiceConfig {
10
10
  interface ManagedMountSnapshot {
11
11
  handle: string;
12
12
  mountPath: string;
13
- state: "mounted" | "pending" | "error";
13
+ state: "mounted" | "reconnecting" | "degraded" | "pending" | "error";
14
14
  message?: string;
15
15
  updatedAt: string;
16
16
  }
@@ -18,7 +18,10 @@ interface MountStatusSnapshot {
18
18
  updatedAt: string;
19
19
  controllerPid?: number;
20
20
  running: boolean;
21
+ lastHealthySyncAt?: string;
21
22
  lastSuccessfulSyncAt?: string;
23
+ lastIssueAt?: string;
24
+ lastIssue?: string;
22
25
  lastError?: string;
23
26
  mounts: ManagedMountSnapshot[];
24
27
  }
@@ -35,12 +38,12 @@ interface MountPaths {
35
38
  }
36
39
  interface MountHandlePaths {
37
40
  stateDir: string;
38
- projectFilePath: string;
39
41
  metaPath: string;
40
42
  sshToolsDir: string;
41
43
  }
42
44
  interface MountHandleMeta {
43
45
  handle: string;
46
+ sessionName?: string;
44
47
  startedAt: string;
45
48
  lastStartedAt?: string;
46
49
  }
@@ -17,7 +17,7 @@ import {
17
17
  writeMountControllerLock,
18
18
  writeMountHandleMeta,
19
19
  writeMountStatusSnapshot
20
- } from "../chunk-5JVJROSI.js";
20
+ } from "../chunk-KXLTHWW3.js";
21
21
  export {
22
22
  MOUNT_SERVICE_LABEL,
23
23
  defaultMountServiceConfig,
@@ -1,4 +1,19 @@
1
- import { MountStatusSnapshot, MountServiceConfig, MountPaths } from './mount-config.js';
1
+ import { MountServiceConfig, MountPaths, MountStatusSnapshot } from './mount-config.js';
2
+
3
+ interface OwnedMountSession {
4
+ identifier: string;
5
+ name?: string;
6
+ handle: string;
7
+ alphaPath: string;
8
+ betaUrl?: string;
9
+ alphaConnected: boolean;
10
+ betaConnected: boolean;
11
+ status?: string;
12
+ lastError?: string;
13
+ scanProblemCount: number;
14
+ conflictCount: number;
15
+ legacy: boolean;
16
+ }
2
17
 
3
18
  interface DesiredMount {
4
19
  handle: string;
@@ -7,18 +22,22 @@ interface DesiredMount {
7
22
  message?: string;
8
23
  }
9
24
  interface MountPlan {
10
- toStart: DesiredMount[];
11
- toCheck: DesiredMount[];
25
+ toCreate: DesiredMount[];
26
+ toInspect: DesiredMount[];
27
+ toReset: string[];
12
28
  toStop: string[];
13
29
  pending: DesiredMount[];
14
30
  }
15
31
  declare function computeMountPlan(input: {
16
32
  desired: DesiredMount[];
17
33
  pending: DesiredMount[];
18
- previousSnapshot: MountStatusSnapshot | null;
34
+ ownedSessions: Pick<OwnedMountSession, "handle" | "legacy">[];
19
35
  rootEntries: string[];
20
36
  }): MountPlan;
21
- declare function planMountReconcile(config: MountServiceConfig, paths: MountPaths): Promise<MountPlan>;
37
+ declare function planMountReconcile(config: MountServiceConfig, paths: MountPaths): Promise<{
38
+ plan: MountPlan;
39
+ ownedSessions: OwnedMountSession[];
40
+ }>;
22
41
  declare function reconcileMounts(config: MountServiceConfig, paths: MountPaths, controllerPid?: number): Promise<MountStatusSnapshot>;
23
42
  declare function teardownManagedSessions(config: MountServiceConfig, paths: MountPaths): Promise<void>;
24
43
 
@@ -3,8 +3,8 @@ import {
3
3
  planMountReconcile,
4
4
  reconcileMounts,
5
5
  teardownManagedSessions
6
- } from "../chunk-KQQUR2YX.js";
7
- import "../chunk-5JVJROSI.js";
6
+ } from "../chunk-5IEWKH52.js";
7
+ import "../chunk-KXLTHWW3.js";
8
8
  export {
9
9
  computeMountPlan,
10
10
  planMountReconcile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicomputer",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Computer CLI - manage your Agent Computer machines from the terminal",
5
5
  "homepage": "https://agentcomputer.ai",
6
6
  "repository": {