@saadnvd1/agent-os 0.3.807 → 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 (57) 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/Media/MediaViewer.tsx +111 -0
  12. package/components/Media/viewer.test.ts +26 -0
  13. package/components/Media/viewer.ts +23 -0
  14. package/components/Pane/index.tsx +4 -2
  15. package/components/Reach/ReachRow.tsx +137 -0
  16. package/components/Settings/SetupSection.tsx +2 -0
  17. package/components/Tips/TipDialog.tsx +1 -1
  18. package/components/Tips/TipMedia.tsx +38 -3
  19. package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
  20. package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
  21. package/components/Updates/index.ts +1 -1
  22. package/components/Welcome/ConnectBlock.tsx +2 -0
  23. package/components/views/DesktopView.tsx +2 -2
  24. package/components/views/MobileView.tsx +2 -2
  25. package/content/tips.json +10 -0
  26. package/content/whats-new.json +48 -0
  27. package/data/devices/queries.ts +6 -1
  28. package/data/reach/index.ts +33 -0
  29. package/lib/connect/serve.ts +22 -3
  30. package/lib/connect/supervise.ts +33 -9
  31. package/lib/db/queries.ts +31 -4
  32. package/lib/db/types.ts +3 -1
  33. package/lib/guides.ts +6 -0
  34. package/lib/hosts/remote-menu.ts +4 -2
  35. package/lib/reach/check-cli.ts +117 -0
  36. package/lib/reach/checkReach.ts +62 -0
  37. package/lib/reach/classify.ts +283 -0
  38. package/lib/reach/cli.ts +38 -0
  39. package/lib/reach/gatherFacts.ts +167 -0
  40. package/lib/reach/index.ts +4 -0
  41. package/lib/reach/probeConnect.ts +122 -0
  42. package/lib/reach/types.ts +96 -0
  43. package/lib/security/auth.ts +2 -0
  44. package/lib/security/demo.ts +2 -0
  45. package/lib/sessions/has-prompt.ts +7 -0
  46. package/lib/sessions/omit-null.ts +8 -0
  47. package/lib/tasks/contract/views.ts +12 -0
  48. package/lib/tasks/index.ts +9 -5
  49. package/lib/tasks/session.ts +15 -1
  50. package/lib/tips/view.ts +2 -4
  51. package/package.json +1 -1
  52. package/scripts/agent-os +3 -0
  53. package/scripts/install.sh +12 -1
  54. package/scripts/lib/commands.sh +59 -65
  55. package/scripts/lib/service.sh +230 -0
  56. package/scripts/lib/setup.sh +303 -0
  57. package/scripts/lib/setup.test.ts +389 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Can other devices reach this machine, and if not, why. The facts are plain
3
+ * labels gathered from the machine (gatherFacts.ts); the classifier turns them
4
+ * into findings whose text comes from one table (classify.ts), so the CLI, the
5
+ * web app and the phone app all say the same thing.
6
+ */
7
+
8
+ export type ReachRoute = "connect" | "tailscale" | "lan";
9
+
10
+ export const REACH_CAUSES = [
11
+ "no-route",
12
+ "service-down",
13
+ "service-stuck",
14
+ "port-taken",
15
+ "lan-off",
16
+ "firewall",
17
+ "tailscale-missing",
18
+ "tailscale-logged-out",
19
+ "connect-not-linked",
20
+ "connect-off",
21
+ "connect-not-here",
22
+ "connect-denied",
23
+ "connector-not-ready",
24
+ "relay-down",
25
+ "origin-down",
26
+ "wrong-origin",
27
+ "dns",
28
+ "offline",
29
+ "certificate",
30
+ ] as const;
31
+
32
+ export type ReachCause = (typeof REACH_CAUSES)[number];
33
+
34
+ export type ReachStatus = "ok" | "warn" | "fail";
35
+
36
+ export interface ReachFinding {
37
+ route?: ReachRoute;
38
+ status: ReachStatus;
39
+ cause?: ReachCause;
40
+ /** "nothing": AgentOS is retrying by itself; "you": the fix line is for you. */
41
+ needs: "you" | "nothing";
42
+ title: string;
43
+ /** One line: a command or what to do. Empty when nothing is needed. */
44
+ fix: string;
45
+ /** Its section of the reachability guide. */
46
+ guide?: string;
47
+ /** The link this route works on, when it works. */
48
+ url?: string;
49
+ }
50
+
51
+ export interface ReachReport {
52
+ status: ReachStatus;
53
+ /** The best link another device can use right now. */
54
+ url?: string;
55
+ findings: ReachFinding[];
56
+ checkedAt: string;
57
+ }
58
+
59
+ /** What answered when this machine fetched its own Connect address. */
60
+ export type ConnectProbe =
61
+ | { kind: "ok" }
62
+ | { kind: "wrong-origin" }
63
+ // The edge's own error page: tunnel not connected, or origin unreachable.
64
+ | { kind: "edge"; status: number; tunnel: boolean }
65
+ | { kind: "dns"; scope: "host" | "all" }
66
+ | { kind: "tls"; skewed: boolean }
67
+ | { kind: "offline" }
68
+ | { kind: "timeout" };
69
+
70
+ /** Why this server doesn't run Connect, as a label. */
71
+ export type NotHere =
72
+ "env-off" | "dev-build" | "wrong-port" | "auth-off" | "lock" | "other";
73
+
74
+ export interface ReachFacts {
75
+ now: number;
76
+ port: number;
77
+ lan: { on: boolean; addresses: string[] };
78
+ /** Read-only firewall reading: "blocks" only when it would stop LAN devices. */
79
+ firewall: "off" | "blocks" | "unknown";
80
+ tailscale: {
81
+ state: "running" | "logged-out" | "missing" | "unknown";
82
+ url?: string;
83
+ };
84
+ connect: {
85
+ state: "off" | "refused" | "error" | "connecting" | "up" | "denied";
86
+ linked: boolean;
87
+ enabled: boolean;
88
+ hostname?: string;
89
+ notHere?: NotHere;
90
+ /** Where the last failure came from: fetching the connector, or the service. */
91
+ stage?: "connector" | "service";
92
+ /** When the tunnel was last seen leaving "up" (or first trying). */
93
+ downSince?: number;
94
+ };
95
+ probe?: ConnectProbe;
96
+ }
@@ -95,6 +95,8 @@ const PUBLIC_EXACT = new Set([
95
95
  "/api/peer/hello",
96
96
  "/api/peer/hello/reveal",
97
97
  "/api/peer/hello/status",
98
+ // A constant per boot, so this machine can tell its own answer through Connect.
99
+ "/api/reach/probe",
98
100
  "/manifest.webmanifest",
99
101
  "/manifest.json",
100
102
  "/favicon.ico",
@@ -146,6 +146,8 @@ const READS = [
146
146
  // stub: what a client checks before it trusts the server.
147
147
  "devices",
148
148
  "devices/network",
149
+ // A fixed "no way in" report: the demo never probes the internet.
150
+ "reach",
149
151
  // Whether this is a demo, for the UI.
150
152
  "demo",
151
153
  // The changelog: a static file, no agent.
@@ -0,0 +1,7 @@
1
+ import type { Session } from "@/lib/db/types";
2
+
3
+ // Whether a session is a task (it has a prompt). A list carries has_prompt
4
+ // in place of the prompt's text; a single session carries the text.
5
+ export const hasPrompt = (
6
+ s: Pick<Session, "task_prompt" | "has_prompt">
7
+ ): boolean => !!(s.has_prompt ?? s.task_prompt);
@@ -0,0 +1,8 @@
1
+ // A list row without its null fields: the client's types read a missing field
2
+ // as null, and over half of a session row's columns are.
3
+ export function omitNulls<T extends object>(row: T): T {
4
+ const out: Partial<T> = {};
5
+ for (const k of Object.keys(row) as (keyof T)[])
6
+ if (row[k] !== null) out[k] = row[k];
7
+ return out as T;
8
+ }
@@ -11,6 +11,17 @@ import type { TaskView } from "../index";
11
11
  import { viewOf } from "../move-targets";
12
12
  import * as client from "./client";
13
13
 
14
+ // The list leaves out the fields that are null.
15
+ const NULL_FIELDS = {
16
+ projectName: null,
17
+ branch: null,
18
+ baseBranch: null,
19
+ pr: null,
20
+ blocked: null,
21
+ cardUrl: null,
22
+ setup: null,
23
+ } as const;
24
+
14
25
  function offlineView(
15
26
  session: Session,
16
27
  hostName: string,
@@ -68,6 +79,7 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
68
79
  }
69
80
  return [
70
81
  {
82
+ ...NULL_FIELDS,
71
83
  ...t,
72
84
  // As this machine mirrored it: the other one may predate views.
73
85
  view: viewOf(s),
@@ -67,6 +67,7 @@ import { saidSinceLastMessage } from "../chat/store";
67
67
 
68
68
  import { lastActiveOf } from "./last-active";
69
69
  import { setPlan, setTask } from "../db/session-parts";
70
+ import { hasPrompt } from "../sessions/has-prompt";
70
71
  import { joinCrew } from "../sessions/crew";
71
72
  export * from "./state";
72
73
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
@@ -77,7 +78,9 @@ export { assertMovable, moveTask } from "./move-flow";
77
78
  export interface TaskView {
78
79
  id: string;
79
80
  name: string;
80
- prompt: string;
81
+ // A list leaves the text out (hasPrompt says there is one).
82
+ prompt?: string;
83
+ hasPrompt?: boolean;
81
84
  projectId: string | null;
82
85
  projectName: string | null;
83
86
  branch: string | null;
@@ -523,7 +526,8 @@ export async function taskView(session: Session): Promise<TaskView> {
523
526
  return {
524
527
  id: session.id,
525
528
  name: session.name,
526
- prompt: session.task_prompt || "",
529
+ prompt: session.task_prompt || undefined,
530
+ hasPrompt: hasPrompt(session),
527
531
  projectId: session.project_id,
528
532
  projectName: project?.name ?? null,
529
533
  branch: session.branch_name,
@@ -563,9 +567,9 @@ export function movedTasks(): { id: string; movedTo: string | null }[] {
563
567
  .all() as { id: string; movedTo: string | null }[];
564
568
  }
565
569
 
566
- // Archived tasks aren't listed.
567
- export async function listTasks(): Promise<TaskView[]> {
568
- const sessions = taskSessions(false);
570
+ // Archived tasks aren't listed; mergedLimit keeps only the most recent merged.
571
+ export async function listTasks(mergedLimit?: number): Promise<TaskView[]> {
572
+ const sessions = taskSessions(false, mergedLimit);
569
573
  const [local, remote] = await Promise.all([
570
574
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
571
575
  mirrorViews(sessions.filter(isMirror)),
@@ -31,12 +31,26 @@ import { setPr } from "../db/session-parts";
31
31
  export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
32
32
 
33
33
  // Without the brief and system prompt: a list doesn't need them.
34
- export function taskSessions(includeArchived = true): Session[] {
34
+ // mergedLimit keeps only that many of the most recently finished merged ones.
35
+ export function taskSessions(
36
+ includeArchived = true,
37
+ mergedLimit?: number
38
+ ): Session[] {
39
+ const cap =
40
+ mergedLimit === undefined
41
+ ? ""
42
+ : `AND (task_status != 'merged' OR id IN (
43
+ SELECT id FROM session_rows
44
+ WHERE task_status = 'merged'
45
+ ${includeArchived ? "" : "AND archived_at IS NULL"}
46
+ ORDER BY COALESCE(NULLIF(merged_at, ''), updated_at) DESC
47
+ LIMIT ${Math.max(0, Math.floor(mergedLimit))}))`;
35
48
  return db
36
49
  .prepare(
37
50
  `SELECT ${sessionListColumns(db)} FROM session_rows
38
51
  WHERE task_status IS NOT NULL AND task_status != 'moved'
39
52
  ${includeArchived ? "" : "AND archived_at IS NULL"}
53
+ ${cap}
40
54
  ORDER BY created_at DESC`
41
55
  )
42
56
  .all() as Session[];
package/lib/tips/view.ts CHANGED
@@ -3,6 +3,7 @@ import { tipApplies } from "./conditions";
3
3
  import { loadTips } from "./load";
4
4
  import { orderDeck, pickToday } from "./pick";
5
5
  import type { ClientTip, Tip, TipsView } from "./schema";
6
+ import { guideUrl } from "@/lib/guides";
6
7
  import {
7
8
  readPinned,
8
9
  readSeen,
@@ -12,10 +13,7 @@ import {
12
13
  writeSeen,
13
14
  } from "./state";
14
15
 
15
- const GUIDES = "https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
16
-
17
- export const guideUrl = (g: NonNullable<Tip["guide"]>) =>
18
- `${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
16
+ export { guideUrl };
19
17
 
20
18
  const forClient = ({ id, title, body, guide, media }: Tip): ClientTip => ({
21
19
  id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.807",
3
+ "version": "0.3.815",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/agent-os CHANGED
@@ -66,6 +66,7 @@ source "$SCRIPT_DIR/lib/prerequisites.sh"
66
66
  source "$SCRIPT_DIR/lib/ai-clis.sh"
67
67
  source "$SCRIPT_DIR/lib/commands.sh"
68
68
  source "$SCRIPT_DIR/lib/machine.sh"
69
+ source "$SCRIPT_DIR/lib/setup.sh"
69
70
 
70
71
  # The package family the installers key off (debian, redhat, linux, macos),
71
72
  # and the WSL version (0 off WSL).
@@ -75,6 +76,8 @@ WSL=$(detect_wsl)
75
76
  # Main
76
77
  case "${1:-}" in
77
78
  install) cmd_install "${2:-}" ;;
79
+ setup) shift; cmd_setup "$@" ;;
80
+ check) shift; aos_tsx lib/reach/cli.ts "$PORT" "$@" ;;
78
81
  start) cmd_start ;;
79
82
  stop) cmd_stop ;;
80
83
  restart) cmd_restart ;;
@@ -45,4 +45,15 @@ else
45
45
  fi
46
46
 
47
47
  # Run the install command
48
- exec "$INSTALL_DIR/scripts/agent-os" install
48
+ "$INSTALL_DIR/scripts/agent-os" install
49
+
50
+ # Under `curl | bash` stdin is this script, so ask on the terminal itself.
51
+ if [[ -t 1 ]] && { : < /dev/tty; } 2> /dev/null; then
52
+ echo ""
53
+ read -p "Set up access from your phone and other devices now? [Y/n] " -r answer < /dev/tty || answer=n
54
+ if [[ ! "$answer" =~ ^[Nn] ]]; then
55
+ exec "$INSTALL_DIR/scripts/agent-os" setup < /dev/tty
56
+ fi
57
+ fi
58
+ echo ""
59
+ echo "Next: agent-os setup"
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  # Command implementations for agent-os
3
3
 
4
+ # The background service (launchd/systemd) the start/stop commands go through.
5
+ source "${BASH_SOURCE[0]%/*}/service.sh"
6
+
4
7
  cmd_install() {
5
8
  local use_local=false
6
9
  [[ "${1:-}" == "--local" ]] && use_local=true
@@ -91,8 +94,8 @@ cmd_install() {
91
94
  fi
92
95
 
93
96
  echo "Next steps:"
94
- echo " agent-os start Start the server"
95
- echo " agent-os enable Auto-start on boot"
97
+ echo " agent-os setup Start it, keep it running, and reach it from your phone"
98
+ echo " agent-os check Can other devices reach this machine? (names the cause if not)"
96
99
  echo " agent-os status Show URLs"
97
100
  wsl_next_steps
98
101
  }
@@ -128,6 +131,16 @@ systemd_unit_active() {
128
131
  }
129
132
 
130
133
  cmd_start() {
134
+ if [[ "${OS:-}" == "macos" ]] && ! is_running && launchd_keeps_alive; then
135
+ if launchd_loaded; then
136
+ launchd_kickstart && log_success "AgentOS started (launchd)" && return 0
137
+ elif launchd_bootstrap; then
138
+ log_success "AgentOS started (launchd)"
139
+ return 0
140
+ fi
141
+ # No desktop session (ssh): run it by hand this time.
142
+ launchd_no_gui_session
143
+ fi
131
144
  if ! is_running && has_systemd &&
132
145
  systemctl --user is-enabled --quiet agent-os 2>/dev/null; then
133
146
  systemctl --user start agent-os
@@ -186,6 +199,15 @@ cmd_start() {
186
199
  }
187
200
 
188
201
  cmd_stop() {
202
+ # A kill would just be restarted: launchd stops it, until the next login
203
+ # or 'agent-os start'.
204
+ if [[ "${OS:-}" == "macos" ]] && launchd_loaded && launchd_keeps_alive; then
205
+ log_info "Stopping AgentOS (launchd)..."
206
+ launchd_bootout
207
+ rm -f "$PID_FILE"
208
+ log_success "AgentOS stopped"
209
+ return 0
210
+ fi
189
211
  if systemd_unit_active; then
190
212
  log_info "Stopping AgentOS (systemd)..."
191
213
  systemctl --user stop agent-os
@@ -226,6 +248,11 @@ cmd_stop() {
226
248
  }
227
249
 
228
250
  cmd_restart() {
251
+ if [[ "${OS:-}" == "macos" ]] && launchd_loaded && launchd_keeps_alive; then
252
+ launchd_restart
253
+ log_success "AgentOS restarted (launchd)"
254
+ return 0
255
+ fi
229
256
  if systemd_unit_active; then
230
257
  systemctl --user restart agent-os
231
258
  log_success "AgentOS restarted (systemd)"
@@ -387,69 +414,34 @@ cmd_enable() {
387
414
  exit 1
388
415
  fi
389
416
 
390
- local script_path
391
- script_path=$(realpath "$0")
392
-
393
417
  if [[ "$OS" == "macos" ]]; then
394
- local plist_path="$HOME/Library/LaunchAgents/com.agent-os.plist"
395
-
396
- cat > "$plist_path" << EOF
397
- <?xml version="1.0" encoding="UTF-8"?>
398
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
399
- <plist version="1.0">
400
- <dict>
401
- <key>Label</key>
402
- <string>com.agent-os</string>
403
- <key>ProgramArguments</key>
404
- <array>
405
- <string>$script_path</string>
406
- <string>start</string>
407
- </array>
408
- <key>RunAtLoad</key>
409
- <true/>
410
- <key>KeepAlive</key>
411
- <false/>
412
- <key>StandardOutPath</key>
413
- <string>$LOG_FILE</string>
414
- <key>StandardErrorPath</key>
415
- <string>$LOG_FILE</string>
416
- <key>EnvironmentVariables</key>
417
- <dict>
418
- <key>PATH</key>
419
- <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
420
- </dict>
421
- </dict>
422
- </plist>
423
- EOF
424
-
425
- launchctl load "$plist_path" 2>/dev/null || true
426
- log_success "Auto-start enabled (launchd)"
427
- echo " Plist: $plist_path"
418
+ local result path managed=false
419
+ path=$(launchd_plist_path)
420
+ launchd_loaded && launchd_keeps_alive && managed=true
421
+ if [[ "$(service_file_state)" == same && "$managed" == true ]]; then
422
+ log_success "Auto-start already enabled (launchd)"
423
+ echo " Plist: $path"
424
+ return 0
425
+ fi
426
+ # launchd runs the server itself from now on: one started by hand
427
+ # (or by the old plist) would hold the port it needs.
428
+ if is_running && [[ "$managed" == false ]]; then
429
+ cmd_stop
430
+ fi
431
+ result=$(service_reconcile)
432
+ [[ "$result" == updated ]] && log_info "Updated $path"
433
+ if launchd_bootstrap; then
434
+ log_success "Auto-start enabled (launchd): AgentOS starts at login and restarts after a crash"
435
+ else
436
+ launchd_no_gui_session
437
+ fi
438
+ echo " Plist: $path"
428
439
 
429
440
  elif has_systemd; then
430
- local service_dir="$HOME/.config/systemd/user"
431
- local service_path="$service_dir/agent-os.service"
432
-
433
- mkdir -p "$service_dir"
434
-
435
- cat > "$service_path" << EOF
436
- [Unit]
437
- Description=AgentOS - AI Coding Session Manager
438
- After=network.target
439
-
440
- [Service]
441
- Type=simple
442
- ExecStart=$script_path start-foreground
443
- Restart=on-failure
444
- RestartSec=10
445
- # Only the server is stopped on a restart: terminals (tmux), chat workers and
446
- # an update in progress outlive it, as they do under launchd.
447
- KillMode=process
448
- Environment=PATH=/usr/local/bin:/usr/bin:/bin
449
-
450
- [Install]
451
- WantedBy=default.target
452
- EOF
441
+ local service_path
442
+ service_path=$(systemd_unit_path)
443
+ # Writes the unit only when it differs; the rest is safe to repeat.
444
+ service_reconcile > /dev/null
453
445
 
454
446
  systemctl --user daemon-reload
455
447
  systemctl --user enable agent-os
@@ -486,8 +478,8 @@ cmd_disable() {
486
478
  local plist_path="$HOME/Library/LaunchAgents/com.agent-os.plist"
487
479
 
488
480
  if [[ -f "$plist_path" ]]; then
489
- launchctl unload "$plist_path" 2>/dev/null || true
490
- rm -f "$plist_path"
481
+ launchd_bootout
482
+ rm -f "$plist_path" "$PID_FILE"
491
483
  log_success "Auto-start disabled"
492
484
  else
493
485
  log_warn "Auto-start was not enabled"
@@ -584,13 +576,15 @@ cmd_help() {
584
576
  echo ""
585
577
  echo "Commands:"
586
578
  echo " install Install AgentOS (auto-installs dependencies)"
579
+ echo " setup Guided setup: background service, how devices reach it, phone"
580
+ echo " check Can other devices reach this machine? Names the cause and the fix"
587
581
  echo " run Start server and open in browser"
588
582
  echo " start Start the server in background"
589
583
  echo " stop Stop the server"
590
584
  echo " restart Restart the server"
591
585
  echo " status Show server status and URLs"
592
586
  echo " pair Add a phone, tablet or laptop (prints a QR code)"
593
- echo " connect Turn on AgentOS Connect for this machine (in development)"
587
+ echo " connect Turn on AgentOS Connect for this machine"
594
588
  echo " disconnect Turn Connect off; the tunnel closes within seconds"
595
589
  echo " link Link machines on your Connect account (link <name>, link accept <code>)"
596
590
  echo " unlink Unlink a machine linked through Connect"