@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
@@ -7,6 +7,7 @@ import { PiAccess, PiDialogs, writeExtension } from "./pi-approvals";
7
7
  import { TEXT_TOOLS_EXTENSION } from "./pi-text-tools";
8
8
  import { PiMapper } from "./pi-mapper";
9
9
  import { PiRpc } from "./pi-rpc";
10
+ import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
10
11
 
11
12
  type P = Record<string, unknown>;
12
13
 
@@ -17,7 +18,10 @@ export function piArgs(o: ChatStartOptions, ...extensions: string[]): string[] {
17
18
  // A path, so a conversation started elsewhere never makes Pi ask about it.
18
19
  if (o.resumeId) args.push("--session", o.resumeId);
19
20
  if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
20
- if (o.systemAppend) args.push("--append-system-prompt", o.systemAppend);
21
+ args.push(
22
+ "--append-system-prompt",
23
+ [o.systemAppend, RESULTS_BRIEF].filter(Boolean).join("\n\n")
24
+ );
21
25
  return args;
22
26
  }
23
27
 
@@ -78,6 +82,7 @@ export const piDriver: ChatDriver = {
78
82
  const dialogs = new PiDialogs(emit);
79
83
  const access = new PiAccess(options.access);
80
84
  // Sent now: goes once the stopped run has ended.
85
+ let nudged = false;
81
86
  const held: { text: string; images?: ChatImage[] }[] = [];
82
87
  const rpc: PiRpc = new PiRpc(
83
88
  piArgs(
@@ -132,6 +137,13 @@ export const piDriver: ChatDriver = {
132
137
  if (!s.isCompacting) break;
133
138
  await new Promise((r) => setTimeout(r, 200));
134
139
  }
140
+ if (!nudged && mapper.needsSummary()) {
141
+ nudged = true;
142
+ console.info("[chat] asked the model for its results summary");
143
+ void prompt(SUMMARY_NUDGE);
144
+ return;
145
+ }
146
+ nudged = false;
135
147
  mapper.endTurn();
136
148
  void rpc
137
149
  .request<{ contextUsage?: P }>("get_session_stats")
@@ -106,3 +106,28 @@ export function logTurnEnd(o: {
106
106
  })
107
107
  );
108
108
  }
109
+
110
+ // Asked of the model once when its turn ran tools but ended without the
111
+ // report it set out to give.
112
+ export const SUMMARY_NUDGE =
113
+ "You ran checks or tools but haven't reported the results. Reply now with a short summary: what worked, what failed (with the exit code or HTTP status) and what you didn't check. Don't run more tools.";
114
+
115
+ // Appended to the system prompt of agents AgentOS drives itself.
116
+ export const RESULTS_BRIEF =
117
+ "After running checks, probes or discovery, end your turn with a results summary: what worked, what failed (exit code or HTTP status) and what you didn't check. Never end on a lead-in such as 'Here's what I found:' without the list.";
118
+
119
+ // Whether a turn that ran tools ended without telling what they found: an
120
+ // empty last reply, or one that only promises the report.
121
+ export function needsSummary(r: {
122
+ toolsRun: number;
123
+ text: string;
124
+ toolCalls: number;
125
+ finishReason?: string;
126
+ error?: string;
127
+ aborted?: boolean;
128
+ }): boolean {
129
+ if (r.toolsRun === 0 || r.toolCalls > 0 || r.aborted || r.error) return false;
130
+ if (r.finishReason === "length") return false;
131
+ const text = r.text.trim();
132
+ return !text || announcesAction(text) || text.endsWith(":");
133
+ }
@@ -185,6 +185,8 @@ export type ChatItem =
185
185
  input: unknown;
186
186
  status: ToolStatus;
187
187
  output?: string;
188
+ // How it ended ("exit 1", "HTTP 404"), kept when the output is deferred.
189
+ outcome?: string;
188
190
  diff?: FileDiff;
189
191
  // When it finished, to show how long a step took.
190
192
  endedAt?: number;
package/lib/chat/group.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { ChatItem } from "./events";
2
2
  import { readingOrder } from "./reading-order";
3
+ import { stepCounts, type StepCounts } from "./tool-outcome";
3
4
  import { eventTurnAt, type EventTurn } from "./turns";
4
5
 
5
6
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
6
7
  export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
7
8
 
8
9
  export type TimelineBlock =
9
- | { type: "item"; item: ChatItem }
10
+ // `steps` rides on the end of a stopped or failed turn: what ran in it.
11
+ | { type: "item"; item: ChatItem; steps?: StepCounts }
10
12
  | { type: "tools"; id: string; tools: ToolItem[] }
11
13
  | { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
12
14
  // Consecutive turns an event started, with the reply to each.
@@ -30,6 +32,8 @@ export function groupTimeline(
30
32
  const items = opts.foldEvents ? readingOrder(all) : all;
31
33
  const blocks: TimelineBlock[] = [];
32
34
  let tools: ToolItem[] = [];
35
+ // Every tool call since the last turn ended, grouped or not.
36
+ let turnTools: ToolItem[] = [];
33
37
  const flush = () => {
34
38
  if (tools.length) blocks.push({ type: "tools", id: tools[0].id, tools });
35
39
  tools = [];
@@ -74,8 +78,10 @@ export function groupTimeline(
74
78
  i = end;
75
79
  } else if (item.kind === "tool" && STANDALONE_TOOLS.has(item.name)) {
76
80
  flush();
81
+ turnTools.push(item);
77
82
  blocks.push({ type: "item", item });
78
83
  } else if (item.kind === "tool") {
84
+ turnTools.push(item);
79
85
  tools.push(item);
80
86
  } else if (item.kind === "reasoning" && tools.length) {
81
87
  continue;
@@ -91,7 +97,15 @@ export function groupTimeline(
91
97
  continue;
92
98
  } else {
93
99
  flush();
94
- blocks.push({ type: "item", item });
100
+ const cut =
101
+ item.kind === "turn_end" &&
102
+ (item.interrupted || item.ended?.reason === "error");
103
+ blocks.push(
104
+ cut && turnTools.length
105
+ ? { type: "item", item, steps: stepCounts(turnTools) }
106
+ : { type: "item", item }
107
+ );
108
+ if (item.kind === "turn_end") turnTools = [];
95
109
  }
96
110
  }
97
111
  flush();
@@ -123,7 +137,13 @@ function undoneRanges(items: ChatItem[]): Map<number, number> {
123
137
  // until they change, so a streamed delta re-renders only its own block.
124
138
  export function sameBlock(a: TimelineBlock, b: TimelineBlock): boolean {
125
139
  if (a === b) return true;
126
- if (a.type === "item" && b.type === "item") return a.item === b.item;
140
+ if (a.type === "item" && b.type === "item")
141
+ return (
142
+ a.item === b.item &&
143
+ a.steps?.total === b.steps?.total &&
144
+ a.steps?.ok === b.steps?.ok &&
145
+ a.steps?.failed === b.steps?.failed
146
+ );
127
147
  if (a.type === "tools" && b.type === "tools")
128
148
  return sameItems(a.tools, b.tools);
129
149
  if (a.type === "undone" && b.type === "undone")
package/lib/chat/page.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { db } from "../db";
2
2
  import type { ChatItem, ToolBody } from "./events";
3
+ import { toolCode } from "./tool-outcome";
3
4
 
4
5
  // A conversation opens on its latest items and pages older ones in as the
5
6
  // reader scrolls up, so a long one costs what's on screen, not its history.
@@ -23,8 +24,10 @@ export function lighten(item: ChatItem): ChatItem {
23
24
  if (item.kind !== "tool" || (item.output === undefined && !item.diff))
24
25
  return item;
25
26
  const { output, diff, ...rest } = item;
27
+ const outcome = toolCode(output);
26
28
  return {
27
29
  ...rest,
30
+ ...(outcome ? { outcome } : {}),
28
31
  deferred: true,
29
32
  ...(diff ? { hasDiff: true } : {}),
30
33
  ...(output ? { hasOutput: true } : {}),
@@ -0,0 +1,45 @@
1
+ import type { ToolItem } from "./group";
2
+
3
+ const EDGE = 4096;
4
+
5
+ // A short label for how a command ended, read from its output: the last HTTP
6
+ // status line (a redirect chain reports where it landed) or an exit code.
7
+ export function toolCode(output?: string): string | undefined {
8
+ if (!output) return undefined;
9
+ const text =
10
+ output.length > EDGE * 2
11
+ ? `${output.slice(0, EDGE)}\n${output.slice(-EDGE)}`
12
+ : output;
13
+ const http = [...text.matchAll(/^HTTP\/[\d.]+ (\d{3})/gm)].pop();
14
+ if (http) return `HTTP ${http[1]}`;
15
+ const exit = [
16
+ ...text.matchAll(
17
+ /(?:^Exit code |Command exited with code |exit (?:code|status)[:= ]+)(\d+)/gim
18
+ ),
19
+ ].pop();
20
+ return exit ? `exit ${exit[1]}` : undefined;
21
+ }
22
+
23
+ export const toolOutcome = (tool: ToolItem): string | undefined =>
24
+ tool.outcome ?? toolCode(tool.output);
25
+
26
+ export interface StepCounts {
27
+ total: number;
28
+ ok: number;
29
+ failed: number;
30
+ // Never finished: the turn stopped first.
31
+ stopped: number;
32
+ running: number;
33
+ }
34
+
35
+ export function stepCounts(tools: ToolItem[]): StepCounts {
36
+ const n = (s: ToolItem["status"]) =>
37
+ tools.filter((t) => t.status === s).length;
38
+ return {
39
+ total: tools.length,
40
+ ok: n("done"),
41
+ failed: n("error"),
42
+ stopped: n("stopped"),
43
+ running: n("running"),
44
+ };
45
+ }
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261023T1200_host_info",
5
+ up: (db) => {
6
+ // What a linked machine says about itself (GET /api/machine), kept so its
7
+ // name, OS and addresses show while it is offline. Foreign keys aren't
8
+ // enforced here, so deleteHost removes the row.
9
+ db.exec(`CREATE TABLE IF NOT EXISTS host_info (
10
+ host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
11
+ name TEXT,
12
+ hostname TEXT,
13
+ os TEXT,
14
+ arch TEXT,
15
+ connect_machine_id TEXT,
16
+ addresses TEXT NOT NULL DEFAULT '[]',
17
+ fetched_at INTEGER NOT NULL DEFAULT 0
18
+ )`);
19
+ },
20
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -325,4 +325,8 @@ export interface Host {
325
325
  // who unlinked it.
326
326
  peer_state?: "active" | "suspended" | "unlinked" | null;
327
327
  peer_note?: string | null;
328
+ // What the machine reported about itself (host_info), when it has.
329
+ hostname?: string | null;
330
+ os?: string | null;
331
+ arch?: string | null;
328
332
  }
package/lib/drafts.ts CHANGED
@@ -63,7 +63,13 @@ export function newDraft(
63
63
  id: string,
64
64
  project: DraftProject | null,
65
65
  carry: Carry,
66
- opts: { openPr?: boolean; now?: number; workspaceId?: string | null } = {}
66
+ opts: {
67
+ openPr?: boolean;
68
+ now?: number;
69
+ workspaceId?: string | null;
70
+ // Where a scratch chat starts: the machine the sidebar is narrowed to.
71
+ hostId?: string | null;
72
+ } = {}
67
73
  ): Draft {
68
74
  const real = project && !project.is_uncategorized ? project : null;
69
75
  const agentType = real?.agent_type ?? carry.agentType ?? "claude";
@@ -71,7 +77,7 @@ export function newDraft(
71
77
  id,
72
78
  projectId: real?.id ?? null,
73
79
  workspaceId: real ? null : (opts.workspaceId ?? null),
74
- hostId: real?.host_id || "local",
80
+ hostId: real?.host_id || opts.hostId || "local",
75
81
  agentType,
76
82
  model: resolveModelForAgent(
77
83
  agentType,
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { getDb, type Host } from "../db";
3
+ import { machineName } from "../claims/machine";
4
+ import { setThisMachineName, thisMachine } from "../machines/identity";
3
5
  import {
4
6
  LOCAL_HOST_ID,
5
7
  isValidSshTarget,
@@ -19,6 +21,18 @@ export const LOCAL_HOST: Host = {
19
21
  created_at: "",
20
22
  };
21
23
 
24
+ /** This machine as a host row, under its real (or user-set) name. */
25
+ export function localHost(): Host {
26
+ const me = thisMachine();
27
+ return {
28
+ ...LOCAL_HOST,
29
+ name: me.name,
30
+ hostname: me.hostname,
31
+ os: me.os,
32
+ arch: me.arch,
33
+ };
34
+ }
35
+
22
36
  export function isRemoteHost(hostId: string | null | undefined): boolean {
23
37
  return !!hostId && hostId !== LOCAL_HOST_ID;
24
38
  }
@@ -38,18 +52,20 @@ export function listRemoteHosts(): Host[] {
38
52
  (SELECT p.suspended_reason FROM peer_links p WHERE p.host_id = h.id),
39
53
  (SELECT u.reason FROM peer_unlinked u WHERE u.host_id = h.id
40
54
  ORDER BY u.at DESC LIMIT 1))
41
- END AS peer_note
42
- FROM hosts h ORDER BY sort_order, created_at`
55
+ END AS peer_note,
56
+ i.hostname AS hostname, i.os AS os, i.arch AS arch
57
+ FROM hosts h LEFT JOIN host_info i ON i.host_id = h.id
58
+ ORDER BY h.sort_order, h.created_at`
43
59
  )
44
60
  .all() as Host[];
45
61
  }
46
62
 
47
63
  export function listHosts(): Host[] {
48
- return [LOCAL_HOST, ...listRemoteHosts()];
64
+ return [localHost(), ...listRemoteHosts()];
49
65
  }
50
66
 
51
67
  export function getHost(hostId: string | null | undefined): Host | null {
52
- if (!isRemoteHost(hostId)) return LOCAL_HOST;
68
+ if (!isRemoteHost(hostId)) return localHost();
53
69
  return (
54
70
  (getDb().prepare(`SELECT * FROM hosts WHERE id = ?`).get(hostId) as
55
71
  Host | undefined) ?? null
@@ -59,7 +75,11 @@ export function getHost(hostId: string | null | undefined): Host | null {
59
75
  /** A machine by its name (any case) or id. */
60
76
  export function hostIdNamed(ref: string): string {
61
77
  const want = ref.trim().toLowerCase();
62
- if (["local", "here", "this machine"].includes(want)) return LOCAL_HOST_ID;
78
+ if (
79
+ ["local", "here", "this machine"].includes(want) ||
80
+ machineName().toLowerCase() === want
81
+ )
82
+ return LOCAL_HOST_ID;
63
83
  const host = listRemoteHosts().find(
64
84
  (h) => h.id === ref || h.name.toLowerCase() === want
65
85
  );
@@ -109,6 +129,7 @@ export function deleteHost(hostId: string): void {
109
129
  }
110
130
  db.prepare(`DELETE FROM host_links WHERE host_id = ?`).run(hostId);
111
131
  db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
132
+ db.prepare(`DELETE FROM host_info WHERE host_id = ?`).run(hostId);
112
133
  db.prepare(`DELETE FROM hosts WHERE id = ?`).run(hostId);
113
134
  }
114
135
 
@@ -139,3 +160,20 @@ export function hostExecFile(
139
160
  ): Promise<ExecResult> {
140
161
  return runFileOnTarget(sshTargetFor(hostId), file, args, timeout);
141
162
  }
163
+
164
+ /** Rename a machine; this one's name is the one Work across machines shows. */
165
+ export function renameHost(hostId: string, raw: string): string {
166
+ if (!isRemoteHost(hostId)) return setThisMachineName(raw);
167
+ const name = raw.trim().slice(0, 60);
168
+ if (!name) throw new Error("Name is required");
169
+ const db = getDb();
170
+ if (!db.prepare(`SELECT 1 FROM hosts WHERE id = ?`).get(hostId))
171
+ throw new Error("Unknown host");
172
+ const clash = db
173
+ .prepare(`SELECT 1 FROM hosts WHERE lower(name) = lower(?) AND id != ?`)
174
+ .get(name, hostId);
175
+ if (clash || name.toLowerCase() === machineName().toLowerCase())
176
+ throw new Error(`Another machine is already called "${name}"`);
177
+ db.prepare(`UPDATE hosts SET name = ? WHERE id = ?`).run(name, hostId);
178
+ return name;
179
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Which machine the browser is on, when the request says. Only what the
3
+ * server itself saw counts: the gate's trust verdict (loopback means an
4
+ * unproxied request from this machine) and the socket's remote address.
5
+ * Through Connect or a proxy the address is the tunnel's, so nothing is
6
+ * claimed and the person says so once per browser instead.
7
+ */
8
+
9
+ import { plainAddress } from "../security/auth";
10
+
11
+ export interface BrowserMachine {
12
+ /** "local", a host id, or null when it can't be told. */
13
+ hostId: string | null;
14
+ how: "loopback" | "address" | null;
15
+ }
16
+
17
+ export interface KnownMachine {
18
+ hostId: string;
19
+ addresses: string[];
20
+ }
21
+
22
+ export function browserMachine(
23
+ trust: string | null,
24
+ remote: string | null,
25
+ own: string[],
26
+ others: KnownMachine[]
27
+ ): BrowserMachine {
28
+ if (trust === "loopback") return { hostId: "local", how: "loopback" };
29
+ const addr = plainAddress(remote ?? "");
30
+ if (!addr || ["connect", "peer", "unknown"].includes(addr))
31
+ return { hostId: null, how: null };
32
+ if (own.includes(addr)) return { hostId: "local", how: "address" };
33
+ const hit = others.find((m) => m.addresses.includes(addr));
34
+ return hit
35
+ ? { hostId: hit.hostId, how: "address" }
36
+ : { hostId: null, how: null };
37
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The agents this machine can run and the models each offers, plus the
3
+ * Ollama models it serves and which agent runs each. The new-session form
4
+ * shows exactly this for the machine picked; a linked machine reports its
5
+ * own through GET /api/machine.
6
+ */
7
+
8
+ import { AGENT_OPTIONS } from "../agent-options";
9
+ import { getProviderDefinition } from "../providers/registry";
10
+ import { probeAgents } from "../agents/probe";
11
+ import { discoveredModels } from "../chat/discovered-models";
12
+ import {
13
+ discoversModels,
14
+ getDefaultModelForAgent,
15
+ getModelOptions,
16
+ } from "../model-catalog";
17
+ import type { ChatModel } from "../chat/events";
18
+ import { ollamaModels } from "./ollama";
19
+ import type { CatalogAgent, MachineCatalog, OllamaEntry } from "./types";
20
+
21
+ const DISCOVER_MS = 6000;
22
+ // Agents that can run a model served by Ollama, in the order to prefer them.
23
+ const LOCAL_RUNNERS = ["opencode", "pi", "omp"] as const;
24
+
25
+ export const TASK_REMOTE_REASON =
26
+ "Tasks on another machine run Claude Code only";
27
+
28
+ const lastSegment = (v: string) => v.slice(v.lastIndexOf("/") + 1);
29
+
30
+ /** The runner that lists this Ollama tag among its models, if any. */
31
+ export function ollamaRunner(
32
+ tag: string,
33
+ byAgent: Record<string, ChatModel[]>
34
+ ): OllamaEntry["runsWith"] {
35
+ for (const agent of LOCAL_RUNNERS) {
36
+ const models = byAgent[agent] ?? [];
37
+ const hit =
38
+ models.find((m) => m.value === `ollama/${tag}`) ??
39
+ models.find(
40
+ (m) =>
41
+ lastSegment(m.value) === tag &&
42
+ /ollama/i.test(`${m.value} ${m.description ?? ""}`)
43
+ );
44
+ if (hit) return { agent, model: hit.value };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ async function discover(agent: string): Promise<ChatModel[]> {
50
+ return Promise.race([
51
+ discoveredModels(agent, null),
52
+ new Promise<never>((_, reject) =>
53
+ setTimeout(() => reject(new Error("timed out")), DISCOVER_MS).unref?.()
54
+ ),
55
+ ]);
56
+ }
57
+
58
+ export async function machineCatalog(): Promise<MachineCatalog> {
59
+ const [probes, tags] = await Promise.all([probeAgents(), ollamaModels()]);
60
+ const found: Record<string, ChatModel[]> = {};
61
+ const errors: Record<string, string> = {};
62
+ const wanted = AGENT_OPTIONS.filter(
63
+ (a) =>
64
+ probes[a.value]?.installed &&
65
+ (discoversModels(a.value) || LOCAL_RUNNERS.some((r) => r === a.value))
66
+ );
67
+ await Promise.all(
68
+ wanted.map(async (a) => {
69
+ if (!discoversModels(a.value)) return;
70
+ try {
71
+ found[a.value] = await discover(a.value);
72
+ } catch (e) {
73
+ errors[a.value] = e instanceof Error ? e.message : String(e);
74
+ }
75
+ })
76
+ );
77
+ const agents: CatalogAgent[] = AGENT_OPTIONS.filter(
78
+ (a) => !getProviderDefinition(a.value)?.legacy || probes[a.value]?.installed
79
+ ).map((a) => {
80
+ const base = getModelOptions(a.value);
81
+ return {
82
+ agent: a.value,
83
+ label: a.label,
84
+ installed: probes[a.value]?.installed ?? false,
85
+ auth: probes[a.value]?.auth ?? "unknown",
86
+ hint: probes[a.value]?.hint ?? null,
87
+ models: [...base, ...(found[a.value] ?? [])],
88
+ defaultModel: getDefaultModelForAgent(a.value),
89
+ taskBlocked: a.value === "claude" ? null : TASK_REMOTE_REASON,
90
+ modelsError: errors[a.value] ?? null,
91
+ };
92
+ });
93
+ return {
94
+ agents,
95
+ ollama: tags.map((name) => ({ name, runsWith: ollamaRunner(name, found) })),
96
+ };
97
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * This machine as it names itself: the one name every surface shows (the
3
+ * Machines dialog, the new-session chip, Work across machines), its OS and
4
+ * the addresses another device could reach it at.
5
+ */
6
+
7
+ import os from "os";
8
+ import { machineName } from "../claims/machine";
9
+ import { putSetting } from "../settings-store";
10
+ import { currentIdentity } from "../peer/keys";
11
+ import { lanAddresses, tailscaleAddresses } from "../security/net";
12
+
13
+ export interface MachineIdentity {
14
+ name: string;
15
+ hostname: string;
16
+ os: string;
17
+ arch: string;
18
+ connectMachineId: string | null;
19
+ addresses: string[];
20
+ }
21
+
22
+ const OS_LABEL: Record<string, string> = {
23
+ darwin: "macOS",
24
+ linux: "Linux",
25
+ win32: "Windows",
26
+ };
27
+
28
+ export const osLabel = (platform: string = os.platform()) =>
29
+ OS_LABEL[platform] ?? platform;
30
+
31
+ export function thisMachine(): MachineIdentity {
32
+ return {
33
+ name: machineName(),
34
+ hostname: os.hostname().split(".")[0] || "machine",
35
+ os: osLabel(),
36
+ arch: os.arch(),
37
+ connectMachineId: currentIdentity()?.machineId ?? null,
38
+ addresses: [...new Set([...tailscaleAddresses(), ...lanAddresses()])],
39
+ };
40
+ }
41
+
42
+ /** A user-set name for this machine; the same one Work across machines shows. */
43
+ export function setThisMachineName(raw: string): string {
44
+ const name = raw
45
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "")
46
+ .replace(/\s+/g, " ")
47
+ .trim()
48
+ .slice(0, 60);
49
+ if (!name) throw new Error("Name is required");
50
+ putSetting("claims.machine_name", name);
51
+ return name;
52
+ }
@@ -0,0 +1,36 @@
1
+ /** How a machine is named on every screen, so they all agree. */
2
+
3
+ export interface Nameable {
4
+ id: string;
5
+ name: string;
6
+ }
7
+
8
+ export const LOCAL_ID = "local";
9
+
10
+ /**
11
+ * "spark-54423f (host)" for the machine serving this dashboard, the plain name
12
+ * for the others; " · you're here" when the browser is on it.
13
+ */
14
+ export function machineLabel(host: Nameable, browserHostId?: string | null) {
15
+ const base = host.id === LOCAL_ID ? `${host.name} (host)` : host.name;
16
+ return browserHostId && browserHostId === host.id
17
+ ? `${base} · you're here`
18
+ : base;
19
+ }
20
+
21
+ /** What a machine reports about itself, as one line. */
22
+ export function machineDetail(h: {
23
+ name?: string;
24
+ hostname?: string | null;
25
+ os?: string | null;
26
+ arch?: string | null;
27
+ }): string {
28
+ // Its hostname is only news when the name isn't already it.
29
+ const hostname =
30
+ h.hostname && h.hostname.toLowerCase() !== h.name?.toLowerCase()
31
+ ? h.hostname
32
+ : null;
33
+ return [hostname, [h.os, h.arch].filter(Boolean).join(" ")]
34
+ .filter(Boolean)
35
+ .join(" · ");
36
+ }
@@ -0,0 +1,32 @@
1
+ /** The models a local Ollama serves, asked of its own API. */
2
+
3
+ const DEFAULT_URL = "http://127.0.0.1:11434";
4
+ const TIMEOUT_MS = 1500;
5
+
6
+ export function ollamaBase(
7
+ env: Record<string, string | undefined> = process.env
8
+ ): string {
9
+ const raw = env.OLLAMA_HOST?.trim();
10
+ if (!raw) return DEFAULT_URL;
11
+ const url = /^https?:\/\//.test(raw) ? raw : `http://${raw}`;
12
+ return url.replace(/\/+$/, "");
13
+ }
14
+
15
+ /** Model tags, or none when Ollama isn't running or doesn't answer in time. */
16
+ export async function ollamaModels(
17
+ fetchImpl: typeof fetch = fetch,
18
+ base = ollamaBase()
19
+ ): Promise<string[]> {
20
+ try {
21
+ const res = await fetchImpl(`${base}/api/tags`, {
22
+ signal: AbortSignal.timeout(TIMEOUT_MS),
23
+ });
24
+ if (!res.ok) return [];
25
+ const body = (await res.json()) as { models?: { name?: unknown }[] };
26
+ return (body.models ?? [])
27
+ .map((m) => (typeof m.name === "string" ? m.name : ""))
28
+ .filter(Boolean);
29
+ } catch {
30
+ return [];
31
+ }
32
+ }