@saadnvd1/agent-os 0.3.810 → 0.3.817

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 (59) hide show
  1. package/README.md +3 -2
  2. package/app/api/files/upload-temp/route.test.ts +55 -3
  3. package/app/api/files/upload-temp/route.ts +15 -10
  4. package/app/api/reach/probe/route.test.ts +22 -0
  5. package/app/api/reach/probe/route.ts +12 -0
  6. package/app/api/reach/route.ts +30 -0
  7. package/app/api/sessions/list-size.test.ts +77 -0
  8. package/app/api/sessions/route.ts +21 -10
  9. package/app/api/tasks/list-size.test.ts +50 -0
  10. package/app/api/tasks/route.ts +20 -3
  11. package/components/Chat/FileChip.tsx +8 -4
  12. package/components/Chat/ViewSwitch.tsx +2 -1
  13. package/components/Chat/composer/paste.ts +5 -2
  14. package/components/Chat/composer/useAttachFiles.ts +8 -5
  15. package/components/Devices/NetworkSection.tsx +2 -0
  16. package/components/Pane/index.tsx +4 -2
  17. package/components/Reach/ReachRow.tsx +137 -0
  18. package/components/Settings/SetupSection.tsx +2 -0
  19. package/components/Welcome/ConnectBlock.tsx +2 -0
  20. package/content/tips.json +10 -0
  21. package/content/whats-new.json +40 -0
  22. package/data/devices/queries.ts +6 -1
  23. package/data/reach/index.ts +33 -0
  24. package/lib/attachments/check.ts +2 -5
  25. package/lib/attachments/har.ts +122 -0
  26. package/lib/attachments/message.ts +24 -2
  27. package/lib/attachments/policy.ts +104 -9
  28. package/lib/attachments/prepare.ts +100 -0
  29. package/lib/attachments/store.ts +7 -1
  30. package/lib/config/client-config-types.ts +2 -0
  31. package/lib/connect/serve.ts +22 -3
  32. package/lib/connect/supervise.ts +33 -9
  33. package/lib/db/queries.ts +31 -4
  34. package/lib/db/types.ts +3 -1
  35. package/lib/guides.ts +6 -0
  36. package/lib/hosts/remote-menu.ts +4 -2
  37. package/lib/reach/check-cli.ts +117 -0
  38. package/lib/reach/checkReach.ts +62 -0
  39. package/lib/reach/classify.ts +283 -0
  40. package/lib/reach/cli.ts +38 -0
  41. package/lib/reach/gatherFacts.ts +167 -0
  42. package/lib/reach/index.ts +4 -0
  43. package/lib/reach/probeConnect.ts +122 -0
  44. package/lib/reach/types.ts +96 -0
  45. package/lib/security/auth.ts +2 -0
  46. package/lib/security/demo.ts +2 -0
  47. package/lib/sessions/has-prompt.ts +7 -0
  48. package/lib/sessions/omit-null.ts +8 -0
  49. package/lib/tasks/contract/views.ts +12 -0
  50. package/lib/tasks/index.ts +9 -5
  51. package/lib/tasks/session.ts +15 -1
  52. package/lib/tips/view.ts +2 -4
  53. package/package.json +1 -1
  54. package/scripts/agent-os +3 -0
  55. package/scripts/install.sh +12 -1
  56. package/scripts/lib/commands.sh +59 -65
  57. package/scripts/lib/service.sh +230 -0
  58. package/scripts/lib/setup.sh +303 -0
  59. package/scripts/lib/setup.test.ts +389 -0
@@ -0,0 +1,100 @@
1
+ import { execFile } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { promisify } from "util";
6
+ import { redactHar } from "./har";
7
+ import {
8
+ extOf,
9
+ isHeic,
10
+ type AttachmentKind,
11
+ type AttachmentType,
12
+ } from "./policy";
13
+
14
+ const run = promisify(execFile);
15
+
16
+ export type Prepared =
17
+ | {
18
+ ok: true;
19
+ data: Buffer;
20
+ name: string;
21
+ mediaType: string;
22
+ kind: AttachmentKind;
23
+ // Saved beside the file and given to the agent with its path.
24
+ summary?: string;
25
+ }
26
+ | { ok: false; status: 400; error: string };
27
+
28
+ export type Converter = (cmd: string, args: string[]) => Promise<unknown>;
29
+
30
+ const realConverter: Converter = (cmd, args) =>
31
+ run(cmd, args, { timeout: 30_000 });
32
+
33
+ // sips ships with macOS; ImageMagick and libheif cover Linux.
34
+ const TOOLS = (src: string, out: string): [string, string[]][] => [
35
+ ["sips", ["-s", "format", "jpeg", src, "--out", out]],
36
+ ["magick", [src, out]],
37
+ ["heif-convert", [src, out]],
38
+ ];
39
+
40
+ /** A JPEG of an iPhone photo, or null when this machine has no converter. */
41
+ export async function heicToJpeg(
42
+ data: Buffer,
43
+ convert: Converter = realConverter
44
+ ): Promise<Buffer | null> {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-heic-"));
46
+ try {
47
+ const src = path.join(dir, "in.heic");
48
+ const out = path.join(dir, "out.jpg");
49
+ fs.writeFileSync(src, data);
50
+ for (const [cmd, args] of TOOLS(src, out)) {
51
+ try {
52
+ await convert(cmd, args);
53
+ if (fs.existsSync(out)) return fs.readFileSync(out);
54
+ } catch {
55
+ // Not installed or failed: the next tool.
56
+ }
57
+ }
58
+ return null;
59
+ } finally {
60
+ fs.rmSync(dir, { recursive: true, force: true });
61
+ }
62
+ }
63
+
64
+ /** What is stored for a checked upload: secrets removed, photos converted. */
65
+ export async function prepareUpload(
66
+ type: AttachmentType,
67
+ name: string,
68
+ data: Buffer,
69
+ convert?: Converter
70
+ ): Promise<Prepared> {
71
+ const keep: Prepared = {
72
+ ok: true,
73
+ data,
74
+ name,
75
+ mediaType: type.mediaType,
76
+ kind: type.kind,
77
+ };
78
+ if (type.ext === "har") {
79
+ const har = redactHar(data.toString("utf8"));
80
+ if (!har)
81
+ return {
82
+ ok: false,
83
+ status: 400,
84
+ error: `${name} doesn't look like a real HAR file.`,
85
+ };
86
+ return { ...keep, data: Buffer.from(har.json), summary: har.summary };
87
+ }
88
+ if (isHeic(name)) {
89
+ const jpeg = await heicToJpeg(data, convert);
90
+ if (!jpeg) return keep;
91
+ const stem = name.slice(0, name.length - extOf(name).length - 1);
92
+ return {
93
+ ...keep,
94
+ data: jpeg,
95
+ name: `${stem}.jpg`,
96
+ mediaType: "image/jpeg",
97
+ };
98
+ }
99
+ return keep;
100
+ }
@@ -28,6 +28,10 @@ export function safeName(name: string, taken: Set<string>, n: number): string {
28
28
  return out;
29
29
  }
30
30
 
31
+ // A short description kept beside a big file (a HAR's hosts and failures),
32
+ // which the agent is given with the path.
33
+ export const summaryPath = (file: string) => `${file}.summary.txt`;
34
+
31
35
  export function scopeDir(scope: string): string {
32
36
  return path.join(attachmentsRoot(), segment(scope) || "new");
33
37
  }
@@ -36,7 +40,8 @@ export function saveAttachment(
36
40
  scope: string,
37
41
  name: string,
38
42
  data: Buffer,
39
- mediaType: string
43
+ mediaType: string,
44
+ summary?: string
40
45
  ): ChatFile {
41
46
  const dir = scopeDir(scope);
42
47
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -44,6 +49,7 @@ export function saveAttachment(
44
49
  const file = safeName(name, taken, taken.size + 1);
45
50
  const full = path.join(dir, file);
46
51
  fs.writeFileSync(full, data, { mode: 0o600 });
52
+ if (summary) fs.writeFileSync(summaryPath(full), summary, { mode: 0o600 });
47
53
  return { path: full, name: file, size: data.length, mediaType };
48
54
  }
49
55
 
@@ -36,12 +36,14 @@ export interface ClientConfig {
36
36
  export interface AttachmentConfig {
37
37
  max: number;
38
38
  maxBytes: number;
39
+ maxTextBytes: number;
39
40
  maxImageBytes: number;
40
41
  pdfInlineBytes: number;
41
42
  types: {
42
43
  ext: string;
43
44
  mediaType: string;
44
45
  kind: "image" | "pdf" | "sheet" | "doc" | "text";
46
+ note?: string;
45
47
  }[];
46
48
  }
47
49
 
@@ -46,7 +46,9 @@ import {
46
46
  reapTunnel,
47
47
  superviseTunnel,
48
48
  type ReapDeps,
49
+ atStage,
49
50
  type Spawn,
51
+ type Stage,
50
52
  type TunnelState,
51
53
  } from "./supervise";
52
54
 
@@ -61,6 +63,10 @@ export interface ConnectStatus {
61
63
  enrolled?: boolean;
62
64
  hostname?: string;
63
65
  reason?: string;
66
+ /** Where the last failure came from: getting the connector, or the service. */
67
+ stage?: Stage;
68
+ /** While not up: when it went down or started trying (ms since epoch). */
69
+ downSince?: number;
64
70
  }
65
71
 
66
72
  const g = globalThis as unknown as { __agentosConnect?: () => ConnectStatus };
@@ -160,6 +166,8 @@ interface Running {
160
166
  supervisor: ReturnType<typeof superviseTunnel> | null;
161
167
  heartbeat: ReturnType<typeof startHeartbeat>;
162
168
  release: () => void;
169
+ /** When this run started, for how long it has been trying. */
170
+ since: number;
163
171
  }
164
172
 
165
173
  export function startConnect(
@@ -183,11 +191,18 @@ export function startConnect(
183
191
  g.__agentosConnect = () => {
184
192
  if (!running) return base;
185
193
  const s: TunnelState = running.supervisor?.state() ?? "connecting";
194
+ const state = bindError ? "error" : s === "stopped" ? "off" : s;
186
195
  return {
187
196
  ...base,
188
197
  hostname: hostname || base.hostname,
189
- state: bindError ? "error" : s === "stopped" ? "off" : s,
198
+ state,
190
199
  reason: bindError ?? running.supervisor?.reason(),
200
+ stage: bindError ? undefined : running.supervisor?.stage(),
201
+ // Up has no down clock: the probe through the tunnel keeps its own.
202
+ downSince:
203
+ state === "up"
204
+ ? undefined
205
+ : (running.supervisor?.downSince() ?? running.since),
191
206
  };
192
207
  };
193
208
 
@@ -281,9 +296,12 @@ export function startConnect(
281
296
  spawn: deps.spawn,
282
297
  delays: deps.delays,
283
298
  prepare: async () => {
284
- const bin = await ensure(dir);
299
+ const bin = await atStage("connector", ensure(dir));
285
300
  // Fresh every start; the token lives only in this call and the child's env.
286
- const tunnel = await ask(config, machineKey, tunnelPort);
301
+ const tunnel = await atStage(
302
+ "service",
303
+ ask(config, machineKey, tunnelPort)
304
+ );
287
305
  if (tunnel.hostname !== hostname) {
288
306
  hostname = tunnel.hostname;
289
307
  saveConnect({ ...config, hostname }, dir);
@@ -350,6 +368,7 @@ export function startConnect(
350
368
  supervisor: null,
351
369
  heartbeat,
352
370
  release: lock.release,
371
+ since: Date.now(),
353
372
  };
354
373
  server.listen(tunnelPort, "127.0.0.1");
355
374
  };
@@ -24,6 +24,17 @@ import type { Readable, Writable } from "stream";
24
24
  import { ApiError, clean } from "./control-api";
25
25
 
26
26
  export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
27
+ /** Where a failure came from: getting the connector, or the Connect service. */
28
+ export type Stage = "connector" | "service";
29
+
30
+ /** Tags an error with the stage it came from, for the supervisor to keep. */
31
+ export function atStage<T>(stage: Stage, work: Promise<T>): Promise<T> {
32
+ return work.catch((err) => {
33
+ throw Object.assign(err instanceof Error ? err : new Error(String(err)), {
34
+ stage,
35
+ });
36
+ });
37
+ }
27
38
 
28
39
  export interface Prepared {
29
40
  bin: string;
@@ -178,6 +189,13 @@ export function superviseTunnel(opts: SuperviseOptions) {
178
189
 
179
190
  let state: TunnelState = "connecting";
180
191
  let reason: string | undefined;
192
+ let stage: Stage | undefined;
193
+ // When it last left "up", or started trying: how long it has been down.
194
+ let downSince = Date.now();
195
+ const set = (next: TunnelState) => {
196
+ if (state === "up" && next !== "up") downSince = Date.now();
197
+ state = next;
198
+ };
181
199
  let stopped = false;
182
200
  let child: Child | null = null;
183
201
  let timer: NodeJS.Timeout | null = null;
@@ -203,13 +221,13 @@ export function superviseTunnel(opts: SuperviseOptions) {
203
221
  // Nothing here may reject: an unhandled rejection takes AgentOS down.
204
222
  const tick = () =>
205
223
  void attempt().catch(() => {
206
- state = "error";
224
+ set("error");
207
225
  retry(delays.max);
208
226
  });
209
227
 
210
228
  const attempt = async () => {
211
229
  if (stopped) return;
212
- state = "connecting";
230
+ set("connecting");
213
231
  try {
214
232
  const prepared = await opts.prepare();
215
233
  if (stopped) return;
@@ -218,13 +236,15 @@ export function superviseTunnel(opts: SuperviseOptions) {
218
236
  if (stopped) return;
219
237
  const status = err instanceof ApiError ? err.status : 0;
220
238
  reason = clean((err as Error)?.message ?? String(err));
239
+ stage =
240
+ (err as { stage?: Stage })?.stage ?? (status ? "service" : undefined);
221
241
  log(`connect: ${reason}`);
222
242
  if (status === 401 || status === 403) {
223
243
  // Revoked or disabled: it won't fix itself in seconds.
224
- state = "denied";
244
+ set("denied");
225
245
  retry(delays.denied);
226
246
  } else {
227
- state = "error";
247
+ set("error");
228
248
  retry(delay);
229
249
  delay = Math.min(delay * 2, delays.setupMax);
230
250
  }
@@ -265,8 +285,9 @@ export function superviseTunnel(opts: SuperviseOptions) {
265
285
  }
266
286
  );
267
287
  } catch (err) {
268
- state = "error";
288
+ set("error");
269
289
  reason = `cloudflared could not start: ${redact((err as Error).message)}`;
290
+ stage = "connector";
270
291
  log(`connect: ${reason}`);
271
292
  retry(delay);
272
293
  delay = Math.min(delay * 2, delays.max);
@@ -298,8 +319,9 @@ export function superviseTunnel(opts: SuperviseOptions) {
298
319
  if (message.startsWith("Registered tunnel connection")) {
299
320
  connections.add(conn);
300
321
  if (state !== "up") log("connect: tunnel up");
301
- state = "up";
322
+ set("up");
302
323
  reason = undefined;
324
+ stage = undefined;
303
325
  delay = delays.min;
304
326
  } else if (
305
327
  message.startsWith("Unregistered tunnel connection") ||
@@ -307,7 +329,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
307
329
  entry.connIndex !== undefined)
308
330
  ) {
309
331
  connections.delete(conn);
310
- if (state === "up" && connections.size === 0) state = "connecting";
332
+ if (state === "up" && connections.size === 0) set("connecting");
311
333
  }
312
334
  if (["warn", "error", "fatal"].includes(entry.level ?? "")) {
313
335
  const text = entry.error ? `${message}: ${entry.error}` : message;
@@ -332,7 +354,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
332
354
  fs.rmSync(pidFile, { force: true });
333
355
  } catch {}
334
356
  if (stopped) return;
335
- state = "connecting";
357
+ set("connecting");
336
358
  reason = why;
337
359
  log(`connect: ${why}; starting it again`);
338
360
  retry(delay);
@@ -361,10 +383,12 @@ export function superviseTunnel(opts: SuperviseOptions) {
361
383
  return {
362
384
  state: () => state,
363
385
  reason: () => reason,
386
+ stage: () => stage,
387
+ downSince: () => (state === "up" ? undefined : downSince),
364
388
  stop() {
365
389
  if (stopped) return;
366
390
  stopped = true;
367
- state = "stopped";
391
+ set("stopped");
368
392
  process.removeListener("exit", killNow);
369
393
  for (const s of SIGNALS) process.removeListener(s, onSignal);
370
394
  if (timer) clearTimeout(timer);
package/lib/db/queries.ts CHANGED
@@ -14,11 +14,21 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
14
14
  return stmt;
15
15
  }
16
16
 
17
- // Every session column but the two large text ones, which only the single
18
- // session paths read; a list gets has_brief for whether a brief exists.
17
+ // Every session column but the large text ones, which only the single
18
+ // session paths read; a list gets has_brief and has_prompt for whether a
19
+ // brief or task prompt exists. orch_token is internal and never listed.
20
+ // task_prompt stays as its first 80 characters: phone app builds already
21
+ // installed tell a task from a chat by !!task_prompt on the list.
19
22
  // Read from session_rows (lib/db/session-parts).
23
+ const LEFT_OUT_OF_LISTS = new Set([
24
+ "task_brief",
25
+ "system_prompt",
26
+ "task_prompt",
27
+ "chat_context",
28
+ "orch_token",
29
+ ]);
20
30
  const LIST_COLUMNS = SESSION_ROW_COLUMNS.filter(
21
- (n) => n !== "task_brief" && n !== "system_prompt"
31
+ (n) => !LEFT_OUT_OF_LISTS.has(n)
22
32
  );
23
33
  export function sessionListColumns(_db: Database.Database, alias = ""): string {
24
34
  const prefix = alias ? `${alias}.` : "";
@@ -26,7 +36,7 @@ export function sessionListColumns(_db: Database.Database, alias = ""): string {
26
36
  // The recipe run or feature piece a session is, as JSON, for its row's chip.
27
37
  const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "session_rows"}.id)`;
28
38
  const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "session_rows"}.id)`;
29
- return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
39
+ return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, (${prefix}task_prompt IS NOT NULL AND ${prefix}task_prompt != '') AS has_prompt, substr(${prefix}task_prompt, 1, 80) AS task_prompt, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
30
40
  }
31
41
 
32
42
  export const queries = {
@@ -60,6 +70,23 @@ export const queries = {
60
70
  ORDER BY updated_at DESC`
61
71
  ),
62
72
 
73
+ // The sidebar again with the Done shelf cut to its most recent finished
74
+ // tasks (limit), for the list a browser refetches on every write.
75
+ getSidebarSessionsCapped: (db: Database.Database, limit: number) =>
76
+ getStmt(
77
+ db,
78
+ `SELECT ${sessionListColumns(db)} FROM session_rows
79
+ WHERE archived_at IS NULL
80
+ AND (task_status IS NULL
81
+ OR task_status NOT IN ('dropped', 'moved'))
82
+ AND (task_status IS NOT 'merged' OR id IN (
83
+ SELECT id FROM session_rows
84
+ WHERE archived_at IS NULL AND task_status = 'merged'
85
+ ORDER BY COALESCE(NULLIF(settled_at, ''), NULLIF(merged_at, ''), updated_at) DESC
86
+ LIMIT ${Math.max(0, Math.floor(limit))}))
87
+ ORDER BY updated_at DESC`
88
+ ),
89
+
63
90
  // The tmux names AgentOS owns, for telling discovered sessions apart.
64
91
  getManagedTmuxNames: (db: Database.Database) =>
65
92
  getStmt(
package/lib/db/types.ts CHANGED
@@ -38,7 +38,7 @@ export interface Session {
38
38
  auto_approve: boolean;
39
39
  host_id: string;
40
40
  // Async tasks: a session started from a prompt that ends in a PR
41
- task_prompt: string | null;
41
+ task_prompt?: string | null;
42
42
  // done: finished without a merge (no PR, or its PR closed).
43
43
  // moving: handing it to another machine (moved_to); moved: handed over.
44
44
  task_status:
@@ -53,6 +53,8 @@ export interface Session {
53
53
  task_brief?: string | null;
54
54
  // Lists carry this instead of the brief itself (0/1).
55
55
  has_brief?: number;
56
+ // Likewise for the task prompt (0/1): lists leave the text out.
57
+ has_prompt?: number;
56
58
  // JSON, in lists: the recipe run this session is ({id, name, seq}), and the
57
59
  // feature piece it builds ({feature, name, position, total}).
58
60
  recipe_run?: string | null;
package/lib/guides.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Where the user guides (docs/guides/*.md) are read on the web. */
2
+ export const GUIDES =
3
+ "https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
4
+
5
+ export const guideUrl = (g: { file: string; anchor?: string }) =>
6
+ `${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
@@ -5,6 +5,7 @@
5
5
  * pin, select, done and delete all work: the ones that change the session
6
6
  * run on its machine.
7
7
  */
8
+ import { hasPrompt } from "@/lib/sessions/has-prompt";
8
9
 
9
10
  export const REMOTE_UNSUPPORTED = {
10
11
  fork: (host: string) => `Runs on ${host}: fork it there`,
@@ -26,12 +27,13 @@ export type RemoteAction = keyof typeof REMOTE_UNSUPPORTED;
26
27
  export function remoteBlocks(
27
28
  session: {
28
29
  host_id: string | null;
29
- task_prompt: string | null;
30
+ task_prompt?: string | null;
31
+ has_prompt?: number;
30
32
  peer_mirror?: number;
31
33
  },
32
34
  linkedHostName: (hostId: string) => string | undefined
33
35
  ): Record<RemoteAction, string> | null {
34
- if (!session.peer_mirror || !session.host_id || session.task_prompt)
36
+ if (!session.peer_mirror || !session.host_id || hasPrompt(session))
35
37
  return null;
36
38
  const host = linkedHostName(session.host_id);
37
39
  if (!host) return null;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * agent-os check [--json] [--via connect|tailscale|lan]: asks the server for
3
+ * its reachability report. When the server can't answer, it looks at who is
4
+ * on the port before naming a cause: AgentOS stuck, another program, or
5
+ * nothing at all.
6
+ */
7
+
8
+ import { finding } from "./classify";
9
+ import type { ReachFinding, ReachReport, ReachRoute } from "./types";
10
+
11
+ export interface CheckCliDeps {
12
+ base: string;
13
+ fetch: typeof fetch;
14
+ out: (line: string) => void;
15
+ /** The program listening on the port, from lsof, or null. */
16
+ holder: () => Promise<string | null>;
17
+ /** Whether a launchd/systemd service for AgentOS is installed. */
18
+ serviceInstalled: () => boolean;
19
+ now?: () => number;
20
+ reportMs?: number;
21
+ probeMs?: number;
22
+ }
23
+
24
+ const ROUTES = ["connect", "tailscale", "lan"] as const;
25
+ const PROGRAM = /^[A-Za-z0-9._-]{1,32}$/;
26
+
27
+ /** Exit code: 0 reachable, 1 a warning, 2 not reachable. */
28
+ export const exitCode = (r: ReachReport) =>
29
+ ({ ok: 0, warn: 1, fail: 2 })[r.status];
30
+
31
+ async function report(
32
+ deps: CheckCliDeps,
33
+ want: ReachRoute[]
34
+ ): Promise<ReachReport | null> {
35
+ const q = new URLSearchParams({ fresh: "" });
36
+ if (want.length) q.set("want", want.join(","));
37
+ try {
38
+ const res = await deps.fetch(`${deps.base}/api/reach?${q}`, {
39
+ signal: AbortSignal.timeout(deps.reportMs ?? 20_000),
40
+ });
41
+ if (!res.ok) return null;
42
+ return (await res.json()) as ReachReport;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** The server didn't report: who, if anyone, is on its port? */
49
+ export async function whoIsOnThePort(
50
+ deps: CheckCliDeps
51
+ ): Promise<ReachFinding> {
52
+ let answered = false;
53
+ let silent = false;
54
+ try {
55
+ const res = await deps.fetch(`${deps.base}/api/reach/probe`, {
56
+ signal: AbortSignal.timeout(deps.probeMs ?? 2_000),
57
+ });
58
+ answered = true;
59
+ const body = (await res.json().catch(() => null)) as {
60
+ boot?: unknown;
61
+ } | null;
62
+ if (res.ok && typeof body?.boot === "string")
63
+ return finding("service-stuck", "fail");
64
+ } catch (err) {
65
+ const e = err as { name?: string; cause?: { code?: string } };
66
+ if (!answered && e.cause?.code === "ECONNREFUSED")
67
+ return finding("service-down", "fail", {
68
+ alt: deps.serviceInstalled() ? undefined : "no-service",
69
+ });
70
+ silent = !answered && e.name === "TimeoutError";
71
+ }
72
+ const program = await deps.holder().catch(() => null);
73
+ // A blocked server takes the connection and never answers, probe included.
74
+ if (silent && program === "node") return finding("service-stuck", "fail");
75
+ // Something holds the port but isn't AgentOS (or doesn't speak HTTP).
76
+ return finding("port-taken", "fail", {
77
+ detail: program && PROGRAM.test(program) ? program : undefined,
78
+ });
79
+ }
80
+
81
+ const MARK = { ok: "✓", warn: "!", fail: "✗" } as const;
82
+
83
+ export function printReport(r: ReachReport, out: (line: string) => void) {
84
+ for (const f of r.findings) {
85
+ const head = f.url ? `${f.title}: ${f.url}` : f.title;
86
+ out(`${MARK[f.status]} ${head}`);
87
+ if (f.status === "ok") continue;
88
+ if (f.needs === "nothing") out(" Retrying on its own, nothing to do");
89
+ else if (f.fix) out(` → ${f.fix}`);
90
+ if (f.guide && f.needs === "you") out(` How to fix: ${f.guide}`);
91
+ }
92
+ if (r.status !== "ok" && r.url) out(`\nWorking link: ${r.url}`);
93
+ }
94
+
95
+ export async function runCheckCli(
96
+ args: string[],
97
+ deps: CheckCliDeps
98
+ ): Promise<number> {
99
+ const json = args.includes("--json");
100
+ const i = args.findIndex((a) => a === "--via" || a.startsWith("--via="));
101
+ const via = i < 0 ? undefined : (args[i].split("=")[1] ?? args[i + 1]);
102
+ const want = i < 0 ? [] : ROUTES.filter((r) => r === via);
103
+ if (i >= 0 && !want.length) {
104
+ deps.out(`--via takes one of: ${ROUTES.join(", ")}`);
105
+ return 2;
106
+ }
107
+ const r =
108
+ (await report(deps, want)) ??
109
+ ({
110
+ status: "fail",
111
+ findings: [await whoIsOnThePort(deps)],
112
+ checkedAt: new Date(deps.now?.() ?? Date.now()).toISOString(),
113
+ } satisfies ReachReport);
114
+ if (json) deps.out(JSON.stringify(r, null, 2));
115
+ else printReport(r, deps.out);
116
+ return exitCode(r);
117
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * gather + classify. The facts are cached: the Connect probe goes out to the
3
+ * internet, so a burst of callers shares one, and a forced fresh check runs
4
+ * at most every 5s.
5
+ */
6
+
7
+ import { classifyReach } from "./classify";
8
+ import { gatherFacts, type GatherDeps } from "./gatherFacts";
9
+ import type { ReachFacts, ReachReport, ReachRoute } from "./types";
10
+
11
+ const CACHE_MS = 30_000;
12
+ const FRESH_MIN_MS = 5_000;
13
+
14
+ let cached: { at: number; facts: ReachFacts } | null = null;
15
+ let inflight: Promise<ReachFacts> | null = null;
16
+ // The tunnel can be up while the probe through it fails: that failure gets
17
+ // its own clock, so it turns into "yours" after a while too.
18
+ let probeBadSince: number | null = null;
19
+
20
+ function ageProbe(f: ReachFacts): ReachFacts {
21
+ if (f.connect.state !== "up" || !f.probe || f.probe.kind === "ok") {
22
+ probeBadSince = null;
23
+ return f;
24
+ }
25
+ probeBadSince ??= f.now;
26
+ return {
27
+ ...f,
28
+ connect: { ...f.connect, downSince: f.connect.downSince ?? probeBadSince },
29
+ };
30
+ }
31
+
32
+ async function facts(fresh: boolean, deps?: GatherDeps): Promise<ReachFacts> {
33
+ if (inflight) return inflight;
34
+ const age = cached ? Date.now() - cached.at : Infinity;
35
+ if (cached && age < (fresh ? FRESH_MIN_MS : CACHE_MS)) return cached.facts;
36
+ inflight = gatherFacts(deps)
37
+ .then((gathered) => {
38
+ const f = ageProbe(gathered);
39
+ cached = { at: Date.now(), facts: f };
40
+ return f;
41
+ })
42
+ .finally(() => {
43
+ inflight = null;
44
+ });
45
+ return inflight;
46
+ }
47
+
48
+ /** `want`: routes the caller means to use, reported even when not set up. */
49
+ export async function checkReach(
50
+ fresh = false,
51
+ want: ReachRoute[] = [],
52
+ deps?: GatherDeps
53
+ ): Promise<ReachReport> {
54
+ return classifyReach(await facts(fresh, deps), want);
55
+ }
56
+
57
+ /** For tests. */
58
+ export function resetReachCache() {
59
+ cached = null;
60
+ inflight = null;
61
+ probeBadSince = null;
62
+ }