@saadnvd1/agent-os 0.3.518 → 0.3.520

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 (47) hide show
  1. package/README.md +19 -1
  2. package/app/api/bus/peers/route.ts +8 -2
  3. package/app/api/bus/spawn/route.ts +4 -0
  4. package/app/api/tasks/[id]/approve-plan/route.ts +22 -0
  5. package/app/api/tasks/[id]/chat-facts/route.ts +21 -0
  6. package/app/api/tasks/[id]/moved/route.ts +7 -0
  7. package/app/api/tasks/held-replies/route.ts +31 -0
  8. package/app/api/tasks/route.ts +2 -0
  9. package/components/Tasks/TaskRow.tsx +46 -1
  10. package/components/Tasks/task-state-labels.ts +1 -0
  11. package/data/tasks/index.ts +12 -0
  12. package/lib/bus/held-reply.ts +89 -0
  13. package/lib/bus/index.ts +86 -10
  14. package/lib/db/migrations.ts +40 -0
  15. package/lib/db/types.ts +5 -1
  16. package/lib/done/worktree.ts +24 -10
  17. package/lib/hosts/remote-api.ts +12 -0
  18. package/lib/lumifyhub/lists.ts +1 -0
  19. package/lib/models/plan-step.ts +116 -32
  20. package/lib/models/roles.ts +3 -1
  21. package/lib/orchestrator/act.ts +24 -2
  22. package/lib/orchestrator/brief.ts +3 -1
  23. package/lib/orchestrator/conditions.ts +1 -0
  24. package/lib/orchestrator/describe.ts +1 -1
  25. package/lib/orchestrator/facts.ts +11 -2
  26. package/lib/orchestrator/serve.ts +8 -0
  27. package/lib/orchestrator/task-state.ts +40 -15
  28. package/lib/orchestrator/tool-names.ts +1 -0
  29. package/lib/orchestrator/tool-schemas.ts +18 -0
  30. package/lib/orchestrator/tools.ts +3 -1
  31. package/lib/security/demo.ts +1 -0
  32. package/lib/status-detector.ts +4 -0
  33. package/lib/tasks/held-remote.ts +31 -2
  34. package/lib/tasks/held-replies.ts +106 -0
  35. package/lib/tasks/import.ts +5 -3
  36. package/lib/tasks/index.ts +28 -7
  37. package/lib/tasks/move-bundle.ts +19 -2
  38. package/lib/tasks/move-flow.ts +21 -2
  39. package/lib/tasks/move-guard.ts +2 -0
  40. package/lib/tasks/move.ts +20 -1
  41. package/lib/tasks/moved-away.ts +37 -0
  42. package/lib/tasks/plan-view.ts +6 -1
  43. package/lib/tasks/queue.ts +6 -2
  44. package/lib/tasks/remote.ts +3 -0
  45. package/lib/tasks/start.ts +1 -1
  46. package/lib/tasks/state.ts +7 -1
  47. package/package.json +1 -1
@@ -114,14 +114,15 @@ const gone = (merged: MergedAs | null, still = false) => ({
114
114
  export async function worktreeFate(
115
115
  session: Judged,
116
116
  merged: MergedAs | null,
117
- waitMs = 60_000
117
+ waitMs = 60_000,
118
+ movedAway = false
118
119
  ): Promise<FatePreview> {
119
120
  const path = session.worktree_path;
120
121
  if (!path) return { action: "none" };
121
122
  for (;;) {
122
123
  if (!(await removalSettled(path, waitMs))) return gone(merged, true);
123
124
  if (!fs.existsSync(path)) return gone(merged);
124
- const fate = await judge(session, merged);
125
+ const fate = await judge(session, merged, movedAway);
125
126
  // A removal that started while it read: read what it leaves.
126
127
  if (!removalRunning(path) && fs.existsSync(path)) return fate;
127
128
  }
@@ -133,8 +134,17 @@ async function commitsKeep(
133
134
  path: string,
134
135
  repo: string,
135
136
  session: Judged,
136
- merged: MergedAs | null
137
+ merged: MergedAs | null,
138
+ movedAway = false
137
139
  ): Promise<string | null> {
140
+ // The task went on elsewhere: only commits no remote has would be lost.
141
+ if (movedAway) {
142
+ const stray = await strayCommits(path, {}).catch(() => null);
143
+ if (stray === null) return "its commits can't be checked";
144
+ return stray > 0
145
+ ? `${stray} commit${stray === 1 ? " is" : "s are"} on it that no remote has`
146
+ : null;
147
+ }
138
148
  if (merged) {
139
149
  const stray = await strayCommits(path, merged).catch(() => null);
140
150
  if (stray === null) return "its commits can't be checked";
@@ -163,7 +173,8 @@ async function commitsKeep(
163
173
  // kept one names every reason, the commits as well as the changes.
164
174
  async function judge(
165
175
  session: Judged,
166
- merged: MergedAs | null
176
+ merged: MergedAs | null,
177
+ movedAway = false
167
178
  ): Promise<FatePreview> {
168
179
  const path = session.worktree_path!;
169
180
  const keep = (why: string) => ({ action: "kept" as const, why, path });
@@ -183,14 +194,16 @@ async function judge(
183
194
  unsaved.length
184
195
  ? `it has uncommitted changes (${describeChanges(unsaved)})`
185
196
  : null,
186
- await commitsKeep(path, repo, session, merged),
197
+ await commitsKeep(path, repo, session, merged, movedAway),
187
198
  ].filter((w) => w !== null);
188
199
  if (why.length) return keep(why.join(", and "));
189
200
  return {
190
201
  action: "remove",
191
- why: merged
192
- ? "its branch is merged"
193
- : "its branch has no commits of its own",
202
+ why: movedAway
203
+ ? "its work moved to another machine"
204
+ : merged
205
+ ? "its branch is merged"
206
+ : "its branch has no commits of its own",
194
207
  repo,
195
208
  };
196
209
  }
@@ -199,9 +212,10 @@ async function judge(
199
212
  // and says so only once it's really gone.
200
213
  export async function settleWorktree(
201
214
  session: Judged,
202
- merged: MergedAs | null
215
+ merged: MergedAs | null,
216
+ movedAway = false
203
217
  ): Promise<WorktreeFate> {
204
- const fate = await worktreeFate(session, merged);
218
+ const fate = await worktreeFate(session, merged, undefined, movedAway);
205
219
  if (fate.action !== "remove") return fate;
206
220
  const path = session.worktree_path!;
207
221
  await deleteWorktree(path, fate.repo, true).catch(() => {});
@@ -70,6 +70,18 @@ export const cleanRemoteText = (s: unknown): string =>
70
70
  .trim()
71
71
  .slice(0, 300);
72
72
 
73
+ /**
74
+ * Another machine's multi-line text (a chat's tail): control characters
75
+ * other than newline and tab become spaces, and the end is kept within `cap`.
76
+ */
77
+ export const cleanRemoteBlock = (s: unknown, cap: number): string => {
78
+ const text = String(s ?? "")
79
+ .replace(/\r\n?/g, "\n")
80
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, " ")
81
+ .replace(/\s+$/, "");
82
+ return text.length > cap ? `…${text.slice(text.length - cap + 1)}` : text;
83
+ };
84
+
73
85
  /**
74
86
  * Why a call failed. `refused`: that AgentOS answered and said no, so nothing
75
87
  * happened there. Otherwise the outcome is unknown (unreachable, timed out,
@@ -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
  }
@@ -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);
@@ -9,6 +9,7 @@ import { blockedReason } from "../tasks/state";
9
9
  import { checkWaitingPatterns, statusDetector } from "../status-detector";
10
10
  import { getCheck, putCheck } from "./checks";
11
11
  import { cleanRemoteText, hostLink, type HostLink } from "../hosts/remote-api";
12
+ import { heldChatFacts } from "../tasks/held-remote";
12
13
  import { hostTasks } from "../tasks/remote";
13
14
  import { peerPane } from "../hosts/peer-sessions";
14
15
  import { untrusted } from "./untrusted";
@@ -24,21 +25,7 @@ export async function waitingState(
24
25
  // its own reading of the task is asked for, fresh.
25
26
  const link = hostLink(task.host_id);
26
27
  if (link && task.view === "chat") return peerChatWaiting(link, task);
27
- if (task.view === "chat") {
28
- const reason = blockedReason(saidSinceLastMessage(task.id));
29
- // Asked of a worker still running from before a restart, too; one
30
- // that can't be reached can't be cleared.
31
- const state = await chatStateNow(task.id).catch(() => "unknown");
32
- return {
33
- blocked: reason === null ? null : untrusted(task.name, reason),
34
- waitingOn:
35
- state === "waiting"
36
- ? "an approval or question in its chat"
37
- : state === "unknown"
38
- ? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
39
- : null,
40
- };
41
- }
28
+ if (task.view === "chat") return localChatWaiting(task);
42
29
  // A linked machine reads its own screens: asked, and an answer it can't
43
30
  // give is a screen that can't be cleared.
44
31
  let tail: string;
@@ -71,6 +58,26 @@ export async function waitingState(
71
58
  };
72
59
  }
73
60
 
61
+ // A chat task whose conversation is in this machine's store (or, for a held
62
+ // task, the one the holder asks about).
63
+ export async function localChatWaiting(
64
+ task: Pick<Session, "id" | "name">
65
+ ): Promise<{ blocked: string | null; waitingOn: string | null }> {
66
+ const reason = blockedReason(saidSinceLastMessage(task.id));
67
+ // Asked of a worker still running from before a restart, too; one
68
+ // that can't be reached can't be cleared.
69
+ const state = await chatStateNow(task.id).catch(() => "unknown");
70
+ return {
71
+ blocked: reason === null ? null : untrusted(task.name, reason),
72
+ waitingOn:
73
+ state === "waiting"
74
+ ? "an approval or question in its chat"
75
+ : state === "unknown"
76
+ ? "its chat worker couldn't be reached, so an open approval or question can't be ruled out"
77
+ : null,
78
+ };
79
+ }
80
+
74
81
  async function peerChatWaiting(
75
82
  link: HostLink,
76
83
  task: Session
@@ -79,6 +86,24 @@ async function peerChatWaiting(
79
86
  blocked: `its chat on ${link.hostName} couldn't be read, so a BLOCKED: line or open question can't be ruled out`,
80
87
  waitingOn: null,
81
88
  };
89
+ // A task its orchestrator moved is asked about directly: the whole task
90
+ // list is slow, and a miss there reads as unread.
91
+ const facts = task.started_by ? await heldChatFacts(task) : null;
92
+ if (facts) {
93
+ const { turn } = facts;
94
+ return {
95
+ blocked: facts.blocked ? untrusted(task.name, facts.blocked) : null,
96
+ waitingOn:
97
+ turn === "waiting"
98
+ ? `an approval or question in its chat on ${link.hostName}`
99
+ : turn === null ||
100
+ turn === "running" ||
101
+ turn === "idle" ||
102
+ turn === "error"
103
+ ? null
104
+ : `its chat on ${link.hostName} couldn't say whether a question is open`,
105
+ };
106
+ }
82
107
  const view = await hostTasks(link, true)
83
108
  .then(({ tasks, capabilities }) =>
84
109
  // One that can't say its chat's turn can't rule out an open question.
@@ -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.",
@@ -56,6 +56,7 @@ export const STATIC_SIBLINGS = [
56
56
  "clone",
57
57
  "detect",
58
58
  "done-idle",
59
+ "held-replies",
59
60
  "import",
60
61
  "init",
61
62
  "init-script",