@saadnvd1/agent-os 0.3.519 → 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 +26 -0
- package/app/api/bus/spawn/route.ts +4 -0
- package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
- package/app/api/tasks/route.ts +2 -0
- package/app/api/welcome/route.ts +22 -0
- package/app/page.tsx +6 -0
- package/app/welcome/page.tsx +6 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/SetupSection.tsx +37 -0
- package/components/Tasks/TaskRow.tsx +46 -1
- package/components/Tasks/task-state-labels.ts +1 -0
- package/components/Welcome/AgentList.tsx +73 -0
- package/components/Welcome/AgentRow.tsx +102 -0
- package/components/Welcome/ProjectStep.tsx +100 -0
- package/components/Welcome/SetupTerminal.tsx +64 -0
- package/components/Welcome/WelcomeDialog.tsx +153 -0
- package/data/agents/index.ts +16 -1
- package/data/tasks/index.ts +12 -0
- package/data/welcome/index.ts +36 -0
- package/hooks/useWelcome.ts +70 -0
- package/lib/agents/probe.ts +27 -0
- package/lib/agents/setup-commands.ts +54 -0
- package/lib/db/migrations.ts +29 -0
- package/lib/db/types.ts +3 -1
- package/lib/lumifyhub/lists.ts +1 -0
- package/lib/models/plan-step.ts +116 -32
- package/lib/models/roles.ts +3 -1
- package/lib/orchestrator/act.ts +24 -2
- package/lib/orchestrator/brief.ts +3 -1
- package/lib/orchestrator/conditions.ts +1 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +11 -2
- package/lib/orchestrator/serve.ts +8 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +18 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/index.ts +28 -7
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +5 -1
- package/lib/tasks/plan-view.ts +6 -1
- package/lib/tasks/queue.ts +6 -2
- package/lib/tasks/start.ts +1 -1
- package/lib/tasks/state.ts +7 -1
- package/lib/welcome-url.ts +33 -0
- package/lib/welcome.ts +88 -0
- package/package.json +1 -1
- package/stores/addProject.ts +19 -1
- package/stores/welcomeUi.ts +26 -0
|
@@ -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
|
+
});
|
package/data/agents/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/data/tasks/index.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function useDropTask() {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export function useApprovePlan() {
|
|
89
|
+
return useTaskMutation(async (input: { id: string; message?: string }) =>
|
|
90
|
+
json(
|
|
91
|
+
await fetch(`/api/tasks/${input.id}/approve-plan`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ message: input.message }),
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function useResumeTask() {
|
|
89
101
|
return useTaskMutation(async (input: { id: string; force?: boolean }) =>
|
|
90
102
|
json(
|
|
@@ -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
|
+
}
|
package/lib/agents/probe.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1739,6 +1739,35 @@ const migrations: Migration[] = [
|
|
|
1739
1739
|
);
|
|
1740
1740
|
},
|
|
1741
1741
|
},
|
|
1742
|
+
{
|
|
1743
|
+
id: 75,
|
|
1744
|
+
name: "plan_only_tasks",
|
|
1745
|
+
up: (db) => {
|
|
1746
|
+
// plan_only: 1 for a task that stops after its plan and waits for
|
|
1747
|
+
// approval (plan_phase 'awaiting'); queued tasks keep it until they start.
|
|
1748
|
+
const has = (table: string) =>
|
|
1749
|
+
(
|
|
1750
|
+
db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
|
|
1751
|
+
).some((c) => c.name === "plan_only");
|
|
1752
|
+
for (const table of ["sessions", "task_queue"])
|
|
1753
|
+
if (!has(table))
|
|
1754
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN plan_only INTEGER`);
|
|
1755
|
+
},
|
|
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
|
+
},
|
|
1742
1771
|
];
|
|
1743
1772
|
|
|
1744
1773
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -19,7 +19,9 @@ export interface Session {
|
|
|
19
19
|
// A task's own models: "provider/model", or JSON of role to one.
|
|
20
20
|
model_overrides?: string | null;
|
|
21
21
|
// A task's plan step: planning on the planning model, then implementing.
|
|
22
|
-
|
|
22
|
+
// 'awaiting': a plan-only task's plan is written; it waits for approval.
|
|
23
|
+
plan_phase?: "planning" | "awaiting" | "implementing" | null;
|
|
24
|
+
plan_only?: number | null;
|
|
23
25
|
// The orchestrator session that started this one; null for a person.
|
|
24
26
|
started_by?: string | null;
|
|
25
27
|
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
package/lib/lumifyhub/lists.ts
CHANGED
package/lib/models/plan-step.ts
CHANGED
|
@@ -19,20 +19,28 @@ import {
|
|
|
19
19
|
} from "./resolve";
|
|
20
20
|
import { ANTHROPIC } from "./roles";
|
|
21
21
|
import { queueEvent } from "../orchestrator/events";
|
|
22
|
+
import { addNote } from "../orchestrator/notes";
|
|
23
|
+
import { amendBrief, amendmentsOf } from "../orchestrator/amendments";
|
|
22
24
|
|
|
23
25
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
24
26
|
|
|
25
|
-
export function planPrompt(task: string): string {
|
|
27
|
+
export function planPrompt(task: string, only = false): string {
|
|
28
|
+
const after = only
|
|
29
|
+
? "it waits for approval before anything is implemented, so don't ask for it"
|
|
30
|
+
: "the implementation model takes over this conversation next and follows it";
|
|
26
31
|
return `${task}
|
|
27
32
|
|
|
28
33
|
---
|
|
29
|
-
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written:
|
|
34
|
+
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written: ${after}.`;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
export function implementPrompt(planned: boolean): string {
|
|
33
|
-
|
|
37
|
+
export function implementPrompt(planned: boolean, amendment?: string): string {
|
|
38
|
+
const base = planned
|
|
34
39
|
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
35
40
|
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
41
|
+
return amendment
|
|
42
|
+
? `${base}\n\nThe plan was approved with this change, which is now part of the task's scope:\n${amendment}`
|
|
43
|
+
: base;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
// The worktree's plan folder, kept out of git by its own .gitignore.
|
|
@@ -72,7 +80,7 @@ export function planSummary(text: string, max = 5): string[] {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
// Tells the orchestrator that started a task its plan is written, once.
|
|
75
|
-
function announcePlan(s: Session, ref: string): void {
|
|
83
|
+
function announcePlan(s: Session, ref: string | null): void {
|
|
76
84
|
if (!s.started_by && !s.held_by_device) return;
|
|
77
85
|
const workspaceId = sessionWorkspace(s);
|
|
78
86
|
// A task held for another machine's orchestrator can't reach it: the line
|
|
@@ -80,10 +88,13 @@ function announcePlan(s: Session, ref: string): void {
|
|
|
80
88
|
if (!s.held_by_device && !workspaceId) return;
|
|
81
89
|
if (!s.worktree_path) return;
|
|
82
90
|
const file = path.join(s.worktree_path, PLAN_FILE);
|
|
83
|
-
|
|
91
|
+
const next = ref
|
|
92
|
+
? `implementing on ${ref}`
|
|
93
|
+
: "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
|
|
94
|
+
let head = `Task "${s.name}": no plan file was written; ${next}${ref ? " anyway" : ""}.`;
|
|
84
95
|
try {
|
|
85
96
|
const summary = planSummary(fs.readFileSync(file, "utf8"));
|
|
86
|
-
head = `Task "${s.name}": plan written to ${file};
|
|
97
|
+
head = `Task "${s.name}": plan written to ${file}; ${next}.`;
|
|
87
98
|
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
88
99
|
} catch {
|
|
89
100
|
// No plan file: the line says so.
|
|
@@ -101,56 +112,129 @@ function announcePlan(s: Session, ref: string): void {
|
|
|
101
112
|
export const implementMessageId = (sessionId: string) =>
|
|
102
113
|
`implement-${sessionId}`;
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
export async function handoffAfterPlan(
|
|
109
|
-
sessionId: string,
|
|
110
|
-
send: (sessionId: string, id: string) => Promise<void>
|
|
111
|
-
): Promise<boolean> {
|
|
112
|
-
const planned = db
|
|
113
|
-
.prepare(
|
|
114
|
-
`SELECT 1 FROM chat_turns WHERE session_id = ? AND role = 'plan' LIMIT 1`
|
|
115
|
-
)
|
|
116
|
-
.get(sessionId);
|
|
117
|
-
if (!planned) return false;
|
|
118
|
-
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
115
|
+
type Send = (sessionId: string, id: string) => Promise<void>;
|
|
116
|
+
|
|
117
|
+
function getSession(sessionId: string): Session | undefined {
|
|
118
|
+
return db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
119
119
|
| Session
|
|
120
120
|
| undefined;
|
|
121
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Planning (or, once approved, awaiting) becomes implementing, once: the
|
|
124
|
+
// phase, the model and the message in one go, so a restart part-way finds
|
|
125
|
+
// either none of them or all, and sends the message on the new model. The
|
|
126
|
+
// message goes under a fixed id, which a restart sends rather than repeats.
|
|
127
|
+
async function startImplementing(
|
|
128
|
+
s: Session,
|
|
129
|
+
from: "planning" | "awaiting",
|
|
130
|
+
send: Send,
|
|
131
|
+
amendment?: string
|
|
132
|
+
): Promise<string | null> {
|
|
133
|
+
const sessionId = s.id;
|
|
122
134
|
const routed = resolveRole("implement", {
|
|
123
135
|
workspaceId: sessionWorkspace(s),
|
|
124
136
|
override: taskOverride(s),
|
|
125
137
|
});
|
|
126
138
|
const id = implementMessageId(sessionId);
|
|
127
|
-
// The phase, the model and the message in one go: a restart part-way
|
|
128
|
-
// finds either none of them or all, and sends the message on the new model.
|
|
129
139
|
const claimed = db.transaction(() => {
|
|
130
140
|
const changed = db
|
|
131
141
|
.prepare(
|
|
132
142
|
`UPDATE sessions SET plan_phase = 'implementing'
|
|
133
|
-
WHERE id = ? AND plan_phase =
|
|
143
|
+
WHERE id = ? AND plan_phase = ? AND task_status = 'running'`
|
|
134
144
|
)
|
|
135
|
-
.run(sessionId).changes;
|
|
145
|
+
.run(sessionId, from).changes;
|
|
136
146
|
if (!changed) return false;
|
|
137
147
|
setSessionModel(sessionId, routed);
|
|
138
148
|
enqueue(sessionId, {
|
|
139
149
|
id,
|
|
140
150
|
text: implementPrompt(
|
|
141
|
-
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE))
|
|
151
|
+
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE)),
|
|
152
|
+
amendment
|
|
142
153
|
),
|
|
143
154
|
origin: { kind: "decision", label: "Plan step" },
|
|
144
155
|
});
|
|
145
156
|
return true;
|
|
146
157
|
})();
|
|
147
|
-
if (!claimed) return
|
|
158
|
+
if (!claimed) return null;
|
|
159
|
+
if (from === "planning") {
|
|
160
|
+
try {
|
|
161
|
+
announcePlan(s, routed.ref);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.error("plan-done event failed:", e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
167
|
+
await send(sessionId, id);
|
|
168
|
+
return routed.ref;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Called when a chat goes idle. A task whose plan turn has ended (it has a
|
|
172
|
+
// recorded plan turn) moves to implementing, once; a plan-only task instead
|
|
173
|
+
// waits for approval (approvePlan), holding no agent process.
|
|
174
|
+
export async function handoffAfterPlan(
|
|
175
|
+
sessionId: string,
|
|
176
|
+
send: Send
|
|
177
|
+
): Promise<boolean> {
|
|
178
|
+
const planned = db
|
|
179
|
+
.prepare(
|
|
180
|
+
`SELECT 1 FROM chat_turns WHERE session_id = ? AND role = 'plan' LIMIT 1`
|
|
181
|
+
)
|
|
182
|
+
.get(sessionId);
|
|
183
|
+
if (!planned) return false;
|
|
184
|
+
const s = getSession(sessionId);
|
|
185
|
+
if (s?.plan_phase !== "planning" || !s.worktree_path) return false;
|
|
186
|
+
if (!s.plan_only)
|
|
187
|
+
return (await startImplementing(s, "planning", send)) !== null;
|
|
188
|
+
const waiting = db
|
|
189
|
+
.prepare(
|
|
190
|
+
`UPDATE sessions SET plan_phase = 'awaiting'
|
|
191
|
+
WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
|
|
192
|
+
)
|
|
193
|
+
.run(sessionId).changes;
|
|
194
|
+
if (!waiting) return false;
|
|
148
195
|
try {
|
|
149
|
-
announcePlan(s,
|
|
196
|
+
announcePlan(s, null);
|
|
150
197
|
} catch (e) {
|
|
151
198
|
console.error("plan-done event failed:", e);
|
|
152
199
|
}
|
|
153
|
-
|
|
154
|
-
|
|
200
|
+
// The worker has nothing left to do: it ends at this turn boundary, and
|
|
201
|
+
// the next message (an approval, or a revision) starts one that resumes.
|
|
202
|
+
try {
|
|
203
|
+
await (await import("../chat/runner")).retireChat(sessionId);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(`Releasing the worker of ${sessionId} failed:`, e);
|
|
206
|
+
}
|
|
155
207
|
return true;
|
|
156
208
|
}
|
|
209
|
+
|
|
210
|
+
// approvePlan, sending through the chat runner (imported when needed: it
|
|
211
|
+
// imports this file).
|
|
212
|
+
export async function approvePlanNow(sessionId: string, message?: string) {
|
|
213
|
+
const { sendQueuedNow } = await import("../chat/runner");
|
|
214
|
+
return approvePlan(sessionId, message, (id, m) => sendQueuedNow(id, m));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Approves a waiting plan-only task: implementation starts on the
|
|
218
|
+
// implementation model, once. A message amends the plan and is recorded on
|
|
219
|
+
// the task's brief as a scope change, before implementing, so its review
|
|
220
|
+
// judges against it.
|
|
221
|
+
export async function approvePlan(
|
|
222
|
+
sessionId: string,
|
|
223
|
+
message: string | undefined,
|
|
224
|
+
send: Send
|
|
225
|
+
): Promise<{ ref: string; amendments: number }> {
|
|
226
|
+
const s = getSession(sessionId);
|
|
227
|
+
if (!s || s.plan_phase !== "awaiting" || s.task_status !== "running")
|
|
228
|
+
throw new Error(
|
|
229
|
+
`${s?.name ?? "That task"} isn't waiting for plan approval`
|
|
230
|
+
);
|
|
231
|
+
const text = message?.trim() || undefined;
|
|
232
|
+
const workspaceId = sessionWorkspace(s);
|
|
233
|
+
if (text && workspaceId) {
|
|
234
|
+
amendBrief(workspaceId, s.id, text);
|
|
235
|
+
addNote(workspaceId, `Plan approved with a change for ${s.name}: ${text}`);
|
|
236
|
+
}
|
|
237
|
+
const ref = await startImplementing(s, "awaiting", send, text);
|
|
238
|
+
if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
|
|
239
|
+
return { ref, amendments: amendmentsOf(s.id).length };
|
|
240
|
+
}
|
package/lib/models/roles.ts
CHANGED
|
@@ -97,5 +97,7 @@ export function sessionRole(session: {
|
|
|
97
97
|
// Work an orchestrator started is implementation, not a person's session.
|
|
98
98
|
if (!session.task_status)
|
|
99
99
|
return session.started_by ? "implement" : "interactive";
|
|
100
|
-
return session.plan_phase === "planning"
|
|
100
|
+
return session.plan_phase === "planning" || session.plan_phase === "awaiting"
|
|
101
|
+
? "plan"
|
|
102
|
+
: "implement";
|
|
101
103
|
}
|