@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.
Files changed (50) hide show
  1. package/README.md +26 -0
  2. package/app/api/bus/spawn/route.ts +4 -0
  3. package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
  4. package/app/api/tasks/route.ts +2 -0
  5. package/app/api/welcome/route.ts +22 -0
  6. package/app/page.tsx +6 -0
  7. package/app/welcome/page.tsx +6 -0
  8. package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
  9. package/components/Settings/SettingsDialog.tsx +9 -0
  10. package/components/Settings/SetupSection.tsx +37 -0
  11. package/components/Tasks/TaskRow.tsx +46 -1
  12. package/components/Tasks/task-state-labels.ts +1 -0
  13. package/components/Welcome/AgentList.tsx +73 -0
  14. package/components/Welcome/AgentRow.tsx +102 -0
  15. package/components/Welcome/ProjectStep.tsx +100 -0
  16. package/components/Welcome/SetupTerminal.tsx +64 -0
  17. package/components/Welcome/WelcomeDialog.tsx +153 -0
  18. package/data/agents/index.ts +16 -1
  19. package/data/tasks/index.ts +12 -0
  20. package/data/welcome/index.ts +36 -0
  21. package/hooks/useWelcome.ts +70 -0
  22. package/lib/agents/probe.ts +27 -0
  23. package/lib/agents/setup-commands.ts +54 -0
  24. package/lib/db/migrations.ts +29 -0
  25. package/lib/db/types.ts +3 -1
  26. package/lib/lumifyhub/lists.ts +1 -0
  27. package/lib/models/plan-step.ts +116 -32
  28. package/lib/models/roles.ts +3 -1
  29. package/lib/orchestrator/act.ts +24 -2
  30. package/lib/orchestrator/brief.ts +3 -1
  31. package/lib/orchestrator/conditions.ts +1 -0
  32. package/lib/orchestrator/describe.ts +1 -1
  33. package/lib/orchestrator/facts.ts +11 -2
  34. package/lib/orchestrator/serve.ts +8 -0
  35. package/lib/orchestrator/tool-names.ts +1 -0
  36. package/lib/orchestrator/tool-schemas.ts +18 -0
  37. package/lib/orchestrator/tools.ts +3 -1
  38. package/lib/settings-url.ts +1 -0
  39. package/lib/tasks/index.ts +28 -7
  40. package/lib/tasks/move-guard.ts +2 -0
  41. package/lib/tasks/move.ts +5 -1
  42. package/lib/tasks/plan-view.ts +6 -1
  43. package/lib/tasks/queue.ts +6 -2
  44. package/lib/tasks/start.ts +1 -1
  45. package/lib/tasks/state.ts +7 -1
  46. package/lib/welcome-url.ts +33 -0
  47. package/lib/welcome.ts +88 -0
  48. package/package.json +1 -1
  49. package/stores/addProject.ts +19 -1
  50. 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
+ });
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
- plan_phase?: "planning" | "implementing" | null;
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.
@@ -26,6 +26,7 @@ export function cardTargetFor(state: TaskState | "queued"): CardTarget {
26
26
  return "todo";
27
27
  case "working":
28
28
  case "needs-input":
29
+ case "plan-ready":
29
30
  case "blocked":
30
31
  case "moving":
31
32
  return "in_progress";
@@ -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: the implementation model takes over this conversation next and follows it.`;
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
- return planned
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
- let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
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}; implementing on ${ref}.`;
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
- // Called when a chat goes idle. A task whose plan turn has ended (it has a
105
- // recorded plan turn) moves to implementing, once, and the implementation
106
- // message is queued under a fixed id, which a restart sends rather than
107
- // repeats.
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
- if (s?.plan_phase !== "planning" || !s.worktree_path) return false;
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 = 'planning' AND task_status = 'running'`
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 false;
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, routed.ref);
196
+ announcePlan(s, null);
150
197
  } catch (e) {
151
198
  console.error("plan-done event failed:", e);
152
199
  }
153
- await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
154
- await send(sessionId, id);
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
+ }
@@ -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" ? "plan" : "implement";
100
+ return session.plan_phase === "planning" || session.plan_phase === "awaiting"
101
+ ? "plan"
102
+ : "implement";
101
103
  }