@saadnvd1/agent-os 0.3.532 → 0.3.534

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 (93) hide show
  1. package/README.md +71 -3
  2. package/app/api/devices/connect/route.ts +79 -0
  3. package/app/api/features/pieces/[id]/release/route.ts +16 -0
  4. package/app/api/features/route.ts +8 -0
  5. package/app/api/recipes/[id]/carry/clear/route.ts +20 -0
  6. package/app/api/recipes/[id]/preview/route.ts +19 -0
  7. package/app/api/recipes/[id]/route.ts +51 -0
  8. package/app/api/recipes/[id]/run/route.ts +35 -0
  9. package/app/api/recipes/[id]/runs/route.ts +23 -0
  10. package/app/api/recipes/draft/route.ts +14 -0
  11. package/app/api/recipes/route.ts +35 -0
  12. package/app/api/tasks/[id]/plan-edits/route.ts +34 -0
  13. package/components/CommandPalette/useAppCommands.ts +20 -0
  14. package/components/Devices/ConnectCard.tsx +2 -15
  15. package/components/Features/FeaturesSection.tsx +100 -0
  16. package/components/Features/index.ts +1 -0
  17. package/components/Recipes/RecipeForm.tsx +251 -0
  18. package/components/Recipes/RecipePage.tsx +127 -0
  19. package/components/Recipes/RecipesPanel.tsx +225 -0
  20. package/components/Recipes/RunChip.tsx +16 -0
  21. package/components/Recipes/RunHistory.tsx +88 -0
  22. package/components/Recipes/RunSheet.tsx +84 -0
  23. package/components/Recipes/TimerFields.tsx +193 -0
  24. package/components/Recipes/format.ts +51 -0
  25. package/components/Recipes/index.ts +1 -0
  26. package/components/Schedules/SchedulesPanel.tsx +1 -0
  27. package/components/SessionList/RowSubtitle.tsx +16 -0
  28. package/components/SessionList/SessionRowMenu.tsx +29 -0
  29. package/components/SessionList/SidebarHeader.tsx +5 -0
  30. package/components/SessionList/WorkspaceSwitcher.tsx +20 -4
  31. package/components/Settings/SettingsDialog.tsx +9 -0
  32. package/components/Tasks/PlanReview.tsx +253 -0
  33. package/components/Tasks/TaskRow.tsx +19 -44
  34. package/components/Tasks/TasksDialog.tsx +2 -0
  35. package/components/Welcome/Block.tsx +26 -0
  36. package/components/Welcome/ConnectBlock.tsx +169 -0
  37. package/components/Welcome/DevicesStep.tsx +4 -26
  38. package/data/devices/queries.ts +21 -1
  39. package/data/features/index.ts +40 -0
  40. package/data/push/topics.ts +8 -0
  41. package/data/recipes/index.ts +129 -0
  42. package/data/tasks/index.ts +14 -0
  43. package/lib/away/run.ts +4 -1
  44. package/lib/connect/cli.ts +6 -18
  45. package/lib/connect/link.ts +35 -0
  46. package/lib/connect/serve.ts +12 -1
  47. package/lib/connect/view.ts +28 -0
  48. package/lib/db/changes.ts +8 -0
  49. package/lib/db/migrations.ts +127 -0
  50. package/lib/db/queries.ts +4 -1
  51. package/lib/db/types.ts +6 -0
  52. package/lib/models/plan-step.ts +67 -6
  53. package/lib/notify/copy.ts +28 -10
  54. package/lib/notify/push/events.ts +36 -2
  55. package/lib/notify/push/policy.ts +2 -0
  56. package/lib/notify/push-types.ts +7 -3
  57. package/lib/orchestrator/brief.ts +13 -0
  58. package/lib/orchestrator/conditions.ts +3 -1
  59. package/lib/orchestrator/deliver.ts +2 -0
  60. package/lib/orchestrator/digest.ts +27 -1
  61. package/lib/orchestrator/plan-pieces.ts +41 -0
  62. package/lib/orchestrator/recipe-tools.ts +159 -0
  63. package/lib/orchestrator/review.ts +2 -1
  64. package/lib/orchestrator/serve.ts +25 -0
  65. package/lib/orchestrator/tool-names.ts +5 -0
  66. package/lib/orchestrator/tool-schemas.ts +54 -0
  67. package/lib/orchestrator/tools.ts +10 -0
  68. package/lib/plans/features.ts +200 -0
  69. package/lib/plans/pieces.ts +366 -0
  70. package/lib/plans/release.ts +323 -0
  71. package/lib/plans/view.ts +85 -0
  72. package/lib/recipes/api.ts +19 -0
  73. package/lib/recipes/chip.ts +40 -0
  74. package/lib/recipes/compose.ts +78 -0
  75. package/lib/recipes/draft.ts +93 -0
  76. package/lib/recipes/index.ts +54 -0
  77. package/lib/recipes/result.ts +59 -0
  78. package/lib/recipes/run.ts +202 -0
  79. package/lib/recipes/settle.ts +465 -0
  80. package/lib/recipes/store.ts +378 -0
  81. package/lib/recipes/test-fixtures.ts +49 -0
  82. package/lib/recipes/timer.ts +107 -0
  83. package/lib/recipes/types.ts +116 -0
  84. package/lib/schedules/index.ts +4 -1
  85. package/lib/schedules/start.ts +33 -9
  86. package/lib/schedules/store.ts +6 -2
  87. package/lib/security/demo.ts +2 -0
  88. package/lib/settings-url.ts +1 -0
  89. package/lib/tasks/loop-types.ts +23 -0
  90. package/lib/tasks/plan-view.ts +38 -1
  91. package/package.json +1 -1
  92. package/server.ts +2 -0
  93. package/stores/recipesUi.ts +53 -0
@@ -0,0 +1,253 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Textarea } from "@/components/ui/textarea";
6
+ import { Markdown } from "@/components/Chat/Markdown";
7
+ import { useApprovePlan, useDropTask, usePlanEdits } from "@/data/tasks";
8
+ import type { PlanPieceView } from "@/lib/tasks/loop-types";
9
+ import type { TaskView } from "@/lib/tasks";
10
+ import { cn } from "@/lib/utils";
11
+
12
+ const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥"];
13
+ const REASON: Record<string, string> = {
14
+ a: "spans repositories",
15
+ b: "must be live before the other can merge",
16
+ c: "needs you mid-way",
17
+ d: "must be revertible on its own",
18
+ };
19
+
20
+ function Piece({
21
+ p,
22
+ edits,
23
+ busy,
24
+ onEdit,
25
+ }: {
26
+ p: PlanPieceView;
27
+ edits: { merge: number[]; drop: number[] };
28
+ busy: boolean;
29
+ onEdit: (merge: number[], drop: number[]) => void;
30
+ }) {
31
+ const mark = CIRCLED[p.position - 1];
32
+ const off = p.edit !== null;
33
+ const without = (list: number[]) => list.filter((n) => n !== p.position);
34
+ return (
35
+ <li className={cn("space-y-1 px-3 py-2.5", off && "opacity-60")}>
36
+ <div className="flex items-baseline gap-2">
37
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">
38
+ {mark} {p.name}
39
+ </span>
40
+ <span className="text-muted-foreground shrink-0 text-xs">
41
+ {p.project}
42
+ </span>
43
+ </div>
44
+ <p className="text-muted-foreground text-xs">{p.slice.split("\n")[0]}</p>
45
+ {p.afterPosition && (
46
+ <p className="text-xs">
47
+ after {CIRCLED[p.afterPosition - 1]} is {p.afterKind}
48
+ </p>
49
+ )}
50
+ {p.reasons.length > 0 && (
51
+ <p className="text-xs">
52
+ {p.reasons.map((r) => `(${r}) ${REASON[r]}`).join("; ")}
53
+ </p>
54
+ )}
55
+ {p.owns.length > 0 && (
56
+ <p className="text-muted-foreground text-xs">
57
+ owns: {p.owns.join(", ")}
58
+ </p>
59
+ )}
60
+ {p.position > 1 && (
61
+ <div className="flex flex-wrap items-center gap-2 pt-1">
62
+ {off ? (
63
+ <>
64
+ <span className="bg-foreground/10 rounded-full px-2 py-0.5 text-[11px]">
65
+ {p.edit === "merge"
66
+ ? `Merged into ${CIRCLED[p.position - 2]}`
67
+ : "Dropped"}
68
+ </span>
69
+ <Button
70
+ size="sm"
71
+ variant="ghost"
72
+ className="h-11 md:h-8"
73
+ disabled={busy}
74
+ onClick={() =>
75
+ onEdit(without(edits.merge), without(edits.drop))
76
+ }
77
+ >
78
+ Undo
79
+ </Button>
80
+ </>
81
+ ) : (
82
+ <>
83
+ <Button
84
+ size="sm"
85
+ variant="outline"
86
+ className="h-11 md:h-8"
87
+ disabled={busy || p.forced}
88
+ title={
89
+ p.forced ? "Split because it spans repositories" : undefined
90
+ }
91
+ onClick={() => onEdit([...edits.merge, p.position], edits.drop)}
92
+ >
93
+ Merge into {CIRCLED[p.position - 2]}
94
+ </Button>
95
+ <Button
96
+ size="sm"
97
+ variant="ghost"
98
+ className="h-11 md:h-8"
99
+ disabled={busy}
100
+ onClick={() => onEdit(edits.merge, [...edits.drop, p.position])}
101
+ >
102
+ Drop
103
+ </Button>
104
+ </>
105
+ )}
106
+ </div>
107
+ )}
108
+ </li>
109
+ );
110
+ }
111
+
112
+ // A plan waiting for a decision: how it splits, the plan itself, and
113
+ // Reject, Approve with a note, Approve.
114
+ export function PlanReview({ task }: { task: TaskView }) {
115
+ const approve = useApprovePlan();
116
+ const drop = useDropTask();
117
+ const planEdits = usePlanEdits();
118
+ const [showPlan, setShowPlan] = useState(false);
119
+ const [noting, setNoting] = useState(false);
120
+ const [note, setNote] = useState("");
121
+ const [rejecting, setRejecting] = useState(false);
122
+ const pieces = task.plan?.pieces;
123
+ const edits = {
124
+ merge:
125
+ pieces?.pieces.filter((p) => p.edit === "merge").map((p) => p.position) ??
126
+ [],
127
+ drop:
128
+ pieces?.pieces.filter((p) => p.edit === "drop").map((p) => p.position) ??
129
+ [],
130
+ };
131
+ const error =
132
+ approve.error?.message || drop.error?.message || planEdits.error?.message;
133
+ return (
134
+ <div className="space-y-3">
135
+ {pieces && pieces.pieces.length > 1 ? (
136
+ <div className="space-y-1">
137
+ <p className="label-mono text-muted-foreground">
138
+ Pieces ({pieces.pieces.length})
139
+ </p>
140
+ <ul className="divide-border/60 border-border/60 divide-y rounded-lg border">
141
+ {pieces.pieces.map((p) => (
142
+ <Piece
143
+ key={p.position}
144
+ p={p}
145
+ edits={edits}
146
+ busy={planEdits.isPending}
147
+ onEdit={(merge, dropped) =>
148
+ planEdits.mutate({ id: task.id, merge, drop: dropped })
149
+ }
150
+ />
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ ) : (
155
+ <p className="text-muted-foreground text-xs">1 piece</p>
156
+ )}
157
+ {pieces?.folded.map((f) => (
158
+ <p key={f.name} className="text-xs text-amber-700 dark:text-amber-300">
159
+ &quot;{f.name}&quot; folded into {CIRCLED[f.into - 1]}: {f.why}
160
+ </p>
161
+ ))}
162
+ {pieces?.warnings.map((w) => (
163
+ <p key={w} className="text-xs text-amber-700 dark:text-amber-300">
164
+ {w}
165
+ </p>
166
+ ))}
167
+ <button
168
+ type="button"
169
+ aria-expanded={showPlan}
170
+ className="text-primary min-h-11 text-xs font-medium md:min-h-8"
171
+ onClick={() => setShowPlan((v) => !v)}
172
+ >
173
+ {showPlan ? "Hide the plan" : "Show the plan"}
174
+ </button>
175
+ {showPlan && (
176
+ <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
177
+ {task.plan?.text ? (
178
+ <Markdown text={task.plan.text} />
179
+ ) : (
180
+ <p className="text-muted-foreground text-xs">
181
+ No plan file was written.
182
+ </p>
183
+ )}
184
+ {task.plan?.truncated && (
185
+ <p className="text-muted-foreground mt-2 font-mono text-[11px]">
186
+ Shortened; the whole plan is {task.plan.path} in the worktree.
187
+ </p>
188
+ )}
189
+ </div>
190
+ )}
191
+ {noting && (
192
+ <div className="space-y-2">
193
+ <Textarea
194
+ rows={3}
195
+ value={note}
196
+ placeholder="A change to the plan (recorded as a scope change)"
197
+ onChange={(e) => setNote(e.target.value)}
198
+ />
199
+ <Button
200
+ size="sm"
201
+ className="h-11 md:h-8"
202
+ disabled={approve.isPending || !note.trim()}
203
+ onClick={() =>
204
+ approve.mutate({ id: task.id, message: note.trim() })
205
+ }
206
+ >
207
+ Approve with this note
208
+ </Button>
209
+ </div>
210
+ )}
211
+ <div className="flex flex-wrap gap-2">
212
+ {rejecting ? (
213
+ <Button
214
+ size="sm"
215
+ variant="destructive"
216
+ className="h-11 md:h-8"
217
+ disabled={drop.isPending}
218
+ onClick={() => drop.mutate(task.id)}
219
+ >
220
+ Drop this plan
221
+ </Button>
222
+ ) : (
223
+ <Button
224
+ size="sm"
225
+ variant="ghost"
226
+ className="h-11 md:h-8"
227
+ onClick={() => setRejecting(true)}
228
+ >
229
+ Reject
230
+ </Button>
231
+ )}
232
+ <Button
233
+ size="sm"
234
+ variant="outline"
235
+ className="h-11 md:h-8"
236
+ aria-expanded={noting}
237
+ onClick={() => setNoting((v) => !v)}
238
+ >
239
+ Approve with note…
240
+ </Button>
241
+ <Button
242
+ size="sm"
243
+ className="h-11 md:h-8"
244
+ disabled={approve.isPending}
245
+ onClick={() => approve.mutate({ id: task.id })}
246
+ >
247
+ {approve.isPending ? "Approving..." : "Approve"}
248
+ </Button>
249
+ </div>
250
+ {error && <p className="text-destructive text-xs">{error}</p>}
251
+ </div>
252
+ );
253
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import {
5
+ ChefHat,
5
6
  ExternalLink,
6
7
  GitMerge,
7
8
  KanbanSquare,
@@ -10,17 +11,13 @@ import {
10
11
  import { Button } from "@/components/ui/button";
11
12
  import type { TaskView } from "@/lib/tasks";
12
13
  import { isFinished } from "@/lib/tasks/state";
13
- import {
14
- useApprovePlan,
15
- useArmTask,
16
- useDropTask,
17
- useSignOffTask,
18
- } from "@/data/tasks";
19
- import { Markdown } from "@/components/Chat/Markdown";
14
+ import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
20
15
  import { tmuxAttachActions } from "@/stores/tmuxAttach";
21
16
  import { tasksUiActions } from "@/stores/tasksUi";
17
+ import { recipesUiActions } from "@/stores/recipesUi";
22
18
  import { cn } from "@/lib/utils";
23
19
  import { SimulatorIndicator } from "@/components/Simulator";
20
+ import { PlanReview } from "./PlanReview";
24
21
  import { MoveTaskButton } from "./MoveTaskButton";
25
22
  import { ResumeOnModel } from "./ResumeOnModel";
26
23
  import { TASK_STATE as STATE } from "./task-state-labels";
@@ -30,8 +27,6 @@ export function TaskRow({ task }: { task: TaskView }) {
30
27
  const signOff = useSignOffTask();
31
28
  const drop = useDropTask();
32
29
  const arm = useArmTask();
33
- const approve = useApprovePlan();
34
- const [showPlan, setShowPlan] = useState(false);
35
30
  const [confirmDrop, setConfirmDrop] = useState(false);
36
31
  const [moveError, setMoveError] = useState<string | null>(null);
37
32
  const live = !isFinished(task.state);
@@ -39,7 +34,6 @@ export function TaskRow({ task }: { task: TaskView }) {
39
34
  signOff.error?.message ||
40
35
  drop.error?.message ||
41
36
  arm.error?.message ||
42
- approve.error?.message ||
43
37
  moveError ||
44
38
  task.hostError;
45
39
  const settingUp = live && task.setup?.status === "running";
@@ -159,26 +153,6 @@ export function TaskRow({ task }: { task: TaskView }) {
159
153
  {task.armed ? "Armed" : "Arm merge"}
160
154
  </Button>
161
155
  )}
162
- {task.state === "plan-ready" && (
163
- <>
164
- <Button
165
- size="sm"
166
- variant="outline"
167
- aria-expanded={showPlan}
168
- onClick={() => setShowPlan((v) => !v)}
169
- >
170
- {showPlan ? "Hide plan" : "View plan"}
171
- </Button>
172
- <Button
173
- size="sm"
174
- className="h-11 md:h-8"
175
- disabled={approve.isPending}
176
- onClick={() => approve.mutate({ id: task.id })}
177
- >
178
- {approve.isPending ? "Approving..." : "Approve"}
179
- </Button>
180
- </>
181
- )}
182
156
  {task.state === "review" && (
183
157
  <Button
184
158
  size="sm"
@@ -190,20 +164,21 @@ export function TaskRow({ task }: { task: TaskView }) {
190
164
  )}
191
165
  </div>
192
166
  )}
193
- {live && task.state === "plan-ready" && showPlan && (
194
- <div className="bg-foreground/[0.04] max-h-80 overflow-y-auto rounded-lg p-3 text-sm">
195
- {task.plan?.text ? (
196
- <Markdown text={task.plan.text} />
197
- ) : (
198
- <p className="text-muted-foreground text-xs">
199
- No plan file was written.
200
- </p>
201
- )}
202
- {task.plan?.truncated && (
203
- <p className="text-muted-foreground mt-2 font-mono text-[11px]">
204
- Shortened; the whole plan is {task.plan.path} in the worktree.
205
- </p>
206
- )}
167
+ {live && task.state === "plan-ready" && <PlanReview task={task} />}
168
+ {!live && !task.hostId && (
169
+ <div>
170
+ <Button
171
+ size="sm"
172
+ variant="ghost"
173
+ className="text-muted-foreground h-11 md:h-8"
174
+ onClick={() => {
175
+ recipesUiActions.saveSessionAs(task.id);
176
+ tasksUiActions.setPanelOpen(false);
177
+ }}
178
+ >
179
+ <ChefHat className="h-3.5 w-3.5" />
180
+ Save as recipe
181
+ </Button>
207
182
  </div>
208
183
  )}
209
184
  {error && <p className="text-destructive text-xs">{error}</p>}
@@ -16,6 +16,7 @@ import { newDraft } from "@/stores/drafts";
16
16
  import { TaskRow } from "./TaskRow";
17
17
  import { BoardTodoSection } from "@/components/LumifyHub";
18
18
  import { StacksSection } from "@/components/Stacks";
19
+ import { FeaturesSection } from "@/components/Features";
19
20
 
20
21
  export function TasksDialog() {
21
22
  const { panelOpen } = useSnapshot(tasksUi);
@@ -57,6 +58,7 @@ export function TasksDialog() {
57
58
  <TaskRow key={t.id} task={t} />
58
59
  ))}
59
60
  </div>
61
+ <FeaturesSection />
60
62
  <StacksSection />
61
63
  <BoardTodoSection />
62
64
  {done.length > 0 && (
@@ -0,0 +1,26 @@
1
+ import type { Bell } from "lucide-react";
2
+
3
+ export function Block({
4
+ icon: Icon,
5
+ title,
6
+ detail,
7
+ children,
8
+ }: {
9
+ icon: typeof Bell;
10
+ title: string;
11
+ detail: string;
12
+ children: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
16
+ <div className="flex items-start gap-3">
17
+ <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
18
+ <div className="min-w-0 flex-1">
19
+ <h3 className="text-sm font-medium">{title}</h3>
20
+ <p className="text-muted-foreground text-xs">{detail}</p>
21
+ </div>
22
+ </div>
23
+ {children}
24
+ </section>
25
+ );
26
+ }
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ExternalLink, Globe } from "lucide-react";
5
+ import { CopyButton } from "@/components/Chat/CopyButton";
6
+ import { canManageDevices } from "@/components/Devices/DevicesPanel";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Switch } from "@/components/ui/switch";
10
+ import {
11
+ useDevicesQuery,
12
+ useLinkConnect,
13
+ useNetworkQuery,
14
+ useUpdateNetwork,
15
+ } from "@/data/devices";
16
+ import { CONNECT_SITE, CONNECT_STATE, connectView } from "@/lib/connect/view";
17
+ import { Block } from "./Block";
18
+
19
+ const COMMAND = "agent-os connect";
20
+
21
+ function Command() {
22
+ return (
23
+ <div className="text-muted-foreground flex items-center gap-1 text-xs">
24
+ <span>Or run</span>
25
+ <code className="text-foreground font-mono">{COMMAND}</code>
26
+ <span>in a terminal.</span>
27
+ <CopyButton text={COMMAND} label="Copy command" />
28
+ </div>
29
+ );
30
+ }
31
+
32
+ function LinkForm() {
33
+ const [code, setCode] = useState("");
34
+ const link = useLinkConnect();
35
+ const submit = () => {
36
+ if (code.trim() && !link.isPending) link.mutate(code.trim());
37
+ };
38
+ return (
39
+ <div className="flex min-w-0 flex-col gap-2">
40
+ <p className="text-muted-foreground text-xs">
41
+ Get a code at{" "}
42
+ <a
43
+ href={CONNECT_SITE}
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="text-foreground inline-flex items-center gap-1 underline underline-offset-2"
47
+ >
48
+ runagentos.com
49
+ <ExternalLink className="size-3" />
50
+ </a>{" "}
51
+ (Connect a machine), then paste it here.
52
+ </p>
53
+ <form
54
+ className="flex gap-2"
55
+ onSubmit={(e) => {
56
+ e.preventDefault();
57
+ submit();
58
+ }}
59
+ >
60
+ <Input
61
+ value={code}
62
+ onChange={(e) => setCode(e.target.value)}
63
+ aria-label="Link code"
64
+ placeholder="Link code"
65
+ autoComplete="off"
66
+ autoCapitalize="off"
67
+ spellCheck={false}
68
+ className="min-h-11 flex-1 font-mono sm:min-h-9"
69
+ />
70
+ <Button
71
+ type="submit"
72
+ variant="outline"
73
+ className="min-h-11 sm:min-h-9"
74
+ disabled={!code.trim() || link.isPending}
75
+ >
76
+ {link.isPending ? "Linking…" : "Link"}
77
+ </Button>
78
+ </form>
79
+ {link.error && (
80
+ <p className="text-destructive text-xs">{link.error.message}</p>
81
+ )}
82
+ <Command />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /** This machine's Connect: link it, then turn it on or off. */
88
+ export function ConnectBlock() {
89
+ // The running server follows a change within a couple of seconds.
90
+ const [wanted, setWanted] = useState<boolean | null>(null);
91
+ const network = useNetworkQuery(true, wanted === null ? false : 2000);
92
+ const devices = useDevicesQuery(true);
93
+ const update = useUpdateNetwork();
94
+ const canManage = canManageDevices(devices.data?.current.via);
95
+ const view = network.data ? connectView(network.data.connect) : null;
96
+
97
+ useEffect(() => {
98
+ if (wanted === null) return;
99
+ const t = setTimeout(() => setWanted(null), 6000);
100
+ return () => clearTimeout(t);
101
+ }, [wanted]);
102
+
103
+ return (
104
+ <Block
105
+ icon={Globe}
106
+ title="Use it away from home"
107
+ detail="Connect gives this machine a private address at runagentos.com, so your phone reaches it from anywhere. Nothing to open on your router."
108
+ >
109
+ {network.isPending ? (
110
+ <div className="bg-muted/40 h-12 animate-pulse rounded-lg" />
111
+ ) : network.isError || !view ? (
112
+ <p className="text-destructive text-xs">
113
+ {network.error?.message ?? "Couldn't read Connect."}
114
+ </p>
115
+ ) : !view.linked ? (
116
+ canManage ? (
117
+ <LinkForm />
118
+ ) : (
119
+ <div className="flex flex-col gap-2">
120
+ <p className="text-muted-foreground text-xs">
121
+ Link it on the machine running AgentOS.
122
+ </p>
123
+ <Command />
124
+ </div>
125
+ )
126
+ ) : (
127
+ <div className="flex items-start gap-3">
128
+ <div className="min-w-0 flex-1">
129
+ <p className="text-sm font-medium">
130
+ Linked{" "}
131
+ <span className="text-muted-foreground font-normal">
132
+ · {CONNECT_STATE[network.data.connect.state]}
133
+ </span>
134
+ </p>
135
+ <p className="text-muted-foreground text-xs break-all">
136
+ {view.hostname ? (
137
+ <>
138
+ Reachable at{" "}
139
+ <span className="text-foreground font-mono">
140
+ https://{view.hostname}
141
+ </span>
142
+ </>
143
+ ) : (
144
+ "The address appears once the tunnel starts."
145
+ )}
146
+ {network.data.connect.reason &&
147
+ network.data.connect.state !== "off"
148
+ ? ` ${network.data.connect.reason.replace(/\.$/, "")}.`
149
+ : ""}
150
+ </p>
151
+ {update.error && (
152
+ <p className="text-destructive text-xs">{update.error.message}</p>
153
+ )}
154
+ </div>
155
+ <Switch
156
+ checked={wanted ?? view.on}
157
+ disabled={!view.canToggle || !canManage}
158
+ onCheckedChange={(connect) => {
159
+ setWanted(connect);
160
+ update.mutate({ connect });
161
+ }}
162
+ className="mt-0.5"
163
+ aria-label="Connect"
164
+ />
165
+ </div>
166
+ )}
167
+ </Block>
168
+ );
169
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { Bell, Laptop, Smartphone } from "lucide-react";
5
+ import { Block } from "./Block";
6
+ import { ConnectBlock } from "./ConnectBlock";
5
7
  import { AddDevicePanel } from "@/components/Devices/AddDevicePanel";
6
8
  import {
7
9
  NotificationsSection,
@@ -9,31 +11,6 @@ import {
9
11
  } from "@/components/Settings/NotificationsSection";
10
12
  import { Button } from "@/components/ui/button";
11
13
 
12
- function Block({
13
- icon: Icon,
14
- title,
15
- detail,
16
- children,
17
- }: {
18
- icon: typeof Bell;
19
- title: string;
20
- detail: string;
21
- children: React.ReactNode;
22
- }) {
23
- return (
24
- <section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
25
- <div className="flex items-start gap-3">
26
- <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
27
- <div className="min-w-0 flex-1">
28
- <h3 className="text-sm font-medium">{title}</h3>
29
- <p className="text-muted-foreground text-xs">{detail}</p>
30
- </div>
31
- </div>
32
- {children}
33
- </section>
34
- );
35
- }
36
-
37
14
  /**
38
15
  * Pair a phone, link another machine, set up notifications. Every part is
39
16
  * optional and lives in Settings too.
@@ -51,7 +28,7 @@ export function DevicesStep({
51
28
  <Block
52
29
  icon={Smartphone}
53
30
  title="Pair your phone"
54
- detail="Scan a code to use AgentOS from your phone."
31
+ detail="Scan a code to use AgentOS from your phone on this network."
55
32
  >
56
33
  {pairing ? (
57
34
  <AddDevicePanel onDone={() => setPairing(false)} />
@@ -65,6 +42,7 @@ export function DevicesStep({
65
42
  </Button>
66
43
  )}
67
44
  </Block>
45
+ <ConnectBlock />
68
46
  <Block
69
47
  icon={Laptop}
70
48
  title="Link another machine"
@@ -26,12 +26,32 @@ export function useDevicesQuery(enabled = true) {
26
26
  });
27
27
  }
28
28
 
29
- export function useNetworkQuery(enabled = true) {
29
+ export function useNetworkQuery(
30
+ enabled = true,
31
+ refetchMs: number | false = false
32
+ ) {
30
33
  return useQuery({
31
34
  queryKey: deviceKeys.network(),
32
35
  queryFn: async () =>
33
36
  json<NetworkState>(await fetch("/api/devices/network")),
34
37
  enabled,
38
+ refetchInterval: refetchMs,
39
+ });
40
+ }
41
+
42
+ /** Links this machine to Connect with a code from runagentos.com. */
43
+ export function useLinkConnect() {
44
+ const queryClient = useQueryClient();
45
+ return useMutation({
46
+ mutationFn: async (code: string) =>
47
+ json<NetworkState>(
48
+ await fetch("/api/devices/connect", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ code }),
52
+ })
53
+ ),
54
+ onSuccess: (data) => queryClient.setQueryData(deviceKeys.network(), data),
35
55
  });
36
56
  }
37
57