@saadnvd1/agent-os 0.3.810 → 0.3.815

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 (47) hide show
  1. package/README.md +3 -2
  2. package/app/api/reach/probe/route.test.ts +22 -0
  3. package/app/api/reach/probe/route.ts +12 -0
  4. package/app/api/reach/route.ts +30 -0
  5. package/app/api/sessions/list-size.test.ts +77 -0
  6. package/app/api/sessions/route.ts +21 -10
  7. package/app/api/tasks/list-size.test.ts +50 -0
  8. package/app/api/tasks/route.ts +20 -3
  9. package/components/Chat/ViewSwitch.tsx +2 -1
  10. package/components/Devices/NetworkSection.tsx +2 -0
  11. package/components/Pane/index.tsx +4 -2
  12. package/components/Reach/ReachRow.tsx +137 -0
  13. package/components/Settings/SetupSection.tsx +2 -0
  14. package/components/Welcome/ConnectBlock.tsx +2 -0
  15. package/content/tips.json +10 -0
  16. package/content/whats-new.json +32 -0
  17. package/data/devices/queries.ts +6 -1
  18. package/data/reach/index.ts +33 -0
  19. package/lib/connect/serve.ts +22 -3
  20. package/lib/connect/supervise.ts +33 -9
  21. package/lib/db/queries.ts +31 -4
  22. package/lib/db/types.ts +3 -1
  23. package/lib/guides.ts +6 -0
  24. package/lib/hosts/remote-menu.ts +4 -2
  25. package/lib/reach/check-cli.ts +117 -0
  26. package/lib/reach/checkReach.ts +62 -0
  27. package/lib/reach/classify.ts +283 -0
  28. package/lib/reach/cli.ts +38 -0
  29. package/lib/reach/gatherFacts.ts +167 -0
  30. package/lib/reach/index.ts +4 -0
  31. package/lib/reach/probeConnect.ts +122 -0
  32. package/lib/reach/types.ts +96 -0
  33. package/lib/security/auth.ts +2 -0
  34. package/lib/security/demo.ts +2 -0
  35. package/lib/sessions/has-prompt.ts +7 -0
  36. package/lib/sessions/omit-null.ts +8 -0
  37. package/lib/tasks/contract/views.ts +12 -0
  38. package/lib/tasks/index.ts +9 -5
  39. package/lib/tasks/session.ts +15 -1
  40. package/lib/tips/view.ts +2 -4
  41. package/package.json +1 -1
  42. package/scripts/agent-os +3 -0
  43. package/scripts/install.sh +12 -1
  44. package/scripts/lib/commands.sh +59 -65
  45. package/scripts/lib/service.sh +230 -0
  46. package/scripts/lib/setup.sh +303 -0
  47. package/scripts/lib/setup.test.ts +389 -0
package/README.md CHANGED
@@ -55,16 +55,17 @@ You need Node.js 22+, git, tmux, and at least one agent CLI (the installer offer
55
55
  ```bash
56
56
  npm install -g @saadnvd1/agent-os
57
57
  agent-os install # checks for tmux and ripgrep, installs what's missing, builds the app
58
- agent-os start # runs in the background on http://localhost:3011
58
+ agent-os setup # keeps it running, chooses how your phone reaches it, adds the phone
59
59
  ```
60
60
 
61
61
  Without Node.js, the install script sets up everything:
62
62
 
63
63
  ```bash
64
64
  curl -fsSL https://raw.githubusercontent.com/saadnvd1/agent-os/main/scripts/install.sh | bash
65
- agent-os start
66
65
  ```
67
66
 
67
+ It ends by offering `agent-os setup`. If another device can't reach the machine later, `agent-os check` names the cause and the fix ([guide](docs/guides/reachability.md)).
68
+
68
69
  Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](docs/guides/install.md) covers Windows (WSL2), the supported agents, first run and the `agent-os` commands. To use it from your phone, see the [phone guide](docs/guides/phone.md).
69
70
 
70
71
  ## Connect
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GET } from "./route";
3
+
4
+ // Public before pairing: it may only ever say "AgentOS, this boot".
5
+ describe("GET /api/reach/probe", () => {
6
+ it("answers the same two fields to everyone, all boot long, uncached", async () => {
7
+ const a = GET();
8
+ const b = GET();
9
+ const body = await a.json();
10
+ expect(Object.keys(body).sort()).toEqual(["boot", "ok"]);
11
+ expect(body).toEqual({
12
+ ok: true,
13
+ boot: expect.stringMatching(/^[0-9a-f]{16}$/),
14
+ });
15
+ expect(await b.json()).toEqual(body);
16
+ expect(a.headers.get("cache-control")).toBe("no-store");
17
+ });
18
+
19
+ it("takes no input", () => {
20
+ expect(GET.length).toBe(0);
21
+ });
22
+ });
@@ -0,0 +1,12 @@
1
+ import { NextResponse } from "next/server";
2
+ import { bootId } from "@/lib/reach/probeConnect";
3
+
4
+ // GET /api/reach/probe - public: the same answer for anyone, all boot long.
5
+ // This machine fetches it through its own Connect address to prove the
6
+ // answer came from this process, not a cache or another AgentOS.
7
+ export function GET() {
8
+ return NextResponse.json(
9
+ { ok: true, boot: bootId() },
10
+ { headers: { "Cache-Control": "no-store" } }
11
+ );
12
+ }
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { checkReach } from "@/lib/reach/checkReach";
3
+ import { classifyReach } from "@/lib/reach/classify";
4
+ import { demoMode } from "@/lib/security/demo";
5
+ import type { ReachRoute } from "@/lib/reach/types";
6
+
7
+ const ROUTES = new Set<string>(["connect", "tailscale", "lan"]);
8
+
9
+ // GET /api/reach[?fresh][&want=lan,connect] - can other devices reach this
10
+ // machine, and if not, why. `want` adds routes the caller means to use.
11
+ export async function GET(request: NextRequest) {
12
+ if (demoMode()) return NextResponse.json(demoReach());
13
+ const params = request.nextUrl.searchParams;
14
+ const want = (params.get("want") ?? "")
15
+ .split(",")
16
+ .filter((r): r is ReachRoute => ROUTES.has(r));
17
+ return NextResponse.json(await checkReach(params.has("fresh"), want));
18
+ }
19
+
20
+ // Nothing about the machine a demo runs on.
21
+ function demoReach() {
22
+ return classifyReach({
23
+ now: Date.now(),
24
+ port: 3011,
25
+ lan: { on: false, addresses: [] },
26
+ firewall: "unknown",
27
+ tailscale: { state: "missing" },
28
+ connect: { state: "off", linked: false, enabled: false },
29
+ });
30
+ }
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from "crypto";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+
4
+ const { db, insertSessionRow } = await import("@/lib/db");
5
+ const { GET } = await import("./route");
6
+
7
+ type Row = Record<string, unknown> & { id: string };
8
+
9
+ function seed(extra: Record<string, unknown> = {}): string {
10
+ const id = randomUUID();
11
+ insertSessionRow(db, {
12
+ id,
13
+ name: "n",
14
+ tmux_name: `claude-${id}`,
15
+ working_directory: "/tmp",
16
+ ...extra,
17
+ });
18
+ return id;
19
+ }
20
+
21
+ const list = async (query = "") => {
22
+ const res = await GET({
23
+ nextUrl: new URL(`http://x/api/sessions${query}`),
24
+ } as never);
25
+ return (await res.json()).sessions as Row[];
26
+ };
27
+
28
+ beforeEach(() => {
29
+ db.prepare(`DELETE FROM sessions`).run();
30
+ });
31
+
32
+ describe("GET /api/sessions payload", () => {
33
+ it("never carries the full prompt, the chat context or the orchestrator token", async () => {
34
+ const id = seed({
35
+ task_status: "running",
36
+ task_prompt: "x".repeat(5000),
37
+ chat_context: "ctx",
38
+ orch_token: "secret-token",
39
+ });
40
+ const row = (await list()).find((r) => r.id === id)!;
41
+ // Installed phone builds read !!task_prompt, so a short prefix stays.
42
+ expect(row.task_prompt).toBe("x".repeat(80));
43
+ expect(row).not.toHaveProperty("chat_context");
44
+ expect(row).not.toHaveProperty("orch_token");
45
+ expect(row.has_prompt).toBe(1);
46
+ });
47
+
48
+ it("says has_prompt 0 for a plain session", async () => {
49
+ const id = seed();
50
+ expect((await list()).find((r) => r.id === id)!.has_prompt).toBe(0);
51
+ });
52
+
53
+ it("leaves null fields out", async () => {
54
+ const id = seed();
55
+ const row = (await list()).find((r) => r.id === id)!;
56
+ expect(Object.values(row)).not.toContain(null);
57
+ expect(row).not.toHaveProperty("pr_url");
58
+ });
59
+
60
+ it("lists the 100 most recent merged tasks, all with ?done=all", async () => {
61
+ for (let i = 0; i < 103; i++) {
62
+ const day = String(i + 1).padStart(3, "0");
63
+ seed({
64
+ task_status: "merged",
65
+ merged_at: `2026-01-01 00:00:${i % 60}`.replace(/:(\d)$/, ":0$1"),
66
+ updated_at: `2026-02-01 00:00:00`,
67
+ name: `done-${day}`,
68
+ });
69
+ }
70
+ const live = seed({ task_status: "running" });
71
+ const capped = await list();
72
+ expect(capped.filter((r) => r.task_status === "merged")).toHaveLength(100);
73
+ expect(capped.map((r) => r.id)).toContain(live);
74
+ const all = await list("?done=all");
75
+ expect(all.filter((r) => r.task_status === "merged")).toHaveLength(103);
76
+ });
77
+ });
@@ -10,9 +10,14 @@ import { ModelUnavailableError } from "@/lib/models/agent-binding";
10
10
  import { namesLine, waitBadges } from "@/lib/bus/waits/store";
11
11
  import { claimsSnapshot } from "@/lib/claims";
12
12
  import { isProjectRef } from "@/lib/tasks/project-ref";
13
+ import { omitNulls } from "@/lib/sessions/omit-null";
14
+
15
+ // The sidebar's Done shelf lists this many finished tasks; ?done=all lists
16
+ // every one.
17
+ const DONE_LISTED = 100;
13
18
 
14
19
  // GET /api/sessions - List the sidebar's sessions and the user's groups
15
- export async function GET() {
20
+ export async function GET(request?: NextRequest) {
16
21
  try {
17
22
  const db = getDb();
18
23
  // Merged tasks are listed (the sidebar files them under Done). Dropped
@@ -20,12 +25,18 @@ export async function GET() {
20
25
  // and archived sessions in the Archived view: none is in the sidebar.
21
26
  const badges = waitBadges();
22
27
  const claims = claimsSnapshot();
23
- const sessions = (queries.getSidebarSessions(db).all() as Session[]).map(
24
- (s) => {
25
- const b = badges.get(s.id);
26
- const overlap = claims.bySession.get(s.id);
27
- const row = overlap ? { ...s, overlap } : s;
28
- return b
28
+ const all = request?.nextUrl.searchParams.get("done") === "all";
29
+ const rows = (
30
+ all
31
+ ? queries.getSidebarSessions(db).all()
32
+ : queries.getSidebarSessionsCapped(db, DONE_LISTED).all()
33
+ ) as Session[];
34
+ const sessions = rows.map((s) => {
35
+ const b = badges.get(s.id);
36
+ const overlap = claims.bySession.get(s.id);
37
+ const row = overlap ? { ...s, overlap } : s;
38
+ return omitNulls(
39
+ b
29
40
  ? {
30
41
  ...row,
31
42
  waiting_on: b.on.length
@@ -35,9 +46,9 @@ export async function GET() {
35
46
  ? namesLine(b.by.map((w) => w.name))
36
47
  : null,
37
48
  }
38
- : row;
39
- }
40
- );
49
+ : row
50
+ );
51
+ });
41
52
  return NextResponse.json({
42
53
  sessions,
43
54
  sessionGroups: listGroups(),
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ const { db, insertSessionRow } = await import("@/lib/db");
5
+ const { GET } = await import("./route");
6
+
7
+ describe("GET /api/tasks payload", () => {
8
+ it("lists hasPrompt, not the prompt text, and leaves null fields out", async () => {
9
+ const id = randomUUID();
10
+ insertSessionRow(db, {
11
+ id,
12
+ name: "t",
13
+ tmux_name: `claude-${id}`,
14
+ working_directory: "/tmp",
15
+ task_status: "running",
16
+ task_prompt: "p".repeat(5000),
17
+ });
18
+ const { tasks } = await (await GET()).json();
19
+ const t = tasks.find((x: { id: string }) => x.id === id);
20
+ expect(t.hasPrompt).toBe(true);
21
+ expect(t).not.toHaveProperty("prompt");
22
+ expect(JSON.stringify(t)).not.toContain("ppppp");
23
+ expect(Object.values(t)).not.toContain(null);
24
+ });
25
+ });
26
+
27
+ describe("taskSessions with a merged limit", () => {
28
+ it("keeps the most recently merged ones and every unmerged one", async () => {
29
+ const { taskSessions } = await import("@/lib/tasks/session");
30
+ const add = (status: string, merged_at: string | null) => {
31
+ const id = randomUUID();
32
+ insertSessionRow(db, {
33
+ id,
34
+ name: "t",
35
+ tmux_name: `claude-${id}`,
36
+ working_directory: "/tmp",
37
+ task_status: status,
38
+ merged_at,
39
+ });
40
+ return id;
41
+ };
42
+ const oldest = add("merged", "2020-01-01 00:00:00");
43
+ const newer = add("merged", "2020-01-03 00:00:00");
44
+ const newest = add("merged", "2020-01-04 00:00:00");
45
+ const running = add("running", null);
46
+ const ids = taskSessions(false, 2).map((s) => s.id);
47
+ expect(ids).toEqual(expect.arrayContaining([newer, newest, running]));
48
+ expect(ids).not.toContain(oldest);
49
+ });
50
+ });
@@ -1,6 +1,6 @@
1
1
  import { parseOverride } from "@/lib/models/resolve";
2
2
  import { NextRequest, NextResponse } from "next/server";
3
- import { createTask, listTasks, movedTasks } from "@/lib/tasks";
3
+ import { createTask, listTasks, movedTasks, type TaskView } from "@/lib/tasks";
4
4
  import { stampPriority, startOrQueue } from "@/lib/tasks/queue";
5
5
  import { isPriority } from "@/lib/tasks/schedule-types";
6
6
  import { TASK_CAPABILITIES } from "@/lib/tasks/contract/types";
@@ -13,12 +13,29 @@ import {
13
13
  } from "@/lib/tasks/move-bundle";
14
14
  import { startView } from "@/lib/sessions/launch";
15
15
  import { isValidAgentType } from "@/lib/providers";
16
+ import { omitNulls } from "@/lib/sessions/omit-null";
16
17
  import { isAgentModelName } from "@/lib/providers/registry";
17
18
 
19
+ // The most recent merged tasks the list holds; ?done=all lists every one.
20
+ const MERGED_LISTED = 100;
21
+
22
+ // A task as the list shows it: no prompt text (a mirrored one from a machine
23
+ // that still sends it included) and no null fields.
24
+ function listed(task: TaskView) {
25
+ const { prompt: _prompt, ...rest } = task;
26
+ return omitNulls(rest);
27
+ }
28
+
18
29
  // moved and capabilities are for another machine that mirrors these tasks.
19
- export async function GET() {
30
+ export async function GET(request?: NextRequest) {
20
31
  return NextResponse.json({
21
- tasks: await listTasks(),
32
+ tasks: (
33
+ await listTasks(
34
+ request?.nextUrl.searchParams.get("done") === "all"
35
+ ? undefined
36
+ : MERGED_LISTED
37
+ )
38
+ ).map(listed),
22
39
  moved: movedTasks(),
23
40
  capabilities: TASK_CAPABILITIES,
24
41
  });
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { MessageSquare, SquareTerminal } from "lucide-react";
4
+ import { hasPrompt } from "@/lib/sessions/has-prompt";
4
5
  import type { Session } from "@/lib/db";
5
6
  import { supportsChat } from "@/lib/chat/capabilities";
6
7
  import { viewSwitchActions } from "@/stores/viewSwitch";
@@ -15,7 +16,7 @@ export function ViewSwitch({
15
16
  if (!session || !supportsChat(session.agent_type)) return null;
16
17
  if (session.host_id && session.host_id !== "local") return null;
17
18
  // A task's agent runs in its terminal with its brief; chat would end it.
18
- if (session.task_prompt) return null;
19
+ if (hasPrompt(session)) return null;
19
20
  const options = [
20
21
  { view: "chat" as const, icon: MessageSquare, label: "Chat" },
21
22
  { view: "terminal" as const, icon: SquareTerminal, label: "Terminal" },
@@ -4,6 +4,7 @@ import { SettingToggle as Toggle } from "@/components/Settings/SettingToggle";
4
4
  import { useNetworkQuery, useUpdateNetwork } from "@/data/devices";
5
5
  import { TailscaleCard } from "./TailscaleCard";
6
6
  import { ConnectCard } from "./ConnectCard";
7
+ import { ReachRow } from "@/components/Reach/ReachRow";
7
8
 
8
9
  export function NetworkSection({
9
10
  open,
@@ -22,6 +23,7 @@ export function NetworkSection({
22
23
 
23
24
  return (
24
25
  <div className="space-y-2">
26
+ <ReachRow enabled={open} />
25
27
  <ConnectCard
26
28
  connect={data.connect}
27
29
  canManage={canManage}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { hasPrompt } from "@/lib/sessions/has-prompt";
3
4
  import { useTaskLogQuery } from "@/data/session-logs";
4
5
  import { SetupCard } from "@/components/Chat/SetupCard";
5
6
  import { SignInCard } from "@/components/Terminal/SignInCard";
@@ -217,10 +218,11 @@ export const Pane = memo(function Pane({
217
218
 
218
219
  // A merged task keeps the log AgentOS wrote of it.
219
220
  const merged =
220
- !!session?.task_prompt &&
221
+ !!session &&
222
+ hasPrompt(session) &&
221
223
  (session.task_status === "merged" || session.pr_status === "merged");
222
224
  const hasLog = !!useTaskLogQuery(session?.id, merged).data;
223
- const hasReport = !!session?.task_prompt;
225
+ const hasReport = !!session && hasPrompt(session);
224
226
 
225
227
  // Get current project and its repositories
226
228
  const currentProject = useMemo(() => {
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { Loader2, RefreshCw } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { StatusPill } from "@/components/ui/status-pill";
6
+ import { CopyButton } from "@/components/Chat/CopyButton";
7
+ import { useReachQuery, useRecheckReach } from "@/data/reach";
8
+ import type { ReachFinding, ReachReport, ReachStatus } from "@/lib/reach/types";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ const TONE: Record<ReachStatus, "green" | "amber" | "red"> = {
12
+ ok: "green",
13
+ warn: "amber",
14
+ fail: "red",
15
+ };
16
+
17
+ // Amber has three stories: only retrying, one way in working, or a warning.
18
+ function pillLabel(report: ReachReport, problems: ReachFinding[]): string {
19
+ if (report.status === "ok") return "Reachable";
20
+ if (report.status === "fail") return "Not reachable";
21
+ if (problems.every((f) => f.needs === "nothing")) return "Reconnecting";
22
+ return report.url ? "Partly reachable" : "Needs a look";
23
+ }
24
+
25
+ // A fix is a command when it starts with one; otherwise it's plain advice.
26
+ const isCommand = (fix: string) => /^(agent-os|sudo)( |$)/.test(fix);
27
+
28
+ function Problem({ f }: { f: ReachFinding }) {
29
+ return (
30
+ <li className="flex flex-col gap-1">
31
+ <p className="text-sm font-medium">{f.title}</p>
32
+ {f.needs === "nothing" ? (
33
+ <p className="text-muted-foreground flex items-center gap-1.5 text-xs">
34
+ <Loader2 className="size-3 animate-spin" />
35
+ Retrying on its own, nothing to do
36
+ </p>
37
+ ) : (
38
+ <>
39
+ {f.fix &&
40
+ (isCommand(f.fix) ? (
41
+ <code className="bg-background block rounded px-2 py-1 text-xs break-all">
42
+ {f.fix}
43
+ </code>
44
+ ) : (
45
+ <p className="text-muted-foreground text-xs break-words">
46
+ {f.fix}
47
+ </p>
48
+ ))}
49
+ {f.guide && (
50
+ <a
51
+ href={f.guide}
52
+ target="_blank"
53
+ rel="noopener noreferrer"
54
+ className="text-muted-foreground hover:text-foreground flex min-h-11 w-fit items-center text-xs underline underline-offset-2 sm:min-h-6"
55
+ >
56
+ How to fix
57
+ </a>
58
+ )}
59
+ </>
60
+ )}
61
+ </li>
62
+ );
63
+ }
64
+
65
+ /** Whether other devices can reach this machine, and the fix when they can't. */
66
+ export function ReachRow({
67
+ enabled = true,
68
+ className,
69
+ }: {
70
+ enabled?: boolean;
71
+ className?: string;
72
+ }) {
73
+ const { data, isPending, isError } = useReachQuery(enabled);
74
+ const recheck = useRecheckReach();
75
+ if (isPending)
76
+ return (
77
+ <div
78
+ className={cn("bg-muted/40 h-16 animate-pulse rounded-lg", className)}
79
+ />
80
+ );
81
+ const problems = data?.findings.filter((f) => f.status !== "ok") ?? [];
82
+ const pill = data && {
83
+ tone: TONE[data.status],
84
+ label: pillLabel(data, problems),
85
+ };
86
+ return (
87
+ <div
88
+ className={cn(
89
+ "bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5",
90
+ className
91
+ )}
92
+ >
93
+ <div className="flex min-h-11 items-center gap-2 sm:min-h-0">
94
+ <p className="min-w-0 flex-1 text-sm font-medium">
95
+ Reachable from other devices
96
+ </p>
97
+ {pill && (
98
+ <StatusPill tone={pill.tone} className="shrink-0 whitespace-nowrap">
99
+ {pill.label}
100
+ </StatusPill>
101
+ )}
102
+ </div>
103
+ {isError && (
104
+ <p className="text-destructive text-xs">Couldn&apos;t run the check.</p>
105
+ )}
106
+ {data?.url && (
107
+ <div className="flex min-w-0 items-center gap-1">
108
+ <span className="text-foreground min-w-0 flex-1 truncate font-mono text-xs">
109
+ {data.url}
110
+ </span>
111
+ <CopyButton
112
+ text={data.url}
113
+ label="Copy link"
114
+ className="h-11 w-11 shrink-0 md:h-7 md:w-7"
115
+ />
116
+ </div>
117
+ )}
118
+ {problems.length > 0 && (
119
+ <ul className="flex flex-col gap-3">
120
+ {problems.map((f, i) => (
121
+ <Problem key={`${f.route ?? ""}-${f.cause ?? i}`} f={f} />
122
+ ))}
123
+ </ul>
124
+ )}
125
+ <Button
126
+ size="sm"
127
+ variant="ghost"
128
+ className="h-11 self-end sm:h-8"
129
+ disabled={recheck.isPending}
130
+ onClick={() => recheck.mutate()}
131
+ >
132
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
133
+ Check again
134
+ </Button>
135
+ </div>
136
+ );
137
+ }
@@ -3,6 +3,7 @@
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { HealthList } from "@/components/Welcome/HealthList";
5
5
  import { AgentList } from "@/components/Welcome/AgentList";
6
+ import { ReachRow } from "@/components/Reach/ReachRow";
6
7
  import { settingsUiActions } from "@/stores/settingsUi";
7
8
  import { welcomeUiActions } from "@/stores/welcomeUi";
8
9
 
@@ -32,6 +33,7 @@ export function SetupSection() {
32
33
  <div className="flex flex-col gap-2">
33
34
  <h3 className="text-sm font-medium">Health</h3>
34
35
  <HealthList />
36
+ <ReachRow />
35
37
  </div>
36
38
  <div className="flex flex-col gap-2">
37
39
  <h3 className="text-sm font-medium">Agents on this machine</h3>
@@ -18,6 +18,7 @@ import {
18
18
  CONNECT_STATE,
19
19
  connectView,
20
20
  } from "@/lib/connect/view";
21
+ import { ReachRow } from "@/components/Reach/ReachRow";
21
22
  import { Block } from "./Block";
22
23
 
23
24
  const COMMAND = "agent-os connect";
@@ -155,6 +156,7 @@ export function ConnectBlock() {
155
156
  {update.error && (
156
157
  <p className="text-destructive text-xs">{update.error.message}</p>
157
158
  )}
159
+ <ReachRow className="mt-2 px-0" />
158
160
  </div>
159
161
  <Switch
160
162
  checked={wanted ?? view.on}
package/content/tips.json CHANGED
@@ -536,6 +536,16 @@
536
536
  "anchor": "whats-new"
537
537
  },
538
538
  "added": "2026-10-31"
539
+ },
540
+ {
541
+ "id": "reach-check",
542
+ "title": "Can't reach your machine?",
543
+ "body": "Run agent-os check on the computer. It names the cause, like a stopped server or Wi-Fi access being off, and the one line that fixes it.",
544
+ "guide": {
545
+ "file": "reachability",
546
+ "anchor": "run-the-check"
547
+ },
548
+ "added": "2026-10-31"
539
549
  }
540
550
  ]
541
551
  }
@@ -1865,6 +1865,38 @@
1865
1865
  "area": "Updates",
1866
1866
  "text": "Update notices now appear as a small pop-up instead of a bar across the top of the app.",
1867
1867
  "url": "https://github.com/saadnvd1/agent-os/pull/543"
1868
+ },
1869
+ {
1870
+ "id": 544,
1871
+ "date": "2026-10-31",
1872
+ "kind": "new",
1873
+ "area": "AgentOS",
1874
+ "text": "Run agent-os setup to get your phone connected in a few steps, and agent-os check tells you why a device can't reach your computer and how to fix it.",
1875
+ "url": "https://github.com/saadnvd1/agent-os/pull/544"
1876
+ },
1877
+ {
1878
+ "id": 545,
1879
+ "date": "2026-10-31",
1880
+ "kind": "improved",
1881
+ "area": "AgentOS",
1882
+ "text": "The session list loads much faster, especially on a phone.",
1883
+ "url": "https://github.com/saadnvd1/agent-os/pull/545"
1884
+ },
1885
+ {
1886
+ "id": 546,
1887
+ "date": "2026-10-31",
1888
+ "kind": "fixed",
1889
+ "area": "AgentOS",
1890
+ "text": "Session list rows in the installed phone app no longer lose their short task prompt.",
1891
+ "url": "https://github.com/saadnvd1/agent-os/pull/546"
1892
+ },
1893
+ {
1894
+ "id": 547,
1895
+ "date": "2026-10-31",
1896
+ "kind": "fixed",
1897
+ "area": "AgentOS",
1898
+ "text": "The reachability check now tells a stuck AgentOS apart from another program on its port, and setup keeps a copy of a startup file you edited before replacing it.",
1899
+ "url": "https://github.com/saadnvd1/agent-os/pull/547"
1868
1900
  }
1869
1901
  ]
1870
1902
  }
@@ -3,6 +3,7 @@ import type { ListedDevice } from "@/lib/security/devices";
3
3
  import type { NetworkState } from "@/lib/security/network-state";
4
4
  import type { PairingOffer } from "@/lib/security/pair-qr";
5
5
  import { deviceKeys } from "./keys";
6
+ import { reachKeys } from "../reach";
6
7
  import { provePresence } from "../presence";
7
8
 
8
9
  async function json<T>(res: Response): Promise<T> {
@@ -77,7 +78,11 @@ export function useUpdateNetwork() {
77
78
  }),
78
79
  })
79
80
  ),
80
- onSuccess: (data) => queryClient.setQueryData(deviceKeys.network(), data),
81
+ onSuccess: (data) => {
82
+ queryClient.setQueryData(deviceKeys.network(), data);
83
+ // Wi-Fi access or Connect changed: whether devices get in did too.
84
+ void queryClient.invalidateQueries({ queryKey: reachKeys.all });
85
+ },
81
86
  });
82
87
  }
83
88
 
@@ -0,0 +1,33 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { ReachReport } from "@/lib/reach/types";
3
+
4
+ export const reachKeys = {
5
+ all: ["reach"] as const,
6
+ };
7
+
8
+ async function fetchReach(fresh: boolean) {
9
+ const res = await fetch(fresh ? "/api/reach?fresh" : "/api/reach");
10
+ if (!res.ok)
11
+ throw new Error(
12
+ "Couldn't check whether other devices can reach this machine"
13
+ );
14
+ return (await res.json()) as ReachReport;
15
+ }
16
+
17
+ export function useReachQuery(enabled = true) {
18
+ return useQuery({
19
+ queryKey: reachKeys.all,
20
+ queryFn: () => fetchReach(false),
21
+ enabled,
22
+ staleTime: 30_000,
23
+ refetchOnMount: "always",
24
+ });
25
+ }
26
+
27
+ export function useRecheckReach() {
28
+ const client = useQueryClient();
29
+ return useMutation({
30
+ mutationFn: () => fetchReach(true),
31
+ onSuccess: (report) => client.setQueryData(reachKeys.all, report),
32
+ });
33
+ }