@saadnvd1/agent-os 0.3.736 → 0.3.738

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 (56) hide show
  1. package/app/api/hosts/[id]/agents/route.ts +22 -0
  2. package/app/api/hosts/[id]/route.ts +24 -1
  3. package/app/api/hosts/route.ts +3 -0
  4. package/app/api/machine/browser/route.ts +22 -0
  5. package/app/api/machine/route.ts +10 -0
  6. package/components/Chat/ChatPanel.tsx +12 -3
  7. package/components/Chat/Messages.test.ts +13 -1
  8. package/components/Chat/Messages.tsx +19 -2
  9. package/components/Chat/StepOutcomes.tsx +66 -0
  10. package/components/Chat/Tools.test.ts +52 -0
  11. package/components/Chat/Tools.tsx +21 -4
  12. package/components/Claims/ClaimsSheet.tsx +19 -1
  13. package/components/Claims/claims-ui.test.ts +1 -1
  14. package/components/Claims/format.ts +1 -1
  15. package/components/Draft/AgentModelChip.test.ts +43 -0
  16. package/components/Draft/AgentModelChip.tsx +162 -0
  17. package/components/Draft/Chip.tsx +9 -2
  18. package/components/Draft/DraftChips.tsx +7 -45
  19. package/components/Draft/DraftPanel.tsx +10 -1
  20. package/components/Hosts/AccountMachines.tsx +3 -1
  21. package/components/Hosts/HostRow.tsx +57 -7
  22. package/components/Hosts/RenameHost.tsx +59 -0
  23. package/components/SessionList/MachineSwitcher.tsx +67 -0
  24. package/components/SessionList/SidebarHeader.tsx +4 -0
  25. package/components/SessionList/useSidebarData.ts +21 -3
  26. package/data/hosts/queries.ts +17 -0
  27. package/data/machines/index.ts +54 -0
  28. package/data/machines/keys.ts +5 -0
  29. package/hooks/useDraftRequests.ts +6 -1
  30. package/lib/chat/drivers/pi-mapper.ts +17 -1
  31. package/lib/chat/drivers/pi.ts +13 -1
  32. package/lib/chat/drivers/turn-outcome.ts +25 -0
  33. package/lib/chat/events.ts +2 -0
  34. package/lib/chat/group.ts +23 -3
  35. package/lib/chat/page.ts +3 -0
  36. package/lib/chat/tool-outcome.ts +45 -0
  37. package/lib/db/migrations/20261023T1200_host_info.ts +20 -0
  38. package/lib/db/types.ts +4 -0
  39. package/lib/drafts.ts +8 -2
  40. package/lib/hosts/index.ts +43 -5
  41. package/lib/machines/browser.ts +37 -0
  42. package/lib/machines/catalog.ts +97 -0
  43. package/lib/machines/identity.ts +52 -0
  44. package/lib/machines/label.ts +36 -0
  45. package/lib/machines/ollama.ts +32 -0
  46. package/lib/machines/remote.ts +179 -0
  47. package/lib/machines/report.ts +8 -0
  48. package/lib/machines/types.ts +55 -0
  49. package/lib/peer/allowlist.ts +2 -0
  50. package/lib/peer/machines.ts +30 -2
  51. package/lib/sidebar/machines.ts +10 -0
  52. package/lib/start-options.ts +20 -4
  53. package/package.json +1 -1
  54. package/scripts/screenshots/chat-verify.ts +68 -0
  55. package/scripts/screenshots/seed.ts +9 -0
  56. package/stores/machines.ts +51 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * What a linked machine says about itself. The identity is kept in
3
+ * host_info so its name, OS and addresses show while it is offline; the
4
+ * catalog (agents and models) is only held in memory for a minute.
5
+ */
6
+
7
+ import { getDb } from "../db";
8
+ import { hostApi, hostLink, linkedHostIds } from "../hosts/remote-api";
9
+ import { isRemoteHost } from "../hosts";
10
+ import type { HostCatalogResponse, MachineReport } from "./types";
11
+
12
+ const CATALOG_TTL_MS = 60_000;
13
+ const INFO_TTL_MS = 5 * 60_000;
14
+
15
+ interface Cached {
16
+ at: number;
17
+ report: MachineReport;
18
+ }
19
+ const cache = new Map<string, Cached>();
20
+ const running = new Map<string, Promise<MachineReport>>();
21
+ const tried = new Map<string, number>();
22
+
23
+ export interface HostInfo {
24
+ hostId: string;
25
+ name: string | null;
26
+ hostname: string | null;
27
+ os: string | null;
28
+ arch: string | null;
29
+ connectMachineId: string | null;
30
+ addresses: string[];
31
+ fetchedAt: number;
32
+ }
33
+
34
+ interface InfoRow {
35
+ host_id: string;
36
+ name: string | null;
37
+ hostname: string | null;
38
+ os: string | null;
39
+ arch: string | null;
40
+ connect_machine_id: string | null;
41
+ addresses: string;
42
+ fetched_at: number;
43
+ }
44
+
45
+ const parseAddresses = (raw: string): string[] => {
46
+ try {
47
+ const v = JSON.parse(raw);
48
+ return Array.isArray(v) ? v.filter((a) => typeof a === "string") : [];
49
+ } catch {
50
+ return [];
51
+ }
52
+ };
53
+
54
+ export function allHostInfo(): HostInfo[] {
55
+ return (getDb().prepare(`SELECT * FROM host_info`).all() as InfoRow[]).map(
56
+ (r) => ({
57
+ hostId: r.host_id,
58
+ name: r.name,
59
+ hostname: r.hostname,
60
+ os: r.os,
61
+ arch: r.arch,
62
+ connectMachineId: r.connect_machine_id,
63
+ addresses: parseAddresses(r.addresses),
64
+ fetchedAt: r.fetched_at,
65
+ })
66
+ );
67
+ }
68
+
69
+ export const hostInfo = (hostId: string): HostInfo | null =>
70
+ allHostInfo().find((i) => i.hostId === hostId) ?? null;
71
+
72
+ function save(hostId: string, report: MachineReport, now: number): void {
73
+ const i = report.identity;
74
+ getDb()
75
+ .prepare(
76
+ `INSERT INTO host_info (host_id, name, hostname, os, arch, connect_machine_id, addresses, fetched_at)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
78
+ ON CONFLICT(host_id) DO UPDATE SET name = excluded.name,
79
+ hostname = excluded.hostname, os = excluded.os, arch = excluded.arch,
80
+ connect_machine_id = excluded.connect_machine_id,
81
+ addresses = excluded.addresses, fetched_at = excluded.fetched_at`
82
+ )
83
+ .run(
84
+ hostId,
85
+ i.name,
86
+ i.hostname,
87
+ i.os,
88
+ i.arch,
89
+ i.connectMachineId,
90
+ JSON.stringify(i.addresses),
91
+ now
92
+ );
93
+ }
94
+
95
+ const text = (v: unknown, max = 120) =>
96
+ typeof v === "string" ? v.slice(0, max) : "";
97
+
98
+ /** A peer's report, trimmed to the shape we use; anything else is refused. */
99
+ export function parseReport(body: unknown): MachineReport | null {
100
+ const b = body as Partial<MachineReport> | null;
101
+ if (!b?.identity || !b.catalog || !Array.isArray(b.catalog.agents))
102
+ return null;
103
+ const i = b.identity;
104
+ return {
105
+ identity: {
106
+ name: text(i.name, 60),
107
+ hostname: text(i.hostname),
108
+ os: text(i.os, 30),
109
+ arch: text(i.arch, 30),
110
+ connectMachineId: text(i.connectMachineId) || null,
111
+ addresses: (Array.isArray(i.addresses) ? i.addresses : [])
112
+ .filter((a): a is string => typeof a === "string")
113
+ .slice(0, 16)
114
+ .map((a) => a.slice(0, 64)),
115
+ },
116
+ catalog: {
117
+ agents: b.catalog.agents.slice(0, 40),
118
+ ollama: Array.isArray(b.catalog.ollama)
119
+ ? b.catalog.ollama.slice(0, 200)
120
+ : [],
121
+ },
122
+ };
123
+ }
124
+
125
+ export async function fetchHostMachine(
126
+ hostId: string,
127
+ now = Date.now()
128
+ ): Promise<MachineReport> {
129
+ const hit = cache.get(hostId);
130
+ if (hit && now - hit.at < CATALOG_TTL_MS) return hit.report;
131
+ const link = hostLink(hostId);
132
+ if (!link) throw new Error("This machine isn't linked");
133
+ const wait = running.get(hostId);
134
+ if (wait) return wait;
135
+ const p = hostApi<unknown>(link, "/api/machine", { timeout: 12_000 })
136
+ .then((body) => {
137
+ const report = parseReport(body);
138
+ if (!report)
139
+ throw new Error("Its AgentOS answered in a format we can't read");
140
+ cache.set(hostId, { at: now, report });
141
+ save(hostId, report, now);
142
+ return report;
143
+ })
144
+ .finally(() => running.delete(hostId));
145
+ running.set(hostId, p);
146
+ return p;
147
+ }
148
+
149
+ /** The agents of a linked machine, or why they can't be listed. */
150
+ export async function hostCatalog(
151
+ hostId: string
152
+ ): Promise<HostCatalogResponse> {
153
+ if (!isRemoteHost(hostId))
154
+ return { catalog: null, error: "Not a linked machine" };
155
+ try {
156
+ return { catalog: (await fetchHostMachine(hostId)).catalog, error: null };
157
+ } catch (e) {
158
+ const old = /404|not found|forbidden|not allowed/i.test(String(e));
159
+ return {
160
+ catalog: null,
161
+ error: old
162
+ ? "Update AgentOS on that machine to see its agents and models"
163
+ : e instanceof Error
164
+ ? e.message
165
+ : String(e),
166
+ };
167
+ }
168
+ }
169
+
170
+ /** Read every linked machine whose identity is old; failures are ignored. */
171
+ export function refreshHostInfo(now = Date.now()): void {
172
+ const have = new Map(allHostInfo().map((i) => [i.hostId, i.fetchedAt]));
173
+ for (const hostId of linkedHostIds()) {
174
+ if (now - (have.get(hostId) ?? 0) < INFO_TTL_MS) continue;
175
+ if (now - (tried.get(hostId) ?? 0) < INFO_TTL_MS) continue;
176
+ tried.set(hostId, now);
177
+ fetchHostMachine(hostId, now).catch(() => undefined);
178
+ }
179
+ }
@@ -0,0 +1,8 @@
1
+ import { thisMachine } from "./identity";
2
+ import { machineCatalog } from "./catalog";
3
+ import type { MachineReport } from "./types";
4
+
5
+ /** What GET /api/machine answers: this machine's identity and agents. */
6
+ export async function thisMachineReport(): Promise<MachineReport> {
7
+ return { identity: thisMachine(), catalog: await machineCatalog() };
8
+ }
@@ -0,0 +1,55 @@
1
+ /** What a machine reports about itself, shared by the server and the UI. */
2
+
3
+ import type { AgentType } from "../providers";
4
+ import type { AgentAuth } from "../agents/probe";
5
+
6
+ export interface CatalogModel {
7
+ value: string;
8
+ label: string;
9
+ description?: string;
10
+ }
11
+
12
+ export interface CatalogAgent {
13
+ agent: AgentType;
14
+ label: string;
15
+ installed: boolean;
16
+ auth: AgentAuth;
17
+ hint: string | null;
18
+ models: CatalogModel[];
19
+ defaultModel: string;
20
+ /** Why it can't run a task on a linked machine; null when it can. */
21
+ taskBlocked: string | null;
22
+ /** Set when asking the agent for its models failed. */
23
+ modelsError: string | null;
24
+ }
25
+
26
+ export interface OllamaEntry {
27
+ /** The tag Ollama calls it, e.g. "qwen3:27b". */
28
+ name: string;
29
+ /** The agent and model value that runs it here; null when none can. */
30
+ runsWith: { agent: AgentType; model: string } | null;
31
+ }
32
+
33
+ export interface MachineCatalog {
34
+ agents: CatalogAgent[];
35
+ ollama: OllamaEntry[];
36
+ }
37
+
38
+ export interface MachineReport {
39
+ identity: {
40
+ name: string;
41
+ hostname: string;
42
+ os: string;
43
+ arch: string;
44
+ connectMachineId: string | null;
45
+ addresses: string[];
46
+ };
47
+ catalog: MachineCatalog;
48
+ }
49
+
50
+ /** One machine's agents as the new-session form asks for them. */
51
+ export interface HostCatalogResponse {
52
+ catalog: MachineCatalog | null;
53
+ /** Why there is no catalog (unlinked, unreachable, older AgentOS). */
54
+ error: string | null;
55
+ }
@@ -58,6 +58,8 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
58
58
  { method: "GET", pattern: "/api/projects" },
59
59
  { method: "POST", pattern: "/api/projects/availability" },
60
60
  { method: "GET", pattern: "/api/tmux/discover" },
61
+ // Its name, OS, addresses, agents and models, for the new-session form.
62
+ { method: "GET", pattern: "/api/machine" },
61
63
  { method: "GET", pattern: "/api/load" },
62
64
 
63
65
  { method: "GET", pattern: "/api/worktree-sweep" },
@@ -17,6 +17,8 @@ export interface AccountMachine {
17
17
  /** The Machines host it's linked as, when it is. */
18
18
  hostId: string | null;
19
19
  linkState: "active" | "suspended" | null;
20
+ /** How it's linked: Connect, or the Tailscale/ssh link in Machines. */
21
+ linkedVia: "connect" | "tailscale" | null;
20
22
  suspendedReason: string | null;
21
23
  }
22
24
 
@@ -54,6 +56,28 @@ export async function accountMachines(
54
56
  }[]
55
57
  ).map((r) => [r.machine_id, r])
56
58
  );
59
+ // Machines linked the other way: AgentOS to AgentOS over Tailscale or ssh.
60
+ const tokenLinked = (
61
+ getDb()
62
+ .prepare(
63
+ `SELECT h.id, h.name, i.name AS reported, i.connect_machine_id AS machine_id
64
+ FROM host_links l JOIN hosts h ON h.id = l.host_id
65
+ LEFT JOIN host_info i ON i.host_id = h.id WHERE h.kind != 'connect'`
66
+ )
67
+ .all() as {
68
+ id: string;
69
+ name: string;
70
+ reported: string | null;
71
+ machine_id: string | null;
72
+ }[]
73
+ ).map((r) => ({ ...r, names: [r.name, r.reported] }));
74
+ const sameMachine = (id: string, name: string) =>
75
+ tokenLinked.find(
76
+ (t) =>
77
+ t.machine_id === id ||
78
+ (!t.machine_id &&
79
+ t.names.some((n) => n && n.toLowerCase() === name.toLowerCase()))
80
+ );
57
81
  return {
58
82
  enrolled: view.enrolled,
59
83
  entitlements: view.entitlements,
@@ -62,6 +86,7 @@ export async function accountMachines(
62
86
  uncheckedSince: view.uncheckedSince,
63
87
  machines: view.peers.map((p) => {
64
88
  const pin = pins.get(p.id);
89
+ const other = pin ? undefined : sameMachine(p.id, p.name);
65
90
  const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
66
91
  return {
67
92
  id: p.id,
@@ -70,12 +95,15 @@ export async function accountMachines(
70
95
  online: Number.isFinite(seen) && now - seen < ONLINE_MS,
71
96
  lastSeenAt: p.lastSeenAt,
72
97
  version: p.version,
73
- hostId: pin?.host_id ?? null,
98
+ hostId: pin?.host_id ?? other?.id ?? null,
74
99
  linkState: pin
75
100
  ? pin.state === "active"
76
101
  ? "active"
77
102
  : "suspended"
78
- : null,
103
+ : other
104
+ ? "active"
105
+ : null,
106
+ linkedVia: pin ? "connect" : other ? "tailscale" : null,
79
107
  suspendedReason:
80
108
  pin && pin.state !== "active" ? pin.suspended_reason : null,
81
109
  };
@@ -54,3 +54,13 @@ export function machineGroups(
54
54
  .filter((g) => g.rows.length > 0)
55
55
  .sort((a, b) => a.name.localeCompare(b.name));
56
56
  }
57
+
58
+ /** Whether a session belongs to the machine the sidebar is narrowed to. */
59
+ export function inMachineScope(
60
+ session: Pick<Session, "host_id">,
61
+ scope: string | null
62
+ ): boolean {
63
+ if (!scope) return true;
64
+ const host = session.host_id || "local";
65
+ return host === scope;
66
+ }
@@ -10,6 +10,7 @@ import { getProviderDefinition } from "./providers/registry";
10
10
  import { probeAgents } from "./agents/probe";
11
11
  import { gitCheck } from "./git";
12
12
  import { listHosts } from "./hosts";
13
+ import { hostCatalog } from "./machines/remote";
13
14
  import { projectMachines } from "./hosts/project-machines";
14
15
  import { getModelOptions, getDefaultModelForAgent } from "./model-catalog";
15
16
  import { effectiveModels } from "./models/config";
@@ -52,6 +53,10 @@ export async function startOptions(
52
53
  planError = error instanceof Error ? error.message : String(error);
53
54
  }
54
55
 
56
+ // A project on another machine shows that machine's agents, as it reports them.
57
+ const there = here
58
+ ? null
59
+ : (await hostCatalog(project?.host_id ?? "")).catalog;
55
60
  const [probes, git, machines] = await Promise.all([
56
61
  here ? probeAgents() : Promise.resolve(null),
57
62
  project && here
@@ -78,10 +83,21 @@ export async function startOptions(
78
83
  ).map((a) => ({
79
84
  value: a.value,
80
85
  label: a.label,
81
- installed: probes?.[a.value]?.installed ?? null,
82
- auth: probes?.[a.value]?.auth ?? null,
83
- hint: probes?.[a.value]?.hint ?? null,
84
- models: getModelOptions(a.value),
86
+ installed:
87
+ probes?.[a.value]?.installed ??
88
+ there?.agents.find((t) => t.agent === a.value)?.installed ??
89
+ null,
90
+ auth:
91
+ probes?.[a.value]?.auth ??
92
+ there?.agents.find((t) => t.agent === a.value)?.auth ??
93
+ null,
94
+ hint:
95
+ probes?.[a.value]?.hint ??
96
+ there?.agents.find((t) => t.agent === a.value)?.hint ??
97
+ null,
98
+ models:
99
+ there?.agents.find((t) => t.agent === a.value)?.models ??
100
+ getModelOptions(a.value),
85
101
  defaultModel: getDefaultModelForAgent(a.value),
86
102
  })),
87
103
  taskModels: modelChoices(models),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.736",
3
+ "version": "0.3.738",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,68 @@
1
+ import type { ChatItem } from "../../lib/chat/events";
2
+
3
+ const step = (
4
+ n: number,
5
+ start: number,
6
+ title: string,
7
+ status: "done" | "error" | "stopped",
8
+ output?: string
9
+ ): ChatItem => ({
10
+ id: `tool-${n}`,
11
+ createdAt: start + n * 4000,
12
+ kind: "tool",
13
+ name: "Bash",
14
+ title,
15
+ input: { command: title },
16
+ status,
17
+ output,
18
+ endedAt: start + n * 4000 + 1500,
19
+ });
20
+
21
+ // A reachability check cut short: some steps answered, one failed, two
22
+ // never ran.
23
+ export function verifyConversation(start: number): ChatItem[] {
24
+ return [
25
+ {
26
+ id: "user-1",
27
+ createdAt: start,
28
+ kind: "user",
29
+ text: "Which services can you reach from here before you scope the sync job?",
30
+ },
31
+ {
32
+ id: "assistant-1",
33
+ createdAt: start + 2000,
34
+ kind: "assistant",
35
+ text: "Here's what I can verify is reachable from this box, before I scope:",
36
+ },
37
+ step(
38
+ 1,
39
+ start,
40
+ "curl -sI https://api.example.test/health",
41
+ "done",
42
+ "HTTP/2 200 \ncontent-type: application/json"
43
+ ),
44
+ step(
45
+ 2,
46
+ start,
47
+ "curl -sI https://billing.example.test/v1",
48
+ "done",
49
+ "HTTP/2 401 \nwww-authenticate: Bearer"
50
+ ),
51
+ step(
52
+ 3,
53
+ start,
54
+ "pg_isready -h db.internal",
55
+ "error",
56
+ "db.internal:5432 - no response\nExit code 2"
57
+ ),
58
+ step(4, start, "curl -sI https://search.example.test", "stopped"),
59
+ step(5, start, "redis-cli -h cache.internal ping", "stopped"),
60
+ {
61
+ id: "turn-1",
62
+ createdAt: start + 40000,
63
+ kind: "turn_end",
64
+ durationMs: 40000,
65
+ interrupted: true,
66
+ },
67
+ ];
68
+ }
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * npx tsx scripts/screenshots/seed.ts
7
7
  */
8
+ import { verifyConversation } from "./chat-verify";
8
9
  import fs from "fs";
9
10
  import path from "path";
10
11
  import { randomUUID } from "crypto";
@@ -148,6 +149,14 @@ const SESSIONS: SessionSeed[] = [
148
149
  minutesAgo: 60 * 5,
149
150
  chat: docsConversation,
150
151
  },
152
+ {
153
+ key: "verify",
154
+ name: "sync-reachability",
155
+ project: "ml-pipeline",
156
+ view: "chat",
157
+ minutesAgo: 60 * 8,
158
+ chat: verifyConversation,
159
+ },
151
160
  {
152
161
  key: "backfill",
153
162
  name: "feature-backfill",
@@ -0,0 +1,51 @@
1
+ import { proxy, subscribe } from "valtio";
2
+
3
+ const KEY = "agentOS-machines-v1";
4
+
5
+ // What this browser remembers about machines: which one it is on (when the
6
+ // server couldn't tell) and which machine the sidebar is narrowed to.
7
+ interface Remembered {
8
+ browserHostId: string | null;
9
+ scopeHostId: string | null;
10
+ }
11
+
12
+ export const machinesUi = proxy<Remembered & { hydrated: boolean }>({
13
+ browserHostId: null,
14
+ scopeHostId: null,
15
+ hydrated: false,
16
+ });
17
+
18
+ export const machinesUiActions = {
19
+ hydrate: () => {
20
+ if (machinesUi.hydrated || typeof window === "undefined") return;
21
+ try {
22
+ const saved = JSON.parse(
23
+ localStorage.getItem(KEY) ?? "{}"
24
+ ) as Partial<Remembered>;
25
+ machinesUi.browserHostId = saved.browserHostId ?? null;
26
+ machinesUi.scopeHostId = saved.scopeHostId ?? null;
27
+ } catch {
28
+ // A bad entry is the same as none.
29
+ }
30
+ machinesUi.hydrated = true;
31
+ subscribe(machinesUi, () => {
32
+ try {
33
+ localStorage.setItem(
34
+ KEY,
35
+ JSON.stringify({
36
+ browserHostId: machinesUi.browserHostId,
37
+ scopeHostId: machinesUi.scopeHostId,
38
+ })
39
+ );
40
+ } catch {
41
+ // Private mode: it just won't be remembered.
42
+ }
43
+ });
44
+ },
45
+ setBrowserHost: (hostId: string | null) => {
46
+ machinesUi.browserHostId = hostId;
47
+ },
48
+ setScope: (hostId: string | null) => {
49
+ machinesUi.scopeHostId = hostId;
50
+ },
51
+ };