@saadnvd1/agent-os 0.3.520 → 0.3.521

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.
package/README.md CHANGED
@@ -213,6 +213,17 @@ its WSL note, and Windows reached it at both `localhost:3011` and
213
213
  `wslview`/`explorer.exe`, mirrored networking, `agent-os enable` under
214
214
  systemd, the idle-timeout settings above, and Debian or other distros.
215
215
 
216
+ ### First run
217
+
218
+ On a fresh install (no sessions, no projects of your own) the web app opens a
219
+ short setup at `/?welcome`: it shows each agent CLI as ready, needing a
220
+ sign-in, or missing, installs or signs one in from an embedded terminal, adds
221
+ a first project (a folder, a clone or an empty one), then opens a session in
222
+ it. Every step after the first can be skipped. It is remembered on the server,
223
+ so a phone you pair later doesn't see it, and an install that already has work
224
+ never does. Reopen it from Settings → Setup (**Run setup again**) or at
225
+ `/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
226
+
216
227
  ### Supported agents
217
228
 
218
229
  | Agent | Chat | Resume | Fork | Auto-approve |
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { demoMode } from "@/lib/security/demo";
3
+ import { completeWelcome, readWelcomeGate } from "@/lib/welcome";
4
+
5
+ // Whether the first-run welcome should open on this server. An install
6
+ // already in use is marked done here, so emptying it later doesn't bring it back.
7
+ export async function GET() {
8
+ return NextResponse.json(readWelcomeGate(demoMode()));
9
+ }
10
+
11
+ // { action: "complete" }: setup finished or skipped.
12
+ export async function POST(request: NextRequest) {
13
+ try {
14
+ const body = await request.json();
15
+ if (body?.action !== "complete") throw new Error("Unknown action");
16
+ completeWelcome(new Date().toISOString(), undefined, true);
17
+ return NextResponse.json(readWelcomeGate(demoMode()));
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 400 });
21
+ }
22
+ }
package/app/page.tsx CHANGED
@@ -78,6 +78,11 @@ const SettingsDialog = dynamic(
78
78
  ),
79
79
  { ssr: false }
80
80
  );
81
+ const WelcomeDialog = dynamic(
82
+ () =>
83
+ import("@/components/Welcome/WelcomeDialog").then((m) => m.WelcomeDialog),
84
+ { ssr: false }
85
+ );
81
86
  const MessagesDialog = dynamic(
82
87
  () => import("@/components/Bus/MessagesDialog").then((m) => m.MessagesDialog),
83
88
  { ssr: false }
@@ -122,6 +127,7 @@ const AppDialogs = memo(function AppDialogs() {
122
127
  return (
123
128
  <>
124
129
  <TasksDialog />
130
+ <WelcomeDialog />
125
131
  <AddProjectDialog />
126
132
  <MessagesDialog />
127
133
  <ArchivedDialog />
@@ -0,0 +1,6 @@
1
+ import { redirect } from "next/navigation";
2
+
3
+ // Reopens the first-run welcome.
4
+ export default function WelcomePage() {
5
+ redirect("/?welcome=agents");
6
+ }
@@ -29,8 +29,8 @@ export function AddProjectDialog() {
29
29
  const { open } = useSnapshot(addProjectUi);
30
30
  const hostNames = useHostNames();
31
31
  const done = (projectId: string) => {
32
- addProjectActions.close();
33
- newDraft({ kind: "project", projectId });
32
+ if (!addProjectActions.finish(projectId))
33
+ newDraft({ kind: "project", projectId });
34
34
  };
35
35
 
36
36
  return (
@@ -11,6 +11,7 @@ import {
11
11
  Cpu,
12
12
  GitMerge,
13
13
  LayoutGrid,
14
+ ListChecks,
14
15
  Smartphone,
15
16
  X,
16
17
  type LucideIcon,
@@ -34,6 +35,7 @@ import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
34
35
  import { cn } from "@/lib/utils";
35
36
  import { schedulesUiActions } from "@/stores/schedulesUi";
36
37
  import { settingsUi, settingsUiActions } from "@/stores/settingsUi";
38
+ import { SetupSection } from "./SetupSection";
37
39
  import {
38
40
  NotificationsSection,
39
41
  type BrowserNotifications,
@@ -78,6 +80,11 @@ export const SECTION_INFO: Record<
78
80
  hint: "Where sessions spent tokens and repeated work",
79
81
  icon: ClipboardList,
80
82
  },
83
+ setup: {
84
+ label: "Setup",
85
+ hint: "Agents on this machine, first-run setup",
86
+ icon: ListChecks,
87
+ },
81
88
  };
82
89
 
83
90
  function SectionBody({
@@ -102,6 +109,8 @@ function SectionBody({
102
109
  return <SchedulesPanel />;
103
110
  case "audits":
104
111
  return <AuditsPanel />;
112
+ case "setup":
113
+ return <SetupSection />;
105
114
  }
106
115
  }
107
116
 
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { AgentList } from "@/components/Welcome/AgentList";
5
+ import { settingsUiActions } from "@/stores/settingsUi";
6
+ import { welcomeUiActions } from "@/stores/welcomeUi";
7
+
8
+ // What this machine has for running agents, and the first-run setup again.
9
+ export function SetupSection() {
10
+ return (
11
+ <div className="flex flex-col gap-4">
12
+ <div className="bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5">
13
+ <div className="min-w-0 flex-1">
14
+ <p className="text-sm font-medium">First-run setup</p>
15
+ <p className="text-muted-foreground text-xs">
16
+ Agents, your first project, and a first session.
17
+ </p>
18
+ </div>
19
+ <Button
20
+ size="sm"
21
+ variant="outline"
22
+ className="min-h-11 sm:min-h-8"
23
+ onClick={() => {
24
+ settingsUiActions.close();
25
+ welcomeUiActions.open("agents");
26
+ }}
27
+ >
28
+ Run setup again
29
+ </Button>
30
+ </div>
31
+ <div className="flex flex-col gap-2">
32
+ <h3 className="text-sm font-medium">Agents on this machine</h3>
33
+ <AgentList />
34
+ </div>
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { RefreshCw } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useAgentStatusQuery, useRecheckAgents } from "@/data/agents";
7
+ import { FEATURED_AGENTS } from "@/lib/agents/setup-commands";
8
+ import { getAllProviderDefinitions } from "@/lib/providers/registry";
9
+ import { AgentRow, type AgentRun } from "./AgentRow";
10
+ import { SetupTerminal } from "./SetupTerminal";
11
+
12
+ /**
13
+ * Every agent CLI on this machine with what it needs, and an embedded
14
+ * terminal to install or sign one in. Checks again when it opens and when a
15
+ * terminal is closed, never on a timer.
16
+ */
17
+ export function AgentList() {
18
+ const { data: agents } = useAgentStatusQuery();
19
+ const recheck = useRecheckAgents();
20
+ const [run, setRun] = useState<(AgentRun & { key: number }) | null>(null);
21
+ const [all, setAll] = useState(false);
22
+ const { mutate } = recheck;
23
+
24
+ useEffect(() => mutate(), [mutate]);
25
+
26
+ const providers = getAllProviderDefinitions().filter((p) => p.cli);
27
+ const shown = providers.filter((p) => all || FEATURED_AGENTS.includes(p.id));
28
+ const hidden = providers.length - shown.length;
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3">
32
+ <ul className="flex flex-col gap-2">
33
+ {shown.map((p) => (
34
+ <AgentRow
35
+ key={p.id}
36
+ provider={p}
37
+ probe={agents?.[p.id]}
38
+ onRun={(r) => setRun({ ...r, key: Date.now() })}
39
+ />
40
+ ))}
41
+ </ul>
42
+ {run && (
43
+ <SetupTerminal
44
+ key={run.key}
45
+ title={run.title}
46
+ command={run.command}
47
+ onDone={() => {
48
+ setRun(null);
49
+ mutate();
50
+ }}
51
+ />
52
+ )}
53
+ <div className="flex items-center justify-between gap-2">
54
+ {hidden > 0 ? (
55
+ <Button size="sm" variant="ghost" onClick={() => setAll(true)}>
56
+ Show {hidden} more
57
+ </Button>
58
+ ) : (
59
+ <span />
60
+ )}
61
+ <Button
62
+ size="sm"
63
+ variant="ghost"
64
+ disabled={recheck.isPending}
65
+ onClick={() => mutate()}
66
+ >
67
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
68
+ Check again
69
+ </Button>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { Check, Download, LogIn } from "lucide-react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { AgentProbe } from "@/lib/agents/probe";
7
+ import { agentState, SETUP_COMMANDS } from "@/lib/agents/setup-commands";
8
+ import type { ProviderDefinition } from "@/lib/providers/registry";
9
+
10
+ const LABEL = {
11
+ missing: "Not installed",
12
+ "needs-login": "Needs sign-in",
13
+ ready: "Ready",
14
+ installed: "Installed",
15
+ } as const;
16
+
17
+ export type AgentRun = { title: string; command: string };
18
+
19
+ /** One agent CLI: its state, and the install or sign-in it needs. */
20
+ export function AgentRow({
21
+ provider,
22
+ probe,
23
+ onRun,
24
+ }: {
25
+ provider: ProviderDefinition;
26
+ probe: AgentProbe | undefined;
27
+ onRun: (run: AgentRun) => void;
28
+ }) {
29
+ const state = probe ? agentState(probe) : null;
30
+ const commands = SETUP_COMMANDS[provider.id];
31
+ return (
32
+ <li className="bg-muted/40 flex min-h-11 items-center gap-3 rounded-lg px-3 py-2">
33
+ <div className="min-w-0 flex-1">
34
+ <p className="truncate text-sm font-medium">{provider.name}</p>
35
+ <p className="text-muted-foreground truncate text-xs">
36
+ {probe?.version
37
+ ? `v${probe.version}`
38
+ : (probe?.hint ?? provider.description)}
39
+ </p>
40
+ </div>
41
+ {!state && <Badge variant="outline">Checking</Badge>}
42
+ {state === "ready" && (
43
+ <Badge variant="success">
44
+ <Check className="mr-1 size-3" />
45
+ {LABEL.ready}
46
+ </Badge>
47
+ )}
48
+ {state === "installed" && <Badge variant="outline">{LABEL[state]}</Badge>}
49
+ {state === "needs-login" && (
50
+ <>
51
+ <Badge variant="secondary">{LABEL[state]}</Badge>
52
+ {commands?.signIn && (
53
+ <Button
54
+ size="sm"
55
+ variant="outline"
56
+ className="min-h-11 sm:min-h-8"
57
+ onClick={() =>
58
+ onRun({
59
+ title: `Sign in to ${provider.name}`,
60
+ command: commands.signIn!,
61
+ })
62
+ }
63
+ >
64
+ <LogIn />
65
+ Sign in
66
+ </Button>
67
+ )}
68
+ </>
69
+ )}
70
+ {state === "missing" &&
71
+ (commands?.install ? (
72
+ <Button
73
+ size="sm"
74
+ variant="outline"
75
+ className="min-h-11 sm:min-h-8"
76
+ onClick={() =>
77
+ onRun({
78
+ title: `Install ${provider.name}`,
79
+ command: commands.install!,
80
+ })
81
+ }
82
+ >
83
+ <Download />
84
+ Install
85
+ </Button>
86
+ ) : commands?.docs ? (
87
+ <Button
88
+ size="sm"
89
+ variant="outline"
90
+ className="min-h-11 sm:min-h-8"
91
+ asChild
92
+ >
93
+ <a href={commands.docs} target="_blank" rel="noreferrer">
94
+ How to install
95
+ </a>
96
+ </Button>
97
+ ) : (
98
+ <Badge variant="outline">{LABEL.missing}</Badge>
99
+ ))}
100
+ </li>
101
+ );
102
+ }
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { FolderGit2, FolderOpen, FolderPlus, Check } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useProjectsQuery } from "@/data/projects/queries";
6
+ import { cn } from "@/lib/utils";
7
+ import { addProjectActions, type AddProjectKind } from "@/stores/addProject";
8
+ import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
9
+ import { useSnapshot } from "valtio";
10
+
11
+ const WAYS: {
12
+ kind: AddProjectKind;
13
+ title: string;
14
+ hint: string;
15
+ icon: typeof FolderOpen;
16
+ }[] = [
17
+ {
18
+ kind: "folder",
19
+ title: "Open a folder",
20
+ hint: "A project already on this machine",
21
+ icon: FolderOpen,
22
+ },
23
+ {
24
+ kind: "clone",
25
+ title: "Clone a repo",
26
+ hint: "From a Git URL",
27
+ icon: FolderGit2,
28
+ },
29
+ {
30
+ kind: "name",
31
+ title: "Start an empty project",
32
+ hint: "A new folder with a name",
33
+ icon: FolderPlus,
34
+ },
35
+ ];
36
+
37
+ /** Add the project the first session will open in, or pick one already there. */
38
+ export function ProjectStep() {
39
+ const { projectId } = useSnapshot(welcomeUi);
40
+ const { data: projects = [] } = useProjectsQuery();
41
+ const own = projects.filter((p) => !p.is_uncategorized);
42
+
43
+ const add = (kind: AddProjectKind) =>
44
+ addProjectActions.show(kind, "local", (id) => {
45
+ welcomeUiActions.setProject(id);
46
+ welcomeUiActions.go("finish");
47
+ });
48
+
49
+ return (
50
+ <div className="flex flex-col gap-3">
51
+ {own.length > 0 && (
52
+ <ul className="flex flex-col gap-2">
53
+ {own.map((p) => (
54
+ <li key={p.id}>
55
+ <button
56
+ type="button"
57
+ onClick={() => {
58
+ welcomeUiActions.setProject(p.id);
59
+ welcomeUiActions.go("finish");
60
+ }}
61
+ className={cn(
62
+ "bg-muted/40 hover:bg-accent flex min-h-11 w-full items-center gap-3 rounded-lg px-3 py-2 text-left",
63
+ projectId === p.id && "ring-primary/40 ring-2"
64
+ )}
65
+ >
66
+ <span className="min-w-0 flex-1">
67
+ <span className="block truncate text-sm font-medium">
68
+ {p.name}
69
+ </span>
70
+ <span className="text-muted-foreground block truncate text-xs">
71
+ {p.working_directory}
72
+ </span>
73
+ </span>
74
+ {projectId === p.id && <Check className="size-4" />}
75
+ </button>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ )}
80
+ <div className="flex flex-col gap-2">
81
+ {WAYS.map((w) => (
82
+ <Button
83
+ key={w.kind}
84
+ variant="outline"
85
+ className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
86
+ onClick={() => add(w.kind)}
87
+ >
88
+ <w.icon />
89
+ <span className="min-w-0">
90
+ <span className="block text-sm font-medium">{w.title}</span>
91
+ <span className="text-muted-foreground block text-xs font-normal">
92
+ {w.hint}
93
+ </span>
94
+ </span>
95
+ </Button>
96
+ ))}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+ import dynamic from "next/dynamic";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { TerminalHandle } from "@/components/Terminal";
7
+
8
+ const Terminal = dynamic(
9
+ () => import("@/components/Terminal").then((m) => m.Terminal),
10
+ { ssr: false }
11
+ );
12
+
13
+ /**
14
+ * A terminal on this machine with one setup command typed at the prompt, for
15
+ * the user to read and run with Enter. It goes into their own shell over the
16
+ * existing terminal socket; the server runs nothing.
17
+ */
18
+ export function SetupTerminal({
19
+ title,
20
+ command,
21
+ onDone,
22
+ }: {
23
+ title: string;
24
+ command: string;
25
+ onDone: () => void;
26
+ }) {
27
+ const ref = useRef<TerminalHandle | null>(null);
28
+ const typed = useRef(false);
29
+ const box = useRef<HTMLDivElement>(null);
30
+
31
+ // On a phone it opens below the list, out of sight.
32
+ useEffect(() => {
33
+ box.current?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
34
+ }, []);
35
+
36
+ const connected = useCallback(() => {
37
+ if (typed.current) return;
38
+ typed.current = true;
39
+ setTimeout(() => {
40
+ ref.current?.sendInput("\x15");
41
+ // Typed, not run: the user reads it and presses Enter.
42
+ setTimeout(() => ref.current?.sendInput(command), 50);
43
+ }, 100);
44
+ }, [command]);
45
+
46
+ return (
47
+ <div
48
+ ref={box}
49
+ className="bg-muted/30 hairline flex flex-col overflow-hidden rounded-lg"
50
+ >
51
+ <div className="flex items-center justify-between gap-2 px-3 py-1.5">
52
+ <span className="text-muted-foreground truncate text-xs">
53
+ {title}. Press Enter to run it.
54
+ </span>
55
+ <Button size="sm" onClick={onDone}>
56
+ Done, check again
57
+ </Button>
58
+ </div>
59
+ <div className="h-64 min-h-0">
60
+ <Terminal ref={ref} onConnected={connected} />
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { memo } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { Check } from "lucide-react";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { Button } from "@/components/ui/button";
13
+ import { useProjectsQuery } from "@/data/projects/queries";
14
+ import { useEndWelcome, useWelcomeUrl } from "@/hooks/useWelcome";
15
+ import { cn } from "@/lib/utils";
16
+ import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
17
+ import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
18
+ import { AgentList } from "./AgentList";
19
+ import { ProjectStep } from "./ProjectStep";
20
+
21
+ const STEPS: Record<
22
+ WelcomeStep,
23
+ { label: string; title: string; hint: string }
24
+ > = {
25
+ agents: {
26
+ label: "Agents",
27
+ title: "Connect your agents",
28
+ hint: "Install and sign in the coding agents you want to use. You need one.",
29
+ },
30
+ project: {
31
+ label: "Project",
32
+ title: "Add your first project",
33
+ hint: "A folder or repo for the first session to work in.",
34
+ },
35
+ finish: {
36
+ label: "Start",
37
+ title: "You're set",
38
+ hint: "Open a session in your project and give it a task.",
39
+ },
40
+ };
41
+
42
+ function FinishStep() {
43
+ const { projectId } = useSnapshot(welcomeUi);
44
+ const { data: projects = [] } = useProjectsQuery();
45
+ const project = projects.find((p) => p.id === projectId);
46
+ return (
47
+ <p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-3 text-sm">
48
+ {project ? (
49
+ <>
50
+ First session opens in{" "}
51
+ <span className="text-foreground font-medium">{project.name}</span>.
52
+ </>
53
+ ) : (
54
+ "No project yet. Add one from the sidebar whenever you are ready."
55
+ )}
56
+ </p>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * First-run setup: agents, then a first project, then a session. It stays
62
+ * open until finished or skipped, and every step after the first can be skipped.
63
+ */
64
+ export const WelcomeDialog = memo(function WelcomeDialog() {
65
+ useWelcomeUrl();
66
+ const { open, step, projectId } = useSnapshot(welcomeUi);
67
+ const end = useEndWelcome();
68
+ const at = WELCOME_STEPS.indexOf(step);
69
+ const info = STEPS[step];
70
+ const last = at === WELCOME_STEPS.length - 1;
71
+
72
+ return (
73
+ <Dialog open={open}>
74
+ <DialogContent
75
+ showCloseButton={false}
76
+ onInteractOutside={(e) => e.preventDefault()}
77
+ onEscapeKeyDown={(e) => e.preventDefault()}
78
+ className="max-h-[100dvh] overflow-y-auto max-sm:h-[100dvh] max-sm:max-w-full max-sm:rounded-none sm:max-h-[90dvh] sm:max-w-xl"
79
+ >
80
+ <nav aria-label="Setup steps" className="flex items-center gap-1">
81
+ {WELCOME_STEPS.map((s, i) => (
82
+ <button
83
+ key={s}
84
+ type="button"
85
+ disabled={i === at}
86
+ onClick={() => welcomeUiActions.go(s)}
87
+ aria-current={i === at ? "step" : undefined}
88
+ className={cn(
89
+ "flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-medium sm:min-h-8",
90
+ i === at
91
+ ? "bg-foreground/[0.08] text-foreground"
92
+ : "text-muted-foreground hover:bg-foreground/[0.06]"
93
+ )}
94
+ >
95
+ {i < at && <Check className="size-3" />}
96
+ {STEPS[s].label}
97
+ </button>
98
+ ))}
99
+ <Button
100
+ variant="ghost"
101
+ size="sm"
102
+ className="min-h-11 sm:min-h-8"
103
+ onClick={() => end()}
104
+ >
105
+ Skip setup
106
+ </Button>
107
+ </nav>
108
+ <div className="flex flex-col gap-1">
109
+ <DialogTitle>{info.title}</DialogTitle>
110
+ <DialogDescription>{info.hint}</DialogDescription>
111
+ </div>
112
+ {step === "agents" && <AgentList />}
113
+ {step === "project" && <ProjectStep />}
114
+ {step === "finish" && <FinishStep />}
115
+ <div className="flex items-center justify-between gap-2">
116
+ <Button
117
+ variant="ghost"
118
+ className="min-h-11 sm:min-h-9"
119
+ disabled={at === 0}
120
+ onClick={() => welcomeUiActions.go(WELCOME_STEPS[at - 1])}
121
+ >
122
+ Back
123
+ </Button>
124
+ <div className="flex items-center gap-2">
125
+ {step === "project" && (
126
+ <Button
127
+ variant="ghost"
128
+ className="min-h-11 sm:min-h-9"
129
+ onClick={() => welcomeUiActions.go("finish")}
130
+ >
131
+ Skip
132
+ </Button>
133
+ )}
134
+ <Button
135
+ className="min-h-11 sm:min-h-9"
136
+ onClick={() =>
137
+ last ? end(true) : welcomeUiActions.go(WELCOME_STEPS[at + 1])
138
+ }
139
+ >
140
+ {last
141
+ ? projectId
142
+ ? "Start your first session"
143
+ : "Go to AgentOS"
144
+ : step === "agents"
145
+ ? "Continue"
146
+ : "Next"}
147
+ </Button>
148
+ </div>
149
+ </div>
150
+ </DialogContent>
151
+ </Dialog>
152
+ );
153
+ });
@@ -1,4 +1,4 @@
1
- import { useQuery } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { AgentProbe } from "@/lib/agents/probe";
3
3
 
4
4
  export const agentKeys = {
@@ -20,3 +20,18 @@ export function useAgentStatusQuery() {
20
20
  refetchOnMount: "always",
21
21
  });
22
22
  }
23
+
24
+ // Asks the server to probe again now, not from its minute-old answer, and
25
+ // puts the result where the status query reads it.
26
+ export function useRecheckAgents() {
27
+ const client = useQueryClient();
28
+ return useMutation({
29
+ mutationFn: async () => {
30
+ const res = await fetch("/api/agents/status?fresh");
31
+ if (!res.ok) throw new Error("Couldn't check the agents");
32
+ return ((await res.json()) as { agents: Record<string, AgentProbe> })
33
+ .agents;
34
+ },
35
+ onSuccess: (agents) => client.setQueryData(agentKeys.status(), agents),
36
+ });
37
+ }
@@ -0,0 +1,36 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { WelcomeGate } from "@/lib/welcome";
3
+
4
+ export const welcomeKeys = {
5
+ all: ["welcome"] as const,
6
+ };
7
+
8
+ export function useWelcomeQuery() {
9
+ return useQuery({
10
+ queryKey: welcomeKeys.all,
11
+ queryFn: async () => {
12
+ const res = await fetch("/api/welcome");
13
+ if (!res.ok) throw new Error("Couldn't check first-run setup");
14
+ return (await res.json()) as WelcomeGate;
15
+ },
16
+ // Asked once per load: it only flips when setup is done here.
17
+ staleTime: Infinity,
18
+ retry: false,
19
+ });
20
+ }
21
+
22
+ export function useCompleteWelcome() {
23
+ const client = useQueryClient();
24
+ return useMutation({
25
+ mutationFn: async () => {
26
+ const res = await fetch("/api/welcome", {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify({ action: "complete" }),
30
+ });
31
+ if (!res.ok) throw new Error("Couldn't save setup");
32
+ return (await res.json()) as WelcomeGate;
33
+ },
34
+ onSuccess: (gate) => client.setQueryData(welcomeKeys.all, gate),
35
+ });
36
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { subscribe } from "valtio";
5
+ import { useCompleteWelcome, useWelcomeQuery } from "@/data/welcome";
6
+ import { hrefWithWelcome, welcomeFromHref } from "@/lib/welcome-url";
7
+ import { newDraft } from "@/stores/drafts";
8
+ import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
9
+
10
+ function fromAddress() {
11
+ const at = welcomeFromHref(window.location.href);
12
+ if (at.open) welcomeUiActions.open(at.step);
13
+ else if (welcomeUi.open) welcomeUiActions.close();
14
+ }
15
+
16
+ /**
17
+ * `?welcome=<step>` opens the first-run welcome at that step, on load and on
18
+ * back/forward; the address follows it. Moving between steps adds a history
19
+ * entry, so Back goes to the step before. A fresh install with setup still to
20
+ * do opens it by itself, once.
21
+ */
22
+ export function useWelcomeUrl() {
23
+ const { data: gate } = useWelcomeQuery();
24
+ const asked = useRef(false);
25
+
26
+ useEffect(() => {
27
+ fromAddress();
28
+ let was = { open: welcomeUi.open, step: welcomeUi.step };
29
+ const unsubscribe = subscribe(welcomeUi, () => {
30
+ const href = window.location.href;
31
+ const next = hrefWithWelcome(href, {
32
+ open: welcomeUi.open,
33
+ step: welcomeUi.step,
34
+ });
35
+ const url = new URL(href);
36
+ if (next !== url.pathname + url.search + url.hash) {
37
+ const move = was.open && welcomeUi.open && was.step !== welcomeUi.step;
38
+ window.history[move ? "pushState" : "replaceState"](
39
+ window.history.state,
40
+ "",
41
+ next
42
+ );
43
+ }
44
+ was = { open: welcomeUi.open, step: welcomeUi.step };
45
+ });
46
+ window.addEventListener("popstate", fromAddress);
47
+ return () => {
48
+ unsubscribe();
49
+ window.removeEventListener("popstate", fromAddress);
50
+ };
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (!gate?.show || asked.current || welcomeUi.open) return;
55
+ asked.current = true;
56
+ welcomeUiActions.open("agents");
57
+ }, [gate?.show]);
58
+ }
59
+
60
+ /** Setup is over, finished or skipped: remember it and close. */
61
+ export function useEndWelcome() {
62
+ const { mutate } = useCompleteWelcome();
63
+ return (start?: boolean) => {
64
+ const projectId = welcomeUi.projectId;
65
+ mutate();
66
+ welcomeUiActions.close();
67
+ welcomeUiActions.setProject(null);
68
+ if (start && projectId) newDraft({ kind: "project", projectId });
69
+ };
70
+ }
@@ -64,7 +64,34 @@ type AuthCheck = (
64
64
  env: NodeJS.ProcessEnv
65
65
  ) => Promise<AgentAuth> | AgentAuth;
66
66
 
67
+ // Claude Code keeps who is signed in under `oauthAccount` in its config, at
68
+ // $CLAUDE_CONFIG_DIR/.claude.json, or ~/.claude.json without that variable.
69
+ function claudeAuth(env: NodeJS.ProcessEnv): AgentAuth {
70
+ const dir = env.CLAUDE_CONFIG_DIR?.trim();
71
+ const configDir = dir || home(".claude");
72
+ const config = dir ? path.join(dir, ".claude.json") : home(".claude.json");
73
+ if (
74
+ anyEnv(env, [
75
+ "ANTHROPIC_API_KEY",
76
+ "ANTHROPIC_AUTH_TOKEN",
77
+ "CLAUDE_CODE_OAUTH_TOKEN",
78
+ "CLAUDE_CODE_USE_BEDROCK",
79
+ "CLAUDE_CODE_USE_VERTEX",
80
+ ]) ||
81
+ hasJsonKeys(path.join(configDir, "settings.json"), "apiKeyHelper")
82
+ )
83
+ return "ready";
84
+ if (hasJsonKeys(config, "oauthAccount")) return "ready";
85
+ // A config without an account is a CLI that has never signed in; no config
86
+ // at all tells nothing.
87
+ return hasJsonKeys(config) ? "needs-login" : "unknown";
88
+ }
89
+
67
90
  const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
91
+ claude: {
92
+ check: (_cli, env) => claudeAuth(env),
93
+ hint: "Run `claude auth login` in a terminal",
94
+ },
68
95
  codex: {
69
96
  // Exits 0 signed in, 1 not.
70
97
  check: async (cli, env) => {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The official commands the welcome runs in its terminal to install an agent
3
+ * CLI or sign it in. Typed into the user's own shell, never run by the
4
+ * server, so they are the same lines the install script (scripts/lib/ai-clis.sh)
5
+ * uses.
6
+ */
7
+
8
+ import type { ProviderId } from "../providers/registry";
9
+
10
+ export interface SetupCommands {
11
+ install?: string;
12
+ signIn?: string;
13
+ docs?: string;
14
+ }
15
+
16
+ export const SETUP_COMMANDS: Partial<Record<ProviderId, SetupCommands>> = {
17
+ claude: {
18
+ install: "curl -fsSL https://claude.ai/install.sh | bash",
19
+ signIn: "claude auth login",
20
+ },
21
+ codex: {
22
+ install: "npm install -g @openai/codex",
23
+ signIn: "codex login",
24
+ },
25
+ gemini: {
26
+ install: "npm install -g @google/gemini-cli",
27
+ signIn: "gemini",
28
+ },
29
+ kilocode: { install: "npm install -g @kilocode/cli", signIn: "kilo" },
30
+ cursor: { signIn: "cursor-agent login", docs: "https://cursor.com/cli" },
31
+ amp: { signIn: "amp login", docs: "https://ampcode.com" },
32
+ pi: { signIn: "pi" },
33
+ // No installer line we can vouch for from here; its own site has it.
34
+ opencode: { docs: "https://opencode.ai/docs" },
35
+ };
36
+
37
+ // The ones the welcome shows up front; the rest wait behind "Show all".
38
+ export const FEATURED_AGENTS: ProviderId[] = [
39
+ "claude",
40
+ "codex",
41
+ "gemini",
42
+ "opencode",
43
+ ];
44
+
45
+ export type AgentState = "missing" | "needs-login" | "ready" | "installed";
46
+
47
+ export function agentState(p: {
48
+ installed: boolean;
49
+ auth: "ready" | "needs-login" | "unknown";
50
+ }): AgentState {
51
+ if (!p.installed) return "missing";
52
+ if (p.auth === "needs-login") return "needs-login";
53
+ return p.auth === "ready" ? "ready" : "installed";
54
+ }
@@ -1754,6 +1754,20 @@ const migrations: Migration[] = [
1754
1754
  db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
1755
1755
  },
1756
1756
  },
1757
+ {
1758
+ id: 76,
1759
+ name: "welcome_backfill",
1760
+ up: (db) => {
1761
+ // An install that already has a session or a project of its own has
1762
+ // been set up; mark it so the first-run welcome never shows there.
1763
+ db.exec(
1764
+ `INSERT OR IGNORE INTO settings (key, value)
1765
+ SELECT 'welcome.completed_at', 'existing-install'
1766
+ WHERE EXISTS (SELECT 1 FROM sessions)
1767
+ OR EXISTS (SELECT 1 FROM projects WHERE is_uncategorized = 0)`
1768
+ );
1769
+ },
1770
+ },
1757
1771
  ];
1758
1772
 
1759
1773
  // `upTo`: stop after this id (tests that start from an older database).
@@ -12,6 +12,7 @@ export const SETTINGS_SECTIONS = [
12
12
  "notifications",
13
13
  "schedules",
14
14
  "audits",
15
+ "setup",
15
16
  ] as const;
16
17
 
17
18
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
@@ -0,0 +1,33 @@
1
+ // The welcome has its own address: `/?welcome=<step>`, beside `?settings=`
2
+ // and `?session=`. `/welcome` redirects here.
3
+
4
+ export const WELCOME_PARAM = "welcome";
5
+
6
+ export const WELCOME_STEPS = ["agents", "project", "finish"] as const;
7
+
8
+ export type WelcomeStep = (typeof WELCOME_STEPS)[number];
9
+
10
+ export interface WelcomeLocation {
11
+ open: boolean;
12
+ step: WelcomeStep;
13
+ }
14
+
15
+ const BASE = "http://agentos.invalid";
16
+
17
+ export const isWelcomeStep = (v: unknown): v is WelcomeStep =>
18
+ (WELCOME_STEPS as readonly unknown[]).includes(v);
19
+
20
+ /** What the address says; a bare or unknown step starts at the first. */
21
+ export function welcomeFromHref(href: string): WelcomeLocation {
22
+ const value = new URL(href, BASE).searchParams.get(WELCOME_PARAM);
23
+ if (value === null) return { open: false, step: WELCOME_STEPS[0] };
24
+ return { open: true, step: isWelcomeStep(value) ? value : WELCOME_STEPS[0] };
25
+ }
26
+
27
+ /** The same address with the welcome set (or removed), as path+query+hash. */
28
+ export function hrefWithWelcome(href: string, at: WelcomeLocation): string {
29
+ const url = new URL(href, BASE);
30
+ if (at.open) url.searchParams.set(WELCOME_PARAM, at.step);
31
+ else url.searchParams.delete(WELCOME_PARAM);
32
+ return url.pathname + url.search + url.hash;
33
+ }
package/lib/welcome.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * First-run welcome: shown once on a fresh install, never on one that
3
+ * already has work. Completion is the server's (one server, many devices),
4
+ * kept in `settings` as an ISO time, or "existing-install" when the install
5
+ * was already in use.
6
+ */
7
+
8
+ import type Database from "better-sqlite3";
9
+ import { db } from "./db";
10
+
11
+ export const WELCOME_KEY = "welcome.completed_at";
12
+ export const EXISTING_INSTALL = "existing-install";
13
+
14
+ export interface WelcomeGate {
15
+ // The welcome should open now.
16
+ show: boolean;
17
+ // No sessions and no project of the user's own.
18
+ fresh: boolean;
19
+ completedAt: string | null;
20
+ }
21
+
22
+ export interface GateInput {
23
+ completedAt: string | null;
24
+ sessionCount: number;
25
+ // Projects other than the built-in Uncategorized one.
26
+ projectCount: number;
27
+ demo: boolean;
28
+ skip: boolean;
29
+ }
30
+
31
+ export function welcomeGate(i: GateInput): WelcomeGate {
32
+ const fresh = i.sessionCount === 0 && i.projectCount === 0;
33
+ return {
34
+ show: !i.completedAt && fresh && !i.demo && !i.skip,
35
+ fresh,
36
+ completedAt: i.completedAt,
37
+ };
38
+ }
39
+
40
+ export function readCompletedAt(d: Database.Database = db): string | null {
41
+ const row = d
42
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
43
+ .get(WELCOME_KEY) as { value: string } | undefined;
44
+ return row?.value ?? null;
45
+ }
46
+
47
+ // Keeps the first stamp unless `overwrite`.
48
+ export function completeWelcome(
49
+ value: string = new Date().toISOString(),
50
+ d: Database.Database = db,
51
+ overwrite = false
52
+ ): void {
53
+ d.prepare(
54
+ overwrite
55
+ ? `INSERT INTO settings (key, value) VALUES (?, ?)
56
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
57
+ : `INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`
58
+ ).run(WELCOME_KEY, value);
59
+ }
60
+
61
+ export function counts(d: Database.Database = db) {
62
+ const n = (sql: string) => (d.prepare(sql).get() as { n: number }).n;
63
+ return {
64
+ sessionCount: n(`SELECT COUNT(*) AS n FROM sessions`),
65
+ projectCount: n(
66
+ `SELECT COUNT(*) AS n FROM projects WHERE is_uncategorized = 0`
67
+ ),
68
+ };
69
+ }
70
+
71
+ /** The gate for this server. An install found in use is stamped as such. */
72
+ export function readWelcomeGate(
73
+ demo: boolean,
74
+ d: Database.Database = db
75
+ ): WelcomeGate {
76
+ const c = counts(d);
77
+ let completedAt = readCompletedAt(d);
78
+ if (!completedAt && (c.sessionCount > 0 || c.projectCount > 0)) {
79
+ completeWelcome(EXISTING_INSTALL, d);
80
+ completedAt = readCompletedAt(d);
81
+ }
82
+ return welcomeGate({
83
+ completedAt,
84
+ ...c,
85
+ demo,
86
+ skip: !!process.env.AGENTOS_SKIP_WELCOME,
87
+ });
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.520",
3
+ "version": "0.3.521",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -13,14 +13,32 @@ export const addProjectUi = proxy<{
13
13
  } | null;
14
14
  }>({ open: null });
15
15
 
16
+ // Who asked for the dialog and wants the new project instead of the draft
17
+ // that opens in it. A function, so it stays out of the proxy.
18
+ let onProjectAdded: ((projectId: string) => void) | null = null;
19
+
16
20
  export const addProjectActions = {
17
- show: (kind: AddProjectKind, hostId: string) => {
21
+ show: (
22
+ kind: AddProjectKind,
23
+ hostId: string,
24
+ onDone?: (projectId: string) => void
25
+ ) => {
26
+ onProjectAdded = onDone ?? null;
18
27
  addProjectUi.open = { kind, hostId, workspaceId: sidebarUi.workspaceId };
19
28
  },
29
+ /** Called once a project is made; false if nobody took it over. */
30
+ finish: (projectId: string): boolean => {
31
+ const handler = onProjectAdded;
32
+ addProjectActions.close();
33
+ if (!handler) return false;
34
+ handler(projectId);
35
+ return true;
36
+ },
20
37
  setWorkspace: (workspaceId: string | null) => {
21
38
  if (addProjectUi.open) addProjectUi.open.workspaceId = workspaceId;
22
39
  },
23
40
  close: () => {
41
+ onProjectAdded = null;
24
42
  addProjectUi.open = null;
25
43
  },
26
44
  };
@@ -0,0 +1,26 @@
1
+ import { proxy } from "valtio";
2
+ import type { WelcomeStep } from "@/lib/welcome-url";
3
+
4
+ // The first-run welcome: whether it is on show, the step, and the project
5
+ // the user made (or picked) to start in.
6
+ export const welcomeUi = proxy<{
7
+ open: boolean;
8
+ step: WelcomeStep;
9
+ projectId: string | null;
10
+ }>({ open: false, step: "agents", projectId: null });
11
+
12
+ export const welcomeUiActions = {
13
+ open: (step: WelcomeStep = "agents") => {
14
+ welcomeUi.step = step;
15
+ welcomeUi.open = true;
16
+ },
17
+ go: (step: WelcomeStep) => {
18
+ welcomeUi.step = step;
19
+ },
20
+ setProject: (projectId: string | null) => {
21
+ welcomeUi.projectId = projectId;
22
+ },
23
+ close: () => {
24
+ welcomeUi.open = false;
25
+ },
26
+ };