@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
@@ -33,6 +33,8 @@ import { treeOrder } from "../stacks/tree";
33
33
  import type { StackItemView } from "../stacks/types";
34
34
  import { doneSession } from "../done";
35
35
  import { amendBrief, amendmentsOf } from "./amendments";
36
+ import type { Session } from "../db";
37
+ import { approvePlanNow } from "../models/plan-step";
36
38
  import { braked } from "./brakes";
37
39
  import { getOrchestrator } from "./home";
38
40
  import { addNote } from "./notes";
@@ -99,9 +101,12 @@ export async function startTask(
99
101
  after?: string;
100
102
  model?: ModelOverride;
101
103
  plan?: boolean;
104
+ planOnly?: boolean;
102
105
  planReason?: string;
103
106
  } = {}
104
107
  ): Promise<string> {
108
+ if (opts.planOnly && opts.plan === false)
109
+ throw new Error("plan_only implies plan; pass plan true");
105
110
  const project = workspaceProject(workspaceId, projectRef);
106
111
  const model = parseOverride(opts.model) ?? undefined;
107
112
  const startedBy = orchestratorId(workspaceId);
@@ -121,6 +126,7 @@ export async function startTask(
121
126
  view,
122
127
  model,
123
128
  plan: opts.plan,
129
+ planOnly: opts.planOnly,
124
130
  planReason: opts.planReason,
125
131
  startedBy,
126
132
  baseBranch: opts.base || undefined,
@@ -140,6 +146,7 @@ export async function startTask(
140
146
  view,
141
147
  model,
142
148
  plan: opts.plan,
149
+ planOnly: opts.planOnly,
143
150
  planReason: opts.planReason,
144
151
  startedBy,
145
152
  }),
@@ -151,11 +158,26 @@ export async function startTask(
151
158
  return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}. It starts by itself; you'll get an event when it does.`;
152
159
  }
153
160
  const task = out.started;
161
+ const planOnlyNote = opts.planOnly
162
+ ? " Plan only: it stops after the plan and waits; approve_plan continues it, drop rejects it."
163
+ : "";
154
164
  const skipped =
155
- opts.plan && task.plan_phase !== "planning"
165
+ opts.plan && !opts.planOnly && task.plan_phase !== "planning"
156
166
  ? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
157
167
  : "";
158
- return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}`;
168
+ return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}${planOnlyNote}`;
169
+ }
170
+
171
+ // The orchestrator approves a plan-only task's plan; a message amends it.
172
+ export async function approveTaskPlan(
173
+ task: Session,
174
+ message?: string
175
+ ): Promise<string> {
176
+ const out = await approvePlanNow(task.id, message);
177
+ const amended = message
178
+ ? ` Recorded as scope change ${out.amendments} on its brief: its review judges against it.`
179
+ : "";
180
+ return `Approved the plan of ${task.name}: implementing on ${out.ref}.${amended}`;
159
181
  }
160
182
 
161
183
  export async function startSession(
@@ -41,6 +41,7 @@ You may act without asking on anything inside this workspace that's additive or
41
41
  Before you start work, decide what it needs, and hand it to the cheapest model that can do it.
42
42
  - Small and clear (a known change, a few files, nothing to discover): start_task with plan false and a plan_reason saying why. Write the steps into the prompt yourself, from what Saad or the card said; do not read code in your own context to do it.
43
43
  - Big, unclear or cross-cutting (many files, a design choice, unknown ground): start_task with plan true and a plan_reason. The planning model writes the plan, then the implementation model carries it out; you get one event with the plan file's path and a short summary when it is written.
44
+ - Saad (or the card) says plan only, or you want to see a plan before any code is written: start_task with plan_only true (it implies plan true). It writes the plan and then stops: no implementation model, no implement prompt. You get one event with the plan file's path and a short summary saying it is waiting for approval. While it waits it isn't blocked and isn't idle; read the plan, then \`approve_plan\` (a message amends the plan and is recorded as a scope change, so the review judges against it) or \`drop\` it.
44
45
  - Not in a repository (research, a document, a machine task): start_session, which runs on the implementation model, with the steps in the prompt.
45
46
  - Implementation on Opus only when the work needs deep reasoning (a subtle concurrency or security change, say). Pass model and say why. Sonnet is the default.
46
47
 
@@ -102,7 +103,8 @@ Reading:
102
103
  Acting:
103
104
  - \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
104
105
  - \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
105
- - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
106
+ - \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
107
+ - \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
106
108
  - \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
107
109
  - \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
108
110
  - \`${TOOL_NAMES.quick_change}\` (project, name): a worktree and branch cut from the project's default branch for a small, low-risk edit you make yourself: a typo, a config value, a one-file fix. It returns the worktree's path; edit there with Write and Edit, and run git there only as \`git -C <path> <status|diff|log|show|add|rm|mv|restore|reset>\`. Anything that needs real work, tests or a long investigation is a task: start_task. At most a few open at once.
@@ -135,6 +135,7 @@ function sessionConditions(
135
135
  shouldPR &&
136
136
  !pr &&
137
137
  !finished &&
138
+ !f.plan?.awaiting &&
138
139
  f.status !== "running" &&
139
140
  idleFor >= IDLE_NO_PR_MS
140
141
  )
@@ -39,7 +39,7 @@ function sessionLine(f: SessionFacts): string {
39
39
  }
40
40
  if (f.plan)
41
41
  parts.push(
42
- `${f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
42
+ `${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
43
43
  );
44
44
  if (f.stack) {
45
45
  const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
@@ -50,7 +50,12 @@ export interface SessionFacts {
50
50
  // A quick change the orchestrator makes itself (quick.ts): no agent.
51
51
  quick?: boolean;
52
52
  // The orchestrator's call on a plan step, with its reason, when stated.
53
- plan?: { planned: boolean; reason: string | null } | null;
53
+ plan?: {
54
+ planned: boolean;
55
+ reason: string | null;
56
+ // A plan-only task's plan is written; it waits for approve_plan.
57
+ awaiting?: boolean;
58
+ } | null;
54
59
  task: {
55
60
  state: TaskState;
56
61
  pr: TaskPR | null;
@@ -344,7 +349,11 @@ export async function sessionFacts(
344
349
  plan:
345
350
  s.plan_decided == null
346
351
  ? null
347
- : { planned: !!s.plan_decided, reason: s.plan_reason ?? null },
352
+ : {
353
+ planned: !!s.plan_decided,
354
+ reason: s.plan_reason ?? null,
355
+ awaiting: s.plan_phase === "awaiting",
356
+ },
348
357
  task,
349
358
  stack: stackOf(s.id),
350
359
  };
@@ -2,6 +2,7 @@ import { resumeOnModel } from "../models/resume";
2
2
  import { workspaceTask } from "./targets";
3
3
  import { getWorkspace } from "../workspaces";
4
4
  import {
5
+ approveTaskPlan,
5
6
  done,
6
7
  drop,
7
8
  moveTaskTo,
@@ -48,6 +49,7 @@ const ACTING = new Set<ToolName>([
48
49
  "land",
49
50
  "drop",
50
51
  "move",
52
+ "approve_plan",
51
53
  "stop",
52
54
  "done",
53
55
  "review",
@@ -114,9 +116,15 @@ export async function runTool(
114
116
  after: a.after,
115
117
  model: a.model,
116
118
  plan: a.plan,
119
+ planOnly: a.plan_only,
117
120
  planReason: a.plan_reason,
118
121
  });
119
122
  }
123
+ case "approve_plan": {
124
+ const a = p(tool, raw);
125
+ const task = workspaceTask(w, a.task);
126
+ return approveTaskPlan(task, a.message);
127
+ }
120
128
  case "set_task_model": {
121
129
  const a = p(tool, raw);
122
130
  const task = workspaceTask(w, a.task);
@@ -22,6 +22,7 @@ export const TOOL_NAMES = {
22
22
  stack: name("stack"),
23
23
  stack_status: name("stack_status"),
24
24
  land: name("land"),
25
+ approve_plan: name("approve_plan"),
25
26
  drop: name("drop"),
26
27
  move: name("move"),
27
28
  stop: name("stop"),
@@ -121,6 +121,12 @@ export const TOOL_SHAPES = {
121
121
  .describe(
122
122
  "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
123
123
  ),
124
+ plan_only: z
125
+ .boolean()
126
+ .optional()
127
+ .describe(
128
+ "true: write the plan on the planning model, then stop and wait; implementation starts only when you approve_plan it (drop rejects it). Implies plan true"
129
+ ),
124
130
  plan_reason: z
125
131
  .string()
126
132
  .trim()
@@ -192,6 +198,18 @@ export const TOOL_SHAPES = {
192
198
  },
193
199
  stack_status: { id: stackId },
194
200
  land: { id: stackId },
201
+ approve_plan: {
202
+ task,
203
+ message: z
204
+ .string()
205
+ .trim()
206
+ .min(1)
207
+ .max(8000)
208
+ .optional()
209
+ .describe(
210
+ "Amends the plan: recorded on the task's brief as a scope change, so its review judges the PR against it. Only for a change Saad made"
211
+ ),
212
+ },
195
213
  drop: { task, reason: z.string().trim().min(1).max(2000) },
196
214
  move: {
197
215
  task,
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
62
62
  message_orchestrator:
63
63
  "Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
64
64
  start_task:
65
- "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason: plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
65
+ "Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
66
66
  set_task_model:
67
67
  'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
68
68
  start_session:
@@ -75,6 +75,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
75
75
  "Run a LumifyHub board's open cards as stacked tasks (each start braked), or with plan_only just show the plan.",
76
76
  stack_status: "One stack's items, PRs and progress.",
77
77
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
78
+ approve_plan:
79
+ 'Approve the plan of a plan_only task that is waiting ("plan ready"): it continues on the implementation model, once. An optional message amends the plan and is recorded on the task\'s brief as a scope change. Reject a plan with drop.',
78
80
  drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
79
81
  move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
80
82
  stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
@@ -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];
@@ -100,6 +100,8 @@ export interface TaskView {
100
100
  // plan step (lib/models).
101
101
  model?: string;
102
102
  planning?: boolean;
103
+ // A plan-only task's plan is written and waits for approval.
104
+ planReady?: boolean;
103
105
  // What its starter decided about a plan step, and why; null if unstated.
104
106
  // Its plan: ready to read or approve, with a title and capped text.
105
107
  plan?: TaskPlan | null;
@@ -128,6 +130,8 @@ export async function createTask(opts: {
128
130
  plan?: boolean;
129
131
  // Why the starter chose to plan or not: shown on the task's card.
130
132
  planReason?: string | null;
133
+ // Stop after the plan and wait for approval (implies plan).
134
+ planOnly?: boolean;
131
135
  // The orchestrator session that started it (null: a person).
132
136
  startedBy?: string | null;
133
137
  // Started from this LumifyHub card, which the task then moves.
@@ -198,6 +202,8 @@ async function startTask(
198
202
  const hostId = opts.hostId ?? project.host_id;
199
203
  const view = opts.view ?? DEFAULT_START_VIEW;
200
204
  if (isRemoteHost(hostId)) {
205
+ if (opts.planOnly)
206
+ throw new Error("A plan-only task runs on this machine for now");
201
207
  if (opts.base || opts.cardId)
202
208
  throw new Error(
203
209
  "Stacked and card tasks run on this machine only for now"
@@ -216,11 +222,18 @@ async function startTask(
216
222
 
217
223
  if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
218
224
  throw new Error(`"${opts.baseBranch}" isn't a branch name`);
225
+ if (opts.planOnly && opts.plan === false)
226
+ throw new Error(
227
+ "planOnly implies plan; it can't be combined with plan false"
228
+ );
229
+ if (opts.planOnly && view !== "chat")
230
+ throw new Error("A plan-only task needs a chat, not a terminal");
219
231
  const routing = taskRouting(
220
232
  project.workspace_id,
221
233
  view,
222
234
  opts.model,
223
- opts.plan
235
+ opts.plan,
236
+ opts.planOnly
224
237
  );
225
238
  const projectPath = expandHome(project.working_directory);
226
239
  const naming = await nameFor(prompt, projectPath, opts.name);
@@ -266,7 +279,7 @@ async function startTask(
266
279
  // Everything the launch needs is on the row, so a restart can resume it.
267
280
  db.prepare(
268
281
  `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
269
- model_provider = ?, model_overrides = ?, plan_phase = ?,
282
+ model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
270
283
  plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
271
284
  ).run(
272
285
  prompt,
@@ -280,7 +293,8 @@ async function startTask(
280
293
  routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
281
294
  routing.override ? JSON.stringify(routing.override) : null,
282
295
  routing.plan ? "planning" : null,
283
- opts.plan === undefined ? null : opts.plan ? 1 : 0,
296
+ routing.planOnly ? 1 : null,
297
+ routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
284
298
  opts.planReason?.trim() || null,
285
299
  opts.startedBy ?? null,
286
300
  id
@@ -356,8 +370,10 @@ export async function taskView(session: Session): Promise<TaskView> {
356
370
  sessionStatus !== undefined
357
371
  ? await shellOnly(session.tmux_name)
358
372
  : false;
373
+ const planReady = live && session.plan_phase === "awaiting";
374
+ // A plan waiting for approval isn't a question the agent asked.
359
375
  const blocked =
360
- live && sessionStatus === "waiting"
376
+ live && sessionStatus === "waiting" && !planReady
361
377
  ? blockedReason(
362
378
  chat
363
379
  ? saidSinceLastMessage(session.id)
@@ -373,6 +389,7 @@ export async function taskView(session: Session): Promise<TaskView> {
373
389
  sessionStatus: agentGone ? "dead" : sessionStatus,
374
390
  pr,
375
391
  blocked: blocked !== null,
392
+ planReady,
376
393
  settingUp: setup?.status === "running" || setup?.status === "held",
377
394
  });
378
395
  syncTaskCardInBackground(session, state, pr);
@@ -399,6 +416,7 @@ export async function taskView(session: Session): Promise<TaskView> {
399
416
  armed: !!session.merge_armed_at,
400
417
  model: sessionModelRef(session),
401
418
  planning: session.plan_phase === "planning",
419
+ planReady,
402
420
  plan: taskPlan(session),
403
421
  quickOf: session.quick_of ?? null,
404
422
  lastActiveAt: session.held_by_device ? lastActiveOf(session) : undefined,
@@ -443,20 +461,23 @@ export function taskRouting(
443
461
  workspaceId: string | null,
444
462
  view: StartView,
445
463
  model: unknown,
446
- plan?: boolean
464
+ plan?: boolean,
465
+ planOnly?: boolean
447
466
  ) {
448
467
  const override = parseOverride(model);
449
468
  const models = effectiveModels();
450
469
  const opts = { workspaceId, override };
451
470
  const implement = resolveRole("implement", opts, models);
452
471
  const planning = resolveRole("plan", opts, models);
472
+ // Plan only plans even on one model: the stop is the point, not a switch.
453
473
  const planStep =
454
474
  view === "chat" &&
455
- (plan ?? models.planStep) &&
456
- planning.ref !== implement.ref;
475
+ (!!planOnly ||
476
+ ((plan ?? models.planStep) && planning.ref !== implement.ref));
457
477
  return {
458
478
  override,
459
479
  plan: planStep,
480
+ planOnly: planStep && !!planOnly,
460
481
  first: planStep ? planning : implement,
461
482
  };
462
483
  }
@@ -25,6 +25,8 @@ export function moveRefusal(session: Session): string | null {
25
25
  return "It's a quick change its orchestrator makes on this machine, so it stays here";
26
26
  if (escalations(session.id).length)
27
27
  return "It's waiting on you (an escalated gate), so it stays here";
28
+ if (session.plan_only && session.plan_phase !== "implementing")
29
+ return "It's a plan-only task that hasn't been approved, so it stays here";
28
30
  const ask = db
29
31
  .prepare(
30
32
  `SELECT 1 FROM orchestrator_asks WHERE subject IN (?, ?) AND status = 'open' LIMIT 1`
package/lib/tasks/move.ts CHANGED
@@ -137,7 +137,11 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
137
137
  since: isoUTC(session.task_since ?? session.created_at),
138
138
  modelProvider: session.model_provider ?? null,
139
139
  modelOverrides: session.model_overrides ?? null,
140
- planPhase: session.plan_phase ?? null,
140
+ // moveRefusal keeps an unapproved plan-only task here.
141
+ planPhase: (session.plan_phase ?? null) as
142
+ | "planning"
143
+ | "implementing"
144
+ | null,
141
145
  planFile: readPlanFile(session.working_directory),
142
146
  orchestrator: orchestratorOf(session),
143
147
  };
@@ -102,7 +102,12 @@ export function taskPlan(session: Session): TaskPlan | null {
102
102
  return {
103
103
  ...base,
104
104
  planned: true,
105
- status: phase === "planning" ? "writing" : "approved",
105
+ status:
106
+ phase === "planning"
107
+ ? "writing"
108
+ : phase === "awaiting"
109
+ ? "ready"
110
+ : "approved",
106
111
  source: "step",
107
112
  title: planTitle(body),
108
113
  path: text !== null ? PLAN_FILE : null,
@@ -48,6 +48,7 @@ export interface QueueRow {
48
48
  // orchestrator session that started it.
49
49
  plan: number | null;
50
50
  plan_reason: string | null;
51
+ plan_only: number | null;
51
52
  started_by: string | null;
52
53
  view: "chat" | "terminal" | null;
53
54
  base_branch: string | null;
@@ -222,6 +223,7 @@ export interface QueueRequest {
222
223
  model?: ModelOverride;
223
224
  plan?: boolean;
224
225
  planReason?: string | null;
226
+ planOnly?: boolean;
225
227
  startedBy?: string | null;
226
228
  view?: "chat" | "terminal";
227
229
  baseBranch?: string;
@@ -277,9 +279,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
277
279
  if (!req.prompt.trim()) throw new Error("Describe the task");
278
280
  const id = randomUUID();
279
281
  db.prepare(
280
- `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, started_by, view, base_branch,
282
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, base_branch,
281
283
  host_id, after_task, after_any, origin_workspace_id, position)
282
- VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @started_by, @view, @base_branch,
284
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @base_branch,
283
285
  @host_id, @after_task, @after_any, @origin,
284
286
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
285
287
  ).run({
@@ -294,6 +296,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
294
296
  : null,
295
297
  plan: req.plan === undefined ? null : req.plan ? 1 : 0,
296
298
  plan_reason: req.planReason?.trim() || null,
299
+ plan_only: req.planOnly ? 1 : null,
297
300
  started_by: req.startedBy ?? null,
298
301
  view: req.view ?? null,
299
302
  base_branch: req.baseBranch || null,
@@ -472,6 +475,7 @@ async function startRow(
472
475
  : (r.model ?? undefined),
473
476
  plan: r.plan == null ? undefined : !!r.plan,
474
477
  planReason: r.plan_reason,
478
+ planOnly: !!r.plan_only,
475
479
  startedBy: r.started_by,
476
480
  view: r.view ?? undefined,
477
481
  baseBranch: r.base_branch ?? undefined,
@@ -141,7 +141,7 @@ export async function finishTaskStart(
141
141
  const task = (session.task_prompt ?? "") + setupNote(setup);
142
142
  const planning = session.plan_phase === "planning";
143
143
  if (planning) preparePlanDir(session.worktree_path);
144
- const prompt = planning ? planPrompt(task) : task;
144
+ const prompt = planning ? planPrompt(task, !!session.plan_only) : task;
145
145
  try {
146
146
  if (session.view === "chat") await launchChat(sessionId, prompt);
147
147
  else {
@@ -41,6 +41,8 @@ export type TaskState =
41
41
  | "blocked"
42
42
  | "review"
43
43
  | "checks-failing"
44
+ // A plan-only task's plan is written; it waits for approval.
45
+ | "plan-ready"
44
46
  | "exited"
45
47
  | "merged"
46
48
  | "dropped"
@@ -56,11 +58,14 @@ export interface TaskStateInput {
56
58
  blocked: boolean;
57
59
  // Its worktree is still being set up; the agent hasn't started yet.
58
60
  settingUp?: boolean;
61
+ // plan_phase "awaiting": no agent runs, and none is needed.
62
+ planReady?: boolean;
59
63
  }
60
64
 
61
65
  // Ordered by what needs the human first.
62
66
  export function deriveTaskState(input: TaskStateInput): TaskState {
63
- const { taskStatus, sessionStatus, pr, blocked, settingUp } = input;
67
+ const { taskStatus, sessionStatus, pr, blocked, settingUp, planReady } =
68
+ input;
64
69
  if (taskStatus === "merged" || pr?.state === "MERGED") return "merged";
65
70
  if (taskStatus === "dropped") return "dropped";
66
71
  if (taskStatus === "done" || taskStatus === "moved") return "done";
@@ -70,6 +75,7 @@ export function deriveTaskState(input: TaskStateInput): TaskState {
70
75
  if (pr?.state === "OPEN") {
71
76
  return pr.checks === "fail" ? "checks-failing" : "review";
72
77
  }
78
+ if (planReady && sessionStatus !== "running") return "plan-ready";
73
79
  if (!sessionStatus || sessionStatus === "dead") return "exited";
74
80
  if (sessionStatus === "running") return "working";
75
81
  return "needs-input";
@@ -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.519",
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
  };