aicomputer 0.1.18 → 0.1.20

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
@@ -93,10 +93,14 @@ computer claude-login
93
93
  computer codex-login
94
94
  computer whoami
95
95
  computer create my-box
96
+ computer power-off my-box
97
+ computer power-on my-box
96
98
  computer open my-box
97
99
  computer ssh
98
100
  computer ssh my-box
101
+ computer ssh my-box --tmux
99
102
  computer ssh --setup
103
+ computer ssh my-box -N -L 3000:localhost:3000
100
104
  computer mount
101
105
  computer mount --background
102
106
  computer mount status
@@ -112,6 +116,10 @@ Codex credentials onto a machine after the CLI is already logged in. Use
112
116
  `computer upgrade` to update a global npm install or the matching Nix profile
113
117
  entry.
114
118
 
119
+ Use `computer power-off` to stop a managed worker without deleting its durable
120
+ home volume. Use `computer power-on` to recreate the runtime against the same
121
+ stored machine and home state.
122
+
115
123
  Run `computer ssh` without a handle in an interactive terminal to pick from your available machines.
116
124
 
117
125
  Run `computer ssh --setup` once to register your SSH key and add a global alias:
@@ -121,6 +129,26 @@ ssh agentcomputer.ai
121
129
  ssh my-box@agentcomputer.ai
122
130
  ```
123
131
 
132
+ Use `computer ssh <handle> <ssh args>` when you want the CLI to resolve
133
+ the machine and identity while still passing standard SSH tunnel flags through
134
+ to the underlying client. Long-running SSH sessions and forwarded tunnels stay
135
+ on the same resilient path:
136
+
137
+ ```bash
138
+ computer ssh my-box -N -L 3000:localhost:3000
139
+ ```
140
+
141
+ `computer ssh` now launches through `autossh` by default. Nix installs package
142
+ `ssh`, `scp`, and `autossh` together. Global npm installs on macOS and Linux
143
+ provision Agent Computer's bundled `autossh` copy during postinstall and retry
144
+ that install on first SSH use if npm scripts were skipped. npm users still need
145
+ local OpenSSH client tools available.
146
+
147
+ Use `computer ssh <handle> --tmux` when you want reconnects to reattach to the
148
+ same remote shell session instead of dropping you into a fresh shell. The flag
149
+ attaches to `tmux new-session -A -s agentcomputer`, so it is meant for
150
+ interactive shells, not `-N` tunnels or other non-interactive SSH flows.
151
+
124
152
  Run `computer mount` to start the mount controller in the foreground and mirror
125
153
  all SSH-ready machine homes under `~/agentcomputer/<handle>`.
126
154
  On macOS, the foreground command also opens Finder to `~/agentcomputer` after
@@ -6,235 +6,22 @@ import {
6
6
  listOwnedSessions,
7
7
  selectPreferredSession,
8
8
  terminateSession
9
- } from "./chunk-F2U4SFJ4.js";
9
+ } from "./chunk-TPFE3CC6.js";
10
10
  import {
11
+ readMountHandleMeta,
11
12
  readMountStatusSnapshot,
12
13
  removeMountHandleState,
13
14
  writeMountHandleMeta,
14
15
  writeMountStatusSnapshot
15
16
  } from "./chunk-KXLTHWW3.js";
17
+ import {
18
+ getConnectionInfo,
19
+ listComputers
20
+ } from "./chunk-LOGK7YYJ.js";
16
21
 
17
22
  // src/lib/mount-reconcile.ts
18
23
  import { readdir, mkdir, rm } from "fs/promises";
19
- import { join as join2 } from "path";
20
-
21
- // src/lib/config.ts
22
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
23
- import { homedir } from "os";
24
24
  import { join } from "path";
25
- var CONFIG_DIR = join(homedir(), ".computer");
26
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
27
- function ensureConfigDir() {
28
- if (!existsSync(CONFIG_DIR)) {
29
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
30
- }
31
- }
32
- function readConfig() {
33
- ensureConfigDir();
34
- if (!existsSync(CONFIG_FILE)) {
35
- return {};
36
- }
37
- try {
38
- return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
39
- } catch {
40
- return {};
41
- }
42
- }
43
- function writeConfig(config) {
44
- ensureConfigDir();
45
- const tempFile = `${CONFIG_FILE}.${process.pid}.tmp`;
46
- writeFileSync(tempFile, JSON.stringify(config, null, 2), { mode: 384 });
47
- renameSync(tempFile, CONFIG_FILE);
48
- }
49
- function getAPIKey() {
50
- const envValue = process.env.COMPUTER_API_KEY ?? process.env.AGENTCOMPUTER_API_KEY;
51
- if (envValue) {
52
- return envValue.trim();
53
- }
54
- return getStoredAPIKey();
55
- }
56
- function getStoredAPIKey() {
57
- return readConfig().auth?.apiKey?.trim() || null;
58
- }
59
- function hasEnvAPIKey() {
60
- return Boolean(process.env.COMPUTER_API_KEY ?? process.env.AGENTCOMPUTER_API_KEY);
61
- }
62
- function setAPIKey(apiKey) {
63
- const config = readConfig();
64
- config.auth = { apiKey: apiKey.trim() };
65
- writeConfig(config);
66
- }
67
- function clearAPIKey() {
68
- const config = readConfig();
69
- delete config.auth;
70
- writeConfig(config);
71
- }
72
-
73
- // src/lib/api.ts
74
- var BASE_URL = process.env.COMPUTER_API_URL ?? process.env.AGENTCOMPUTER_API_URL ?? "https://api.computer.agentcomputer.ai";
75
- var WEB_URL = process.env.COMPUTER_WEB_URL ?? process.env.AGENTCOMPUTER_WEB_URL ?? resolveDefaultWebURL(BASE_URL);
76
- var ApiError = class extends Error {
77
- constructor(status, message) {
78
- super(message);
79
- this.status = status;
80
- this.name = "ApiError";
81
- }
82
- };
83
- function getBaseURL() {
84
- return BASE_URL;
85
- }
86
- function getWebURL() {
87
- return WEB_URL;
88
- }
89
- async function api(path, options = {}) {
90
- const apiKey = getAPIKey();
91
- if (!apiKey) {
92
- throw new ApiError(401, "not logged in; run 'computer login' first");
93
- }
94
- return requestWithKey(apiKey, path, options);
95
- }
96
- function resolveDefaultWebURL(apiURL) {
97
- try {
98
- const parsed = new URL(apiURL);
99
- if (parsed.hostname === "api.computer.agentcomputer.ai") {
100
- return "https://agentcomputer.ai";
101
- }
102
- if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
103
- return `${parsed.protocol}//${parsed.hostname}:3000`;
104
- }
105
- } catch {
106
- return "https://agentcomputer.ai";
107
- }
108
- return "https://agentcomputer.ai";
109
- }
110
- async function apiWithKey(apiKey, path, options = {}) {
111
- return requestWithKey(apiKey, path, options);
112
- }
113
- async function requestWithKey(apiKey, path, options) {
114
- const headers = {
115
- Accept: "application/json",
116
- ...options.headers ?? {}
117
- };
118
- if (options.body !== void 0) {
119
- headers["Content-Type"] = "application/json";
120
- }
121
- if (apiKey) {
122
- headers.Authorization = `Bearer ${apiKey}`;
123
- }
124
- const response = await fetch(`${BASE_URL}${path}`, {
125
- ...options,
126
- headers
127
- });
128
- if (!response.ok) {
129
- throw new ApiError(response.status, await readErrorMessage(response));
130
- }
131
- if (response.status === 204) {
132
- return void 0;
133
- }
134
- return await response.json();
135
- }
136
- async function readErrorMessage(response) {
137
- const contentType = response.headers.get("content-type") ?? "";
138
- if (contentType.includes("application/json")) {
139
- try {
140
- const payload = await response.json();
141
- if (payload.error) {
142
- return payload.error;
143
- }
144
- return JSON.stringify(payload);
145
- } catch {
146
- return response.statusText || "request failed";
147
- }
148
- }
149
- const body = await response.text();
150
- return body || response.statusText || "request failed";
151
- }
152
-
153
- // src/lib/computers.ts
154
- async function listComputers(signal) {
155
- const response = await api("/v1/computers", { signal });
156
- return response.computers;
157
- }
158
- async function getComputerByID(id) {
159
- return api(`/v1/computers/${id}`);
160
- }
161
- async function createComputer(input) {
162
- return api("/v1/computers", {
163
- method: "POST",
164
- body: JSON.stringify(input)
165
- });
166
- }
167
- async function deleteComputer(computerID) {
168
- return api(`/v1/computers/${computerID}`, {
169
- method: "DELETE"
170
- });
171
- }
172
- async function getFilesystemSettings() {
173
- return api("/v1/me/filesystem");
174
- }
175
- async function getConnectionInfo(computerID, signal) {
176
- return api(`/v1/computers/${computerID}/connection`, {
177
- signal
178
- });
179
- }
180
- async function createBrowserAccess(computerID) {
181
- return api(`/v1/computers/${computerID}/access/browser`, {
182
- method: "POST"
183
- });
184
- }
185
- async function listPublishedPorts(computerID) {
186
- const response = await api(`/v1/computers/${computerID}/ports`);
187
- return response.ports;
188
- }
189
- async function publishPort(computerID, input) {
190
- return api(`/v1/computers/${computerID}/ports`, {
191
- method: "POST",
192
- body: JSON.stringify(input)
193
- });
194
- }
195
- async function deletePublishedPort(computerID, targetPort) {
196
- return api(`/v1/computers/${computerID}/ports/${targetPort}`, {
197
- method: "DELETE"
198
- });
199
- }
200
- async function resolveComputer(identifier) {
201
- try {
202
- return await getComputerByID(identifier);
203
- } catch (error) {
204
- if (!(error instanceof Error) || !("status" in error)) {
205
- throw error;
206
- }
207
- const status = Reflect.get(error, "status");
208
- if (status !== 404) {
209
- throw error;
210
- }
211
- }
212
- const computers = await listComputers();
213
- const exact = computers.find(
214
- (computer) => computer.handle === identifier || computer.id === identifier
215
- );
216
- if (exact) {
217
- return exact;
218
- }
219
- throw new Error(`computer '${identifier}' not found`);
220
- }
221
- function webURL(computer) {
222
- return `https://${computer.primary_web_host}${normalizePrimaryPath(computer.primary_path)}`;
223
- }
224
- function vncURL(computer) {
225
- if (!computer.vnc_enabled) {
226
- return null;
227
- }
228
- const domain = computer.primary_web_host.replace(/^[^.]+\./, "");
229
- return `https://6080--${computer.handle}.${domain}`;
230
- }
231
- function normalizePrimaryPath(primaryPath) {
232
- const trimmed = primaryPath?.trim();
233
- if (!trimmed) {
234
- return "/";
235
- }
236
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
237
- }
238
25
 
239
26
  // src/lib/computer-picker.ts
240
27
  import { select } from "@inquirer/prompts";
@@ -363,7 +150,7 @@ async function planMountReconcile(config, paths, signal) {
363
150
  const desired = [];
364
151
  const pending = [];
365
152
  for (const computer of computers.filter(isSSHSelectable)) {
366
- const mountPath = join2(paths.rootPath, computer.handle);
153
+ const mountPath = join(paths.rootPath, computer.handle);
367
154
  try {
368
155
  const info = await getConnectionInfo(computer.id, signal);
369
156
  if (!info.connection.ssh_available) {
@@ -436,7 +223,7 @@ async function reconcileMounts(config, paths, controllerPid = process.pid, signa
436
223
  (candidate) => candidate.handle === entry.handle
437
224
  )
438
225
  );
439
- const inspected = inspectSnapshotEntry(session, entry.mountPath);
226
+ const inspected = inspectSnapshotEntry(session, entry.mountPath, config.rootPath);
440
227
  mounts.push(inspected.snapshot);
441
228
  if (inspected.issue) {
442
229
  issues.push(inspected.issue);
@@ -466,7 +253,11 @@ async function reconcileMounts(config, paths, controllerPid = process.pid, signa
466
253
  (candidate) => candidate.handle === entry.handle
467
254
  )
468
255
  );
469
- const inspected2 = inspectSnapshotEntry(createdSession, entry.mountPath);
256
+ const inspected2 = inspectSnapshotEntry(
257
+ createdSession,
258
+ entry.mountPath,
259
+ config.rootPath
260
+ );
470
261
  mounts.push(inspected2.snapshot);
471
262
  if (inspected2.issue) {
472
263
  issues.push(inspected2.issue);
@@ -474,7 +265,7 @@ async function reconcileMounts(config, paths, controllerPid = process.pid, signa
474
265
  continue;
475
266
  }
476
267
  const session = selectPreferredSession(entry.handle, sessions);
477
- const inspected = inspectSnapshotEntry(session, entry.mountPath);
268
+ const inspected = inspectSnapshotEntry(session, entry.mountPath, config.rootPath);
478
269
  mounts.push(inspected.snapshot);
479
270
  if (inspected.issue) {
480
271
  issues.push(inspected.issue);
@@ -494,12 +285,13 @@ async function reconcileMounts(config, paths, controllerPid = process.pid, signa
494
285
  const now = (/* @__PURE__ */ new Date()).toISOString();
495
286
  const previousSnapshot = readMountStatusSnapshot(config.rootPath);
496
287
  const healthy = mounts.every((mount) => mount.state === "mounted" || mount.state === "pending");
288
+ const startupInProgress = previousSnapshot?.startupPhase !== "ready" && mounts.some((mount) => mount.state === "syncing" || mount.state === "reconnecting");
497
289
  const snapshot = {
498
290
  updatedAt: now,
499
291
  controllerPid,
500
292
  running: true,
501
- startupPhase: "ready",
502
- startupMessage: void 0,
293
+ startupPhase: startupInProgress ? "syncing" : "ready",
294
+ startupMessage: startupInProgress ? createInitialSyncMessage(mounts) : void 0,
503
295
  lastHealthySyncAt: healthy ? now : previousSnapshot?.lastHealthySyncAt,
504
296
  lastSuccessfulSyncAt: healthy ? now : previousSnapshot?.lastSuccessfulSyncAt,
505
297
  lastIssueAt: issues.length > 0 ? now : previousSnapshot?.lastIssueAt,
@@ -540,8 +332,9 @@ async function ensureMountedHandle(entry, config, paths, signal) {
540
332
  config.rootPath
541
333
  );
542
334
  }
543
- function inspectSnapshotEntry(session, mountPath) {
335
+ function inspectSnapshotEntry(session, mountPath, rootPath) {
544
336
  const status = (session.status ?? "").toLowerCase();
337
+ const progressDetails = buildSyncProgressDetails(session, rootPath);
545
338
  const problemParts = [];
546
339
  if (session.conflictCount > 0) {
547
340
  problemParts.push(
@@ -563,36 +356,137 @@ function inspectSnapshotEntry(session, mountPath) {
563
356
  if (!session.alphaConnected) {
564
357
  const message = session.lastError ?? "local sync endpoint disconnected";
565
358
  return {
566
- snapshot: snapshotEntry(session.handle, mountPath, "error", message),
359
+ snapshot: snapshotEntry(session.handle, mountPath, "error", message, {
360
+ status: session.status,
361
+ progress: session.stagingProgress,
362
+ currentFile: session.currentFile
363
+ }),
567
364
  issue: `${session.handle}: ${message}`
568
365
  };
569
366
  }
570
367
  if (!session.betaConnected || status.includes("connecting") || status.includes("reconnect")) {
571
- const message = session.lastError ?? "reconnecting to remote machine";
368
+ const message = session.lastError ?? progressDetails.message ?? "connecting to remote machine";
572
369
  return {
573
- snapshot: snapshotEntry(session.handle, mountPath, "reconnecting", message),
574
- issue: `${session.handle}: ${message}`
370
+ snapshot: snapshotEntry(session.handle, mountPath, "reconnecting", message, {
371
+ status: session.status,
372
+ progress: session.stagingProgress,
373
+ currentFile: session.currentFile,
374
+ etaSeconds: progressDetails.etaSeconds
375
+ })
376
+ };
377
+ }
378
+ if (isMutagenSyncInProgress(status)) {
379
+ return {
380
+ snapshot: snapshotEntry(
381
+ session.handle,
382
+ mountPath,
383
+ "syncing",
384
+ progressDetails.message ?? session.status ?? "initial sync in progress",
385
+ {
386
+ status: session.status,
387
+ progress: session.stagingProgress,
388
+ currentFile: session.currentFile,
389
+ etaSeconds: progressDetails.etaSeconds
390
+ }
391
+ )
575
392
  };
576
393
  }
577
394
  if (session.lastError && !status.includes("watching")) {
578
395
  return {
579
- snapshot: snapshotEntry(session.handle, mountPath, "degraded", session.lastError),
396
+ snapshot: snapshotEntry(session.handle, mountPath, "degraded", session.lastError, {
397
+ status: session.status,
398
+ progress: session.stagingProgress,
399
+ currentFile: session.currentFile
400
+ }),
580
401
  issue: `${session.handle}: ${session.lastError}`
581
402
  };
582
403
  }
583
404
  return {
584
- snapshot: snapshotEntry(session.handle, mountPath, "mounted")
405
+ snapshot: snapshotEntry(session.handle, mountPath, "mounted", void 0, {
406
+ status: session.status
407
+ })
585
408
  };
586
409
  }
587
- function snapshotEntry(handle, mountPath, state, message) {
410
+ function snapshotEntry(handle, mountPath, state, message, extra = {}) {
588
411
  return {
589
412
  handle,
590
413
  mountPath,
591
414
  state,
592
415
  message,
593
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
416
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
417
+ ...extra
594
418
  };
595
419
  }
420
+ function isMutagenSyncInProgress(status) {
421
+ return [
422
+ "staging",
423
+ "scanning",
424
+ "reconciling",
425
+ "applying",
426
+ "saving",
427
+ "waiting"
428
+ ].some((phase) => status.includes(phase));
429
+ }
430
+ function buildSyncProgressDetails(session, rootPath) {
431
+ const details = [];
432
+ if (session.status) {
433
+ details.push(session.status);
434
+ }
435
+ if (session.stagingProgress) {
436
+ details.push(session.stagingProgress);
437
+ }
438
+ if (session.currentFile) {
439
+ details.push(session.currentFile);
440
+ }
441
+ const startedAt = readMountHandleMeta(session.handle, rootPath)?.lastStartedAt;
442
+ const etaSeconds = estimateEtaSeconds(startedAt, session.stagingProgress);
443
+ if (etaSeconds !== void 0) {
444
+ details.push(`eta ~${formatSeconds(etaSeconds)}`);
445
+ }
446
+ return {
447
+ message: details.length > 0 ? details.join("; ") : void 0,
448
+ etaSeconds
449
+ };
450
+ }
451
+ function estimateEtaSeconds(startedAt, stagingProgress) {
452
+ if (!startedAt || !stagingProgress) {
453
+ return void 0;
454
+ }
455
+ const match = stagingProgress.match(/(\d+)%/);
456
+ if (!match) {
457
+ return void 0;
458
+ }
459
+ const percent = Number.parseInt(match[1] ?? "", 10);
460
+ if (!Number.isFinite(percent) || percent <= 0 || percent >= 100) {
461
+ return void 0;
462
+ }
463
+ const elapsedSeconds = Math.max(
464
+ 0,
465
+ (Date.now() - new Date(startedAt).getTime()) / 1e3
466
+ );
467
+ if (!Number.isFinite(elapsedSeconds) || elapsedSeconds < 1) {
468
+ return void 0;
469
+ }
470
+ return Math.max(1, Math.round(elapsedSeconds * (100 - percent) / percent));
471
+ }
472
+ function formatSeconds(seconds) {
473
+ if (seconds < 60) {
474
+ return `${seconds}s`;
475
+ }
476
+ const minutes = Math.floor(seconds / 60);
477
+ const remainder = seconds % 60;
478
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
479
+ }
480
+ function createInitialSyncMessage(mounts) {
481
+ const actionable = mounts.filter((mount) => mount.state !== "pending");
482
+ const readyCount = actionable.filter((mount) => mount.state === "mounted").length;
483
+ const active = actionable.find((mount) => mount.state === "syncing") ?? actionable.find((mount) => mount.state === "reconnecting");
484
+ if (!active) {
485
+ return "Waiting for initial sync...";
486
+ }
487
+ const progressPrefix = actionable.length > 0 ? `${readyCount}/${actionable.length} ready; ` : "";
488
+ return `Waiting for initial sync... ${progressPrefix}${active.handle}: ${active.message ?? active.state}`;
489
+ }
596
490
  function sortSnapshots(mounts) {
597
491
  return mounts.slice().sort((left, right) => left.handle.localeCompare(right.handle));
598
492
  }
@@ -614,7 +508,7 @@ function terminateOwnedSessions(sessions, config, paths, signal) {
614
508
  ).then(() => void 0);
615
509
  }
616
510
  async function removeHandleFromRoot(handle, paths) {
617
- await rm(join2(paths.rootPath, handle), { recursive: true, force: true });
511
+ await rm(join(paths.rootPath, handle), { recursive: true, force: true });
618
512
  }
619
513
  async function listRootHandleDirectories(rootPath) {
620
514
  try {
@@ -639,29 +533,6 @@ function throwIfAborted(signal) {
639
533
  }
640
534
 
641
535
  export {
642
- getAPIKey,
643
- getStoredAPIKey,
644
- hasEnvAPIKey,
645
- setAPIKey,
646
- clearAPIKey,
647
- ApiError,
648
- getBaseURL,
649
- getWebURL,
650
- api,
651
- apiWithKey,
652
- listComputers,
653
- getComputerByID,
654
- createComputer,
655
- deleteComputer,
656
- getFilesystemSettings,
657
- getConnectionInfo,
658
- createBrowserAccess,
659
- listPublishedPorts,
660
- publishPort,
661
- deletePublishedPort,
662
- resolveComputer,
663
- webURL,
664
- vncURL,
665
536
  padEnd,
666
537
  timeAgo,
667
538
  formatStatus,