@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
@@ -90,10 +90,17 @@ export function ChipItem({
90
90
  );
91
91
  }
92
92
 
93
- export function ChipHeading({ children }: { children: React.ReactNode }) {
93
+ export function ChipHeading({
94
+ children,
95
+ first,
96
+ }: {
97
+ children: React.ReactNode;
98
+ // The top of the menu: nothing above it to separate from.
99
+ first?: boolean;
100
+ }) {
94
101
  return (
95
102
  <>
96
- <DropdownMenuSeparator />
103
+ {!first && <DropdownMenuSeparator />}
97
104
  <DropdownMenuLabel className="text-muted-foreground text-[11px] font-medium">
98
105
  {children}
99
106
  </DropdownMenuLabel>
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { useEffect } from "react";
4
4
  import {
5
- Bot,
6
5
  Cpu,
7
6
  Eye,
8
7
  FolderGit2,
@@ -15,14 +14,13 @@ import {
15
14
  import type { Host } from "@/lib/db";
16
15
  import type { Project } from "@/lib/db";
17
16
  import { draftHostGone, newDraft, type Draft } from "@/lib/drafts";
18
- import { AGENT_OPTIONS, agentLabel } from "@/lib/agent-options";
19
- import { resolveModelForAgent } from "@/lib/model-catalog";
20
17
  import type { GitCheck } from "@/data/git/queries";
21
- import { useAgentStatusQuery } from "@/data/agents";
22
18
  import { useProjectMachinesQuery } from "@/data/projects";
23
19
  import { modelChoices, useModelsQuery } from "@/data/models";
24
- import type { AgentProbe } from "@/lib/agents/probe";
20
+ import { useBrowserMachine } from "@/data/machines";
21
+ import { machineLabel } from "@/lib/machines/label";
25
22
  import { RoleChips } from "./RoleChips";
23
+ import { AgentModelChip } from "./AgentModelChip";
26
24
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
27
25
  import {
28
26
  DEFAULT_PRIORITY,
@@ -31,14 +29,6 @@ import {
31
29
  PRIORITY_LABEL,
32
30
  } from "@/lib/tasks/schedule-types";
33
31
 
34
- // What stands between an agent and its first prompt, if anything.
35
- export function agentBlocker(probe?: AgentProbe): string | null {
36
- if (!probe) return null;
37
- if (!probe.installed) return "Not installed";
38
- if (probe.auth === "needs-login") return "Needs sign-in";
39
- return null;
40
- }
41
-
42
32
  const BRANCHES_SHOWN = 20;
43
33
 
44
34
  // A draft's choices, above its composer: where it runs and how.
@@ -58,6 +48,7 @@ export function DraftChips({
58
48
  git: GitCheck | undefined;
59
49
  onChange: (patch: Partial<Draft>) => void;
60
50
  }) {
51
+ const browser = useBrowserMachine();
61
52
  const real = projects.filter((p) => !p.is_uncategorized);
62
53
  const project = real.find((p) => p.id === draft.projectId) ?? null;
63
54
  const projectHere = !!project && (project.host_id || "local") === "local";
@@ -70,10 +61,6 @@ export function DraftChips({
70
61
  : hosts;
71
62
  const host = hosts.find((h) => h.id === draft.hostId);
72
63
  const local = draft.hostId === "local";
73
- // Which agents are installed and signed in, on this machine: a draft for
74
- // another machine shows them all.
75
- const { data: localProbes } = useAgentStatusQuery();
76
- const probes = local ? localProbes : undefined;
77
64
  const base = draft.baseBranch ?? git?.defaultBranch ?? "main";
78
65
  const { data: models } = useModelsQuery(draft.openPr);
79
66
  // A project elsewhere runs where it lives; a scratch chat, a task or a
@@ -147,7 +134,7 @@ export function DraftChips({
147
134
  <ChipMenu
148
135
  icon={Monitor}
149
136
  name="Machine"
150
- label={host?.name ?? draft.hostId}
137
+ label={host ? machineLabel(host) : draft.hostId}
151
138
  disabled={!canMove}
152
139
  title={canMove ? undefined : "Runs where the project lives"}
153
140
  >
@@ -164,31 +151,14 @@ export function DraftChips({
164
151
  })
165
152
  }
166
153
  >
167
- {h.name}
154
+ {machineLabel(h, browser.hostId)}
168
155
  </ChipItem>
169
156
  ))}
170
157
  </ChipMenu>
171
158
  )}
172
159
 
173
160
  {!draft.openPr && !draft.agentRole && (
174
- <ChipMenu icon={Bot} name="Agent" label={agentLabel(draft.agentType)}>
175
- {AGENT_OPTIONS.map((o) => (
176
- <ChipItem
177
- key={o.value}
178
- checked={o.value === draft.agentType}
179
- disabled={probes?.[o.value] ? !probes[o.value].installed : false}
180
- hint={agentBlocker(probes?.[o.value]) ?? undefined}
181
- onSelect={() =>
182
- onChange({
183
- agentType: o.value,
184
- model: resolveModelForAgent(o.value, draft.model),
185
- })
186
- }
187
- >
188
- {o.label}
189
- </ChipItem>
190
- ))}
191
- </ChipMenu>
161
+ <AgentModelChip draft={draft} onChange={onChange} />
192
162
  )}
193
163
 
194
164
  {!draft.openPr && local && (
@@ -314,14 +284,6 @@ export function DraftChips({
314
284
  onChange={(terminal) => onChange({ terminal })}
315
285
  />
316
286
  )}
317
- {!draft.openPr && agentBlocker(probes?.[draft.agentType]) && (
318
- <p className="w-full text-xs text-amber-600 dark:text-amber-400">
319
- {agentBlocker(probes?.[draft.agentType])}
320
- {probes?.[draft.agentType]?.hint
321
- ? `: ${probes[draft.agentType].hint}`
322
- : ""}
323
- </p>
324
- )}
325
287
  </div>
326
288
  );
327
289
  }
@@ -27,6 +27,7 @@ import { useStartAnalystBeside } from "@/components/Analyst/pair";
27
27
  import { DraftChips } from "./DraftChips";
28
28
  import { DraftHeading } from "./DraftHeading";
29
29
  import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
30
+ import { useMachineCatalog } from "@/data/machines";
30
31
  import { draftModels } from "./draftModels";
31
32
 
32
33
  // A new session before its first send: choices on top, a composer below.
@@ -57,6 +58,8 @@ export function DraftPanel({
57
58
  draft?.projectId ?? null,
58
59
  discovers
59
60
  );
61
+ // On another machine the models are the ones it reports, not this one's.
62
+ const there = useMachineCatalog(draft?.hostId ?? "local");
60
63
  const launch = useLaunchSession();
61
64
  const startAnalyst = useStartAnalystBeside();
62
65
  const { workspace } = useSelectedWorkspace();
@@ -253,7 +256,13 @@ export function DraftPanel({
253
256
  }
254
257
  onSend={(text, images, files) => void send(text, images, files)}
255
258
  onStop={() => {}}
256
- models={draftModels(draft.agentType, found)}
259
+ models={
260
+ (draft.hostId !== "local" &&
261
+ there.data?.catalog?.agents.find(
262
+ (a) => a.agent === draft.agentType
263
+ )?.models) ||
264
+ draftModels(draft.agentType, found)
265
+ }
257
266
  model={draft.model}
258
267
  onSetModel={(model) => draftsActions.update(draft.id, { model })}
259
268
  access={draft.openPr ? undefined : draft.access}
@@ -55,7 +55,9 @@ function MachineRow({
55
55
  </div>
56
56
  {linked ? (
57
57
  <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
58
- Linked
58
+ {machine.linkedVia === "tailscale"
59
+ ? "Linked over Tailscale"
60
+ : "Linked"}
59
61
  </span>
60
62
  ) : machine.linkState === "suspended" ? (
61
63
  <span
@@ -11,15 +11,20 @@ import {
11
11
  PlugZap,
12
12
  Loader2,
13
13
  Link2,
14
+ MapPin,
15
+ Pencil,
14
16
  Unlink,
15
17
  } from "lucide-react";
16
18
  import { Notice } from "@/components/ui/notice";
17
19
  import type { Host } from "@/lib/db";
18
20
  import { useDeleteHost, useLinkHost, useTestHost } from "@/data/hosts";
21
+ import { useBrowserMachine } from "@/data/machines";
22
+ import { machineDetail, machineLabel } from "@/lib/machines/label";
19
23
  import { useUnlinkPeer } from "@/data/peers";
20
24
  import { NEEDS_PRO, UPGRADE_URL } from "@/lib/peer/reasons";
21
25
  import { cn } from "@/lib/utils";
22
26
  import { PeerActivitySheet } from "./PeerActivitySheet";
27
+ import { RenameHost } from "./RenameHost";
23
28
 
24
29
  function connectLine(host: Host): string {
25
30
  if (host.peer_state === "active")
@@ -67,6 +72,9 @@ export function HostRow({ host }: { host: Host }) {
67
72
  const unlink = useUnlinkPeer();
68
73
  const [activity, setActivity] = useState(false);
69
74
  const [confirmUnlink, setConfirmUnlink] = useState(false);
75
+ const [renaming, setRenaming] = useState(false);
76
+ const browser = useBrowserMachine();
77
+ const here = browser.hostId === host.id;
70
78
  const isLocal = host.id === "local";
71
79
  const viaConnect = host.kind === "connect";
72
80
  const pinned =
@@ -80,17 +88,58 @@ export function HostRow({ host }: { host: Host }) {
80
88
  <div className="flex items-center gap-3">
81
89
  <Icon className="text-muted-foreground h-4 w-4 shrink-0" />
82
90
  <div className="min-w-0 flex-1">
83
- <p className="truncate text-sm font-medium">{host.name}</p>
91
+ <p className="truncate text-sm font-medium">
92
+ {machineLabel(host, browser.hostId)}
93
+ </p>
84
94
  <p className="text-muted-foreground truncate text-xs">
85
95
  {isLocal
86
- ? "Where agent-os is running"
87
- : viaConnect
88
- ? connectLine(host)
89
- : host.linked
90
- ? `${host.ssh_target} · tasks run on its AgentOS`
91
- : host.ssh_target}
96
+ ? [machineDetail(host), "serves this dashboard"]
97
+ .filter(Boolean)
98
+ .join(" · ")
99
+ : [
100
+ machineDetail(host),
101
+ viaConnect
102
+ ? connectLine(host)
103
+ : host.linked
104
+ ? `${host.ssh_target} · tasks run on its AgentOS`
105
+ : host.ssh_target,
106
+ ]
107
+ .filter(Boolean)
108
+ .join(" · ")}
92
109
  </p>
93
110
  </div>
111
+ <Button
112
+ variant="ghost"
113
+ size="icon-sm"
114
+ className="h-11 w-11 sm:h-8 sm:w-8"
115
+ onClick={() => setRenaming(true)}
116
+ disabled={renaming}
117
+ aria-label={`Rename ${host.name}`}
118
+ title="Give it a name of your own"
119
+ >
120
+ <Pencil className="h-4 w-4" />
121
+ </Button>
122
+ <Button
123
+ variant="ghost"
124
+ size="icon-sm"
125
+ className={cn("h-11 w-11 sm:h-8 sm:w-8", here && "text-primary")}
126
+ aria-pressed={here}
127
+ onClick={() => browser.set(here ? null : host.id)}
128
+ aria-label={
129
+ here
130
+ ? `This browser is not on ${host.name}`
131
+ : `This browser is on ${host.name}`
132
+ }
133
+ title={
134
+ here
135
+ ? browser.detected
136
+ ? "Detected from the connection. Tap to say otherwise"
137
+ : "You said you're here. Tap to undo"
138
+ : "Say this is the machine you're using now"
139
+ }
140
+ >
141
+ <MapPin className="h-4 w-4" />
142
+ </Button>
94
143
  {!isLocal && (
95
144
  <>
96
145
  {!viaConnect && (
@@ -171,6 +220,7 @@ export function HostRow({ host }: { host: Host }) {
171
220
  </>
172
221
  )}
173
222
  </div>
223
+ {renaming && <RenameHost host={host} onDone={() => setRenaming(false)} />}
174
224
  {result && (
175
225
  <p
176
226
  className={cn(
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import type { Host } from "@/lib/db";
7
+ import { useRenameHost } from "@/data/hosts";
8
+
9
+ /** A name of the person's own for a machine, shown on every screen. */
10
+ export function RenameHost({
11
+ host,
12
+ onDone,
13
+ }: {
14
+ host: Host;
15
+ onDone: () => void;
16
+ }) {
17
+ const rename = useRenameHost();
18
+ const [name, setName] = useState(host.name);
19
+ const save = () =>
20
+ rename.mutate({ id: host.id, name }, { onSuccess: onDone });
21
+ return (
22
+ <form
23
+ className="mt-2 flex flex-wrap items-center gap-2"
24
+ onSubmit={(e) => {
25
+ e.preventDefault();
26
+ save();
27
+ }}
28
+ >
29
+ <Input
30
+ value={name}
31
+ onChange={(e) => setName(e.target.value)}
32
+ aria-label={`Name for ${host.name}`}
33
+ maxLength={60}
34
+ autoFocus
35
+ className="h-11 min-w-0 flex-1 sm:h-9"
36
+ />
37
+ <Button
38
+ type="submit"
39
+ className="min-h-11 sm:min-h-9"
40
+ disabled={rename.isPending || !name.trim()}
41
+ >
42
+ Save
43
+ </Button>
44
+ <Button
45
+ type="button"
46
+ variant="ghost"
47
+ className="min-h-11 sm:min-h-9"
48
+ onClick={onDone}
49
+ >
50
+ Cancel
51
+ </Button>
52
+ {rename.error && (
53
+ <p className="text-destructive w-full text-xs">
54
+ {rename.error.message}
55
+ </p>
56
+ )}
57
+ </form>
58
+ );
59
+ }
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { Check, ChevronDown, Monitor } from "lucide-react";
6
+ import * as DM from "@/components/ui/dropdown-menu";
7
+ import { useHostsQuery } from "@/data/hosts";
8
+ import { useBrowserMachine } from "@/data/machines";
9
+ import { machineLabel } from "@/lib/machines/label";
10
+ import { machinesUi, machinesUiActions } from "@/stores/machines";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ // A quick way between the machines linked here: narrows the sidebar to one
14
+ // machine's sessions and starts new chats there. Hidden until one is linked.
15
+ export function MachineSwitcher() {
16
+ const { data: hosts = [], isSuccess } = useHostsQuery();
17
+ const { scopeHostId } = useSnapshot(machinesUi);
18
+ const browser = useBrowserMachine();
19
+ const machines = hosts.filter((h) => h.id === "local" || h.linked);
20
+ const current = machines.find((h) => h.id === scopeHostId) ?? null;
21
+
22
+ // A machine that was unlinked since goes back to all of them.
23
+ useEffect(() => {
24
+ if (isSuccess && scopeHostId && !current) machinesUiActions.setScope(null);
25
+ }, [isSuccess, scopeHostId, current]);
26
+
27
+ if (machines.length < 2) return null;
28
+ return (
29
+ <DM.DropdownMenu>
30
+ <DM.DropdownMenuTrigger asChild>
31
+ <button
32
+ type="button"
33
+ aria-label={`Machine: ${current ? machineLabel(current) : "All machines"}`}
34
+ className={cn(
35
+ "border-border/70 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.04] flex h-11 max-w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs md:h-8",
36
+ current && "border-primary/40 bg-primary/10 text-primary"
37
+ )}
38
+ >
39
+ <Monitor className="h-3.5 w-3.5 shrink-0" />
40
+ <span className="truncate">
41
+ {current ? machineLabel(current) : "All machines"}
42
+ </span>
43
+ <ChevronDown className="h-3 w-3 shrink-0" />
44
+ </button>
45
+ </DM.DropdownMenuTrigger>
46
+ <DM.DropdownMenuContent align="start" className="max-w-80">
47
+ <DM.DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
48
+ Machine
49
+ </DM.DropdownMenuLabel>
50
+ {[null, ...machines].map((h) => (
51
+ <DM.DropdownMenuItem
52
+ key={h?.id ?? "all"}
53
+ className="min-h-11 md:min-h-8"
54
+ onClick={() => machinesUiActions.setScope(h?.id ?? null)}
55
+ >
56
+ <span className="flex-1 truncate">
57
+ {h ? machineLabel(h, browser.hostId) : "All machines"}
58
+ </span>
59
+ {(current?.id ?? null) === (h?.id ?? null) && (
60
+ <Check className="text-primary h-3.5 w-3.5" />
61
+ )}
62
+ </DM.DropdownMenuItem>
63
+ ))}
64
+ </DM.DropdownMenuContent>
65
+ </DM.DropdownMenu>
66
+ );
67
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { MachineSwitcher } from "./MachineSwitcher";
3
4
  import {
4
5
  Archive,
5
6
  Star,
@@ -189,6 +190,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
189
190
  ]}
190
191
  />
191
192
  </div>
193
+ <div className="w-full empty:hidden">
194
+ <MachineSwitcher />
195
+ </div>
192
196
  </div>
193
197
  );
194
198
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useMemo } from "react";
3
+ import { useCallback, useEffect, useMemo } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import type { Session } from "@/lib/db";
6
6
  import type { SessionGroup } from "@/lib/sidebar/shelves";
@@ -11,7 +11,11 @@ import {
11
11
  projectsInWorkspace,
12
12
  } from "@/lib/sidebar/shelves";
13
13
  import type { TaskState } from "@/lib/tasks/state";
14
- import { machineGroups, onMachineOnly } from "@/lib/sidebar/machines";
14
+ import {
15
+ inMachineScope,
16
+ machineGroups,
17
+ onMachineOnly,
18
+ } from "@/lib/sidebar/machines";
15
19
  import { orchestratorsToStart } from "@/lib/sidebar/orchestrator-row";
16
20
  import { useSessionsQuery } from "@/data/sessions";
17
21
  import { useLinkedHostNames } from "@/data/hosts";
@@ -23,6 +27,7 @@ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
23
27
  import { useOrchestratorsQuery } from "@/data/orchestrators";
24
28
  import { useClock } from "@/hooks/useMinuteTick";
25
29
  import { sidebarUi } from "@/stores/sidebarUi";
30
+ import { machinesUi, machinesUiActions } from "@/stores/machines";
26
31
  import type { SessionStatus } from "./SessionList.types";
27
32
 
28
33
  // One empty list for queries still loading, so memos below hold.
@@ -35,6 +40,10 @@ export function useSidebarData(
35
40
  sessionStatuses: Record<string, SessionStatus> | undefined
36
41
  ) {
37
42
  const ui = useSnapshot(sidebarUi);
43
+ const scope = useSnapshot(machinesUi).scopeHostId;
44
+ useEffect(() => {
45
+ machinesUiActions.hydrate();
46
+ }, []);
38
47
  // Wakes and 3-day settles are read from the clock.
39
48
  const now = useClock();
40
49
 
@@ -98,6 +107,7 @@ export function useSidebarData(
98
107
  sessions: sessions.filter(
99
108
  (s) =>
100
109
  !onMachineOnly(s, linked) &&
110
+ inMachineScope(s, scope) &&
101
111
  inWorkspace(
102
112
  s,
103
113
  (id) => byId.get(id)?.workspace_id,
@@ -122,7 +132,14 @@ export function useSidebarData(
122
132
  machines:
123
133
  project || ui.orchestratorId
124
134
  ? []
125
- : machineGroups({ ...common, sessions }, linked),
135
+ : machineGroups(
136
+ { ...common, sessions },
137
+ scope
138
+ ? Object.fromEntries(
139
+ Object.entries(linked).filter(([id]) => id === scope)
140
+ )
141
+ : linked
142
+ ),
126
143
  };
127
144
  }, [
128
145
  now,
@@ -138,6 +155,7 @@ export function useSidebarData(
138
155
  project,
139
156
  ui.query,
140
157
  ui.orchestratorId,
158
+ scope,
141
159
  ]);
142
160
 
143
161
  // Per workspace, how many of its project sessions are working: the
@@ -61,6 +61,23 @@ export function useDeleteHost() {
61
61
  });
62
62
  }
63
63
 
64
+ /** Rename a machine; "local" renames this one. */
65
+ export function useRenameHost() {
66
+ const queryClient = useQueryClient();
67
+ return useMutation({
68
+ mutationFn: async ({ id, name }: { id: string; name: string }) =>
69
+ json<{ name: string }>(
70
+ await fetch(`/api/hosts/${encodeURIComponent(id)}`, {
71
+ method: "PATCH",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({ name }),
74
+ })
75
+ ),
76
+ // The name is on the claims, the sidebar and the chips too.
77
+ onSuccess: () => queryClient.invalidateQueries(),
78
+ });
79
+ }
80
+
64
81
  export interface HostTestResult {
65
82
  ok: boolean;
66
83
  hostname?: string;
@@ -0,0 +1,54 @@
1
+ import { useEffect } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useSnapshot } from "valtio";
4
+ import { machinesUi, machinesUiActions } from "@/stores/machines";
5
+ import type { HostCatalogResponse } from "@/lib/machines/types";
6
+ import type { BrowserMachine } from "@/lib/machines/browser";
7
+ import { machineKeys } from "./keys";
8
+
9
+ /** The agents and models a machine reports, for the new-session form. */
10
+ export function useMachineCatalog(hostId: string) {
11
+ return useQuery({
12
+ queryKey: machineKeys.agents(hostId),
13
+ queryFn: async () => {
14
+ const res = await fetch(
15
+ `/api/hosts/${encodeURIComponent(hostId)}/agents`
16
+ );
17
+ const data = await res.json().catch(() => ({}));
18
+ if (!res.ok) throw new Error(data.error || "Couldn't list its agents");
19
+ return data as HostCatalogResponse;
20
+ },
21
+ staleTime: 60_000,
22
+ retry: false,
23
+ });
24
+ }
25
+
26
+ /**
27
+ * The machine this browser is on: what the person set, else what the server
28
+ * could tell from the connection, else unknown.
29
+ */
30
+ export function useBrowserMachine(): {
31
+ hostId: string | null;
32
+ detected: BrowserMachine["how"];
33
+ set: (hostId: string | null) => void;
34
+ } {
35
+ const snap = useSnapshot(machinesUi);
36
+ useEffect(() => {
37
+ machinesUiActions.hydrate();
38
+ }, []);
39
+ const { data } = useQuery({
40
+ queryKey: machineKeys.browser(),
41
+ queryFn: async () => {
42
+ const res = await fetch("/api/machine/browser");
43
+ if (!res.ok) throw new Error("Couldn't tell");
44
+ return (await res.json()) as BrowserMachine;
45
+ },
46
+ staleTime: Infinity,
47
+ retry: false,
48
+ });
49
+ return {
50
+ hostId: snap.browserHostId ?? data?.hostId ?? null,
51
+ detected: data?.how ?? null,
52
+ set: machinesUiActions.setBrowserHost,
53
+ };
54
+ }
@@ -0,0 +1,5 @@
1
+ export const machineKeys = {
2
+ all: ["machines"] as const,
3
+ agents: (hostId: string) => [...machineKeys.all, "agents", hostId] as const,
4
+ browser: () => [...machineKeys.all, "browser"] as const,
5
+ };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { machinesUi } from "@/stores/machines";
3
4
  import { useEffect, useRef } from "react";
4
5
  import { subscribe } from "valtio";
5
6
  import { FolderGit2 } from "lucide-react";
@@ -64,7 +65,11 @@ export function useDraftRequests(options: Options) {
64
65
  ? { agentType: s.agent_type, model: s.model, access: s.chat_access }
65
66
  : {};
66
67
  const project = projects.find((p) => p.id === projectId) ?? null;
67
- const draft = makeDraft(uuid(), project, carry, { openPr, workspaceId });
68
+ const draft = makeDraft(uuid(), project, carry, {
69
+ openPr,
70
+ workspaceId,
71
+ hostId: openPr ? null : machinesUi.scopeHostId,
72
+ });
68
73
  draftsActions.put(draft);
69
74
  show(draft.id);
70
75
  };
@@ -2,7 +2,7 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { toolDiff, toolTitle } from "../tools";
4
4
  import { TurnItems, type ToolStart } from "./turn-items";
5
- import { logTurnEnd, turnOutcome } from "./turn-outcome";
5
+ import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
6
6
 
7
7
  type P = Record<string, unknown>;
8
8
  const str = (v: unknown) => (typeof v === "string" ? v : "");
@@ -53,6 +53,7 @@ export class PiMapper {
53
53
  private error = "";
54
54
  // The last answer's text, its tool calls and who gave it, for the notice
55
55
  // when a turn ends without doing anything.
56
+ private toolsRun = 0;
56
57
  private last = { text: "", calls: 0, provider: "", model: "", api: "" };
57
58
  totals: UsageTotals = {
58
59
  costUsd: 0,
@@ -74,6 +75,8 @@ export class PiMapper {
74
75
  map(e: P): void {
75
76
  switch (e.type) {
76
77
  case "agent_start":
78
+ // A nudged run continues the same turn, so its tools still count.
79
+ if (!this.items.inTurn) this.toolsRun = 0;
77
80
  this.stopReason = this.error = "";
78
81
  this.last = { text: "", calls: 0, provider: "", model: "", api: "" };
79
82
  return this.items.startTurn();
@@ -85,6 +88,7 @@ export class PiMapper {
85
88
  case "message_end":
86
89
  return this.ended(e.message as P);
87
90
  case "tool_execution_start":
91
+ this.toolsRun++;
88
92
  return this.items.tool(
89
93
  str(e.toolCallId),
90
94
  piTool(str(e.toolName), e.args)
@@ -168,6 +172,18 @@ export class PiMapper {
168
172
  };
169
173
  }
170
174
 
175
+ // The run ran tools and ended without saying what they found.
176
+ needsSummary(): boolean {
177
+ return needsSummary({
178
+ toolsRun: this.toolsRun,
179
+ text: this.last.text,
180
+ toolCalls: this.last.calls,
181
+ finishReason: this.stopReason,
182
+ error: this.error || undefined,
183
+ aborted: this.aborted,
184
+ });
185
+ }
186
+
171
187
  // The run is over: Pi has nothing left to do for it.
172
188
  endTurn(): void {
173
189
  if (!this.items.inTurn) return;