@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
@@ -537,6 +537,10 @@ class SessionStatusDetector {
537
537
  if (this.peersAsked.has(hostId)) return;
538
538
  this.peersAsked.add(hostId);
539
539
  this.hostListedAt.set(hostId, Date.now());
540
+ // What its held tasks have said to their orchestrators here.
541
+ void import("./tasks/held-replies")
542
+ .then((m) => m.pullHeldReplies(link))
543
+ .catch(() => undefined);
540
544
  void peerTmuxSessions(link)
541
545
  .then(
542
546
  (list) => {
@@ -5,7 +5,12 @@
5
5
  */
6
6
 
7
7
  import type { Session } from "../db";
8
- import { cleanRemoteText, hostApi, requireHostLink } from "../hosts/remote-api";
8
+ import {
9
+ cleanRemoteBlock,
10
+ cleanRemoteText,
11
+ hostApi,
12
+ requireHostLink,
13
+ } from "../hosts/remote-api";
9
14
  import type { Delivery } from "../bus/delivery";
10
15
 
11
16
  const path = (id: string, leaf: string) =>
@@ -54,7 +59,31 @@ export async function heldChatTail(
54
59
  `${path(task.id, "chat-tail")}?lines=${lines}`,
55
60
  { timeout: 10000 }
56
61
  ).catch(() => ({ text: undefined }));
57
- return typeof text === "string" ? cleanRemoteText(text) : null;
62
+ // Whole lines, as a local read has them (read.ts caps and trims them).
63
+ return typeof text === "string" ? cleanRemoteBlock(text, 16000) : null;
64
+ }
65
+
66
+ /**
67
+ * What its chat says about being blocked or asking, as that machine reads it
68
+ * (the same reading a local chat task gets). Null when it can't be asked,
69
+ * which callers treat as unread, never as clear.
70
+ */
71
+ export async function heldChatFacts(
72
+ task: Pick<Session, "id" | "host_id">
73
+ ): Promise<{ blocked: string | null; turn: string | null } | null> {
74
+ const link = requireHostLink(task.host_id);
75
+ const res = await hostApi<{ blocked?: unknown; turn?: unknown }>(
76
+ link,
77
+ path(task.id, "chat-facts"),
78
+ { timeout: 10000 }
79
+ ).catch(() => null);
80
+ if (!res || (res.blocked !== null && typeof res.blocked !== "string"))
81
+ return null;
82
+ if (res.turn !== null && typeof res.turn !== "string") return null;
83
+ return {
84
+ blocked: res.blocked ? cleanRemoteBlock(res.blocked, 2000) : null,
85
+ turn: res.turn,
86
+ };
58
87
  }
59
88
 
60
89
  /** Stops its agent there; its conversation and worktree are kept. */
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What the tasks this machine's orchestrators moved to a linked machine say
3
+ * to those orchestrators. That machine only stores the messages (bus/held-reply);
4
+ * this machine, which polls, pulls them and delivers each as that task's own
5
+ * message to the orchestrator that started it. Who receives it is this
6
+ * machine's own record (started_by), never anything the other machine names.
7
+ */
8
+
9
+ import { db, type Session } from "../db";
10
+ import { sendMessage } from "../bus";
11
+ import { hostApi, type HostLink } from "../hosts/remote-api";
12
+
13
+ const PULL_EVERY_MS = 5000;
14
+ const MISSING_FOR_MS = 10 * 60 * 1000;
15
+ const MAX_BODY = 8000;
16
+
17
+ const g = globalThis as unknown as {
18
+ __agentosRepliesAt?: Map<string, number>;
19
+ __agentosRepliesBusy?: Set<string>;
20
+ };
21
+ const lastAsked = (g.__agentosRepliesAt ??= new Map());
22
+ const busy = (g.__agentosRepliesBusy ??= new Set());
23
+
24
+ interface Pulled {
25
+ id: number;
26
+ fromId: string;
27
+ body: string;
28
+ }
29
+
30
+ const heldMirror = (hostId: string, id: string) =>
31
+ db
32
+ .prepare(
33
+ `SELECT * FROM sessions WHERE id = ? AND host_id = ?
34
+ AND started_by IS NOT NULL AND task_status IN ('running', 'moving')`
35
+ )
36
+ .get(id, hostId) as Session | undefined;
37
+
38
+ export function hasHeldMirrors(hostId: string): boolean {
39
+ return !!db
40
+ .prepare(
41
+ `SELECT 1 FROM sessions WHERE host_id = ? AND started_by IS NOT NULL
42
+ AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
43
+ )
44
+ .get(hostId);
45
+ }
46
+
47
+ /** Delivers what the link's machine holds for this machine's orchestrators. */
48
+ export async function pullHeldReplies(link: HostLink): Promise<number> {
49
+ const hostId = link.hostId;
50
+ if (busy.has(hostId) || !hasHeldMirrors(hostId)) return 0;
51
+ const now = Date.now();
52
+ if (now - (lastAsked.get(hostId) ?? 0) < PULL_EVERY_MS) return 0;
53
+ lastAsked.set(hostId, now);
54
+ busy.add(hostId);
55
+ try {
56
+ let messages: Pulled[];
57
+ try {
58
+ const res = await hostApi<{ messages?: unknown }>(
59
+ link,
60
+ "/api/tasks/held-replies",
61
+ { timeout: 8000 }
62
+ );
63
+ messages = (Array.isArray(res.messages) ? res.messages : []).filter(
64
+ (m): m is Pulled =>
65
+ !!m &&
66
+ Number.isSafeInteger(m.id) &&
67
+ typeof m.fromId === "string" &&
68
+ typeof m.body === "string"
69
+ );
70
+ } catch (err) {
71
+ // A machine that predates replies: leave it alone for a while.
72
+ if ((err as { status?: number }).status === 404)
73
+ lastAsked.set(hostId, now + MISSING_FOR_MS);
74
+ return 0;
75
+ }
76
+ const handled: number[] = [];
77
+ let delivered = 0;
78
+ for (const m of messages) {
79
+ const mirror = heldMirror(hostId, m.fromId);
80
+ if (!mirror) {
81
+ handled.push(m.id); // nothing here to deliver it from
82
+ continue;
83
+ }
84
+ try {
85
+ await sendMessage({
86
+ fromId: mirror.id,
87
+ to: mirror.started_by!,
88
+ body: m.body.slice(0, MAX_BODY),
89
+ relayKey: `${hostId}:${m.id}`,
90
+ });
91
+ handled.push(m.id);
92
+ delivered++;
93
+ } catch {
94
+ // Gone, or rate limited: offered again at the next pull.
95
+ }
96
+ }
97
+ if (handled.length)
98
+ await hostApi(link, "/api/tasks/held-replies", {
99
+ body: { ids: handled },
100
+ timeout: 8000,
101
+ }).catch(() => undefined);
102
+ return delivered;
103
+ } finally {
104
+ busy.delete(hostId);
105
+ }
106
+ }
@@ -174,10 +174,13 @@ async function arrive(
174
174
  if (holder)
175
175
  db.prepare(
176
176
  `UPDATE sessions SET held_by_device = ?, held_from = ?,
177
+ held_for = ?, held_for_name = ?,
177
178
  model_provider = ?, model_overrides = ? WHERE id = ?`
178
179
  ).run(
179
180
  holder,
180
181
  orch.heldFrom,
182
+ orch.orchestrator?.id ?? null,
183
+ orch.orchestrator?.name ?? null,
181
184
  orch.modelProvider,
182
185
  orch.modelOverrides,
183
186
  id
@@ -203,9 +206,8 @@ async function arrive(
203
206
  });
204
207
  }, `setup-moved-${bundle.branch}`);
205
208
  }
206
- const prompt = claude
207
- ? arrivalNote(bundle, worktreePath)
208
- : `${bundle.prompt}\n\n${arrivalNote(bundle, worktreePath)}`;
209
+ const note = arrivalNote(bundle, worktreePath, os.hostname());
210
+ const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
209
211
  if (bundle.chat) {
210
212
  // Its worker starts on the carried conversation, takes the note
211
213
  // first, then what was queued.
@@ -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
  }
@@ -71,6 +71,9 @@ export interface OrchestratedBundle {
71
71
  planPhase: "planning" | "implementing" | null;
72
72
  // .agentos/plan.md, which is never in git.
73
73
  planFile: string | null;
74
+ // The orchestrator that holds the task (its id and name on the machine
75
+ // that runs it), so the task can message it from where it runs.
76
+ orchestrator?: { id: string; name: string };
74
77
  }
75
78
 
76
79
  export interface ChatBundle {
@@ -147,6 +150,19 @@ function validateOrchestrated(o: OrchestratedBundle): OrchestratedBundle {
147
150
  modelOverrides: o.modelOverrides,
148
151
  planPhase: o.planPhase,
149
152
  planFile: o.planFile,
153
+ orchestrator:
154
+ o.orchestrator &&
155
+ isSessionId(o.orchestrator.id) &&
156
+ typeof o.orchestrator.name === "string" &&
157
+ o.orchestrator.name.trim()
158
+ ? {
159
+ id: o.orchestrator.id,
160
+ name: o.orchestrator.name
161
+ .replace(/[\u0000-\u001f]/g, " ")
162
+ .trim()
163
+ .slice(0, 200),
164
+ }
165
+ : undefined,
150
166
  };
151
167
  }
152
168
 
@@ -192,10 +208,11 @@ export function validateBundle(b: TaskBundle): TaskBundle {
192
208
 
193
209
  export function arrivalNote(
194
210
  b: { from: string; branch: string },
195
- cwd: string
211
+ cwd: string,
212
+ here: string
196
213
  ): string {
197
214
  return [
198
- `This task just moved here from ${b.from}. Your worktree is now ${cwd}, on branch ${b.branch}.`,
215
+ `This task just moved from ${b.from} to ${here}. It now runs on ${here}: every command you run executes there, not on ${b.from}, so name ${here} when you report where something ran. Your worktree is now ${cwd}, on branch ${b.branch}.`,
199
216
  `Anything that was uncommitted came over as a commit starting "wip: moving to"; fold it into your own commits as you see fit.`,
200
217
  `Continue the task where you left off.`,
201
218
  ].join(" ");
@@ -10,7 +10,7 @@ import { db, type Session } from "../db";
10
10
  import { isRemoteHost } from "../hosts";
11
11
  import { requireHostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
- import { exportOrResume, markMoved, resumeTask, type TaskBundle } from "./move";
13
+ import { exportOrResume, resumeTask, type TaskBundle } from "./move";
14
14
  import { isOrchestrated, moveRefusal } from "./move-guard";
15
15
  import { holderRefusal } from "./held";
16
16
  import { isPaused } from "../orchestrator/pause";
@@ -146,6 +146,13 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
146
146
  return settleMovedOut(session, hostId, link.hostName, there);
147
147
  }
148
148
 
149
+ function mirrorRunning(id: string): void {
150
+ db.prepare(
151
+ `UPDATE sessions SET task_status = 'running', moved_to = NULL
152
+ WHERE id = ? AND task_status = 'moving'`
153
+ ).run(id);
154
+ }
155
+
149
156
  async function moveIn(mirror: Session): Promise<Session> {
150
157
  const link = requireHostLink(mirror.host_id);
151
158
  if (mirror.view === "chat") await requireChatMoves(link);
@@ -154,6 +161,12 @@ async function moveIn(mirror: Session): Promise<Session> {
154
161
  // A retry after it arrived here but that machine didn't hear: tidy up.
155
162
  let here = arrivedHere(mirror.id);
156
163
  if (!here) {
164
+ // Moving, so nothing reads the mirror as a task to judge, or sign off,
165
+ // while it leaves (a refusal below puts it back).
166
+ db.prepare(
167
+ `UPDATE sessions SET task_status = 'moving', moved_to = 'this machine'
168
+ WHERE id = ? AND task_status = 'running'`
169
+ ).run(mirror.id);
157
170
  // That machine resumes the agent itself if its half fails there.
158
171
  stepProgress(mirror.id, "export");
159
172
  let bundle: TaskBundle;
@@ -165,6 +178,7 @@ async function moveIn(mirror: Session): Promise<Session> {
165
178
  300000
166
179
  ));
167
180
  } catch (err) {
181
+ mirrorRunning(mirror.id);
168
182
  if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
169
183
  throw err;
170
184
  }
@@ -176,6 +190,7 @@ async function moveIn(mirror: Session): Promise<Session> {
176
190
  `${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
177
191
  );
178
192
  });
193
+ mirrorRunning(mirror.id);
179
194
  throw err;
180
195
  }
181
196
  }
@@ -190,7 +205,11 @@ async function moveIn(mirror: Session): Promise<Session> {
190
205
  `It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
191
206
  );
192
207
  }
193
- markMoved(mirror.id, os.hostname());
208
+ // The mirror was only how this machine showed it: the task is the new row.
209
+ db.prepare(`DELETE FROM sessions WHERE id = ? AND host_id = ?`).run(
210
+ mirror.id,
211
+ mirror.host_id
212
+ );
194
213
  forgetHostTasks(link.hostId);
195
214
  return db
196
215
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
@@ -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
@@ -115,6 +115,20 @@ const readPlanFile = (cwd: string): string | null => {
115
115
  }
116
116
  };
117
117
 
118
+ // The orchestrator that holds the task: its own, or the one on the machine
119
+ // that moved it here.
120
+ function orchestratorOf(
121
+ session: Session
122
+ ): { id: string; name: string } | undefined {
123
+ if (session.held_for && session.held_for_name)
124
+ return { id: session.held_for, name: session.held_for_name };
125
+ if (!session.started_by) return undefined;
126
+ const o = db
127
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
128
+ .get(session.started_by) as { name: string } | undefined;
129
+ return o ? { id: session.started_by, name: o.name } : undefined;
130
+ }
131
+
118
132
  // What an orchestrator's task carries besides its work (the orchestrator's
119
133
  // own state is re-keyed here; see move-carry).
120
134
  function orchestratedBundle(session: Session): OrchestratedBundle {
@@ -123,8 +137,13 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
123
137
  since: isoUTC(session.task_since ?? session.created_at),
124
138
  modelProvider: session.model_provider ?? null,
125
139
  modelOverrides: session.model_overrides ?? null,
126
- 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,
127
145
  planFile: readPlanFile(session.working_directory),
146
+ orchestrator: orchestratorOf(session),
128
147
  };
129
148
  }
130
149
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What a machine tidies up after a task it held went back to the machine
3
+ * that sent it: its agent and chat worker stop, its ports are released, and
4
+ * its worktree goes when nothing in it would be lost (no uncommitted change,
5
+ * no commit a remote lacks). The row stays, archived, as history.
6
+ */
7
+
8
+ import { db, type Session } from "../db";
9
+ import { endSessionAgent } from "../sessions/end";
10
+ import { releasePorts } from "../ports";
11
+ import { settleWorktree, type WorktreeFate } from "../done/worktree";
12
+ import { archiveSession } from "../done/archive";
13
+
14
+ export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
15
+ const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
16
+ | Session
17
+ | undefined;
18
+ if (!s || s.task_status !== "moved" || !s.held_by_device)
19
+ return { action: "none" };
20
+ await endSessionAgent(s).catch((err) =>
21
+ console.error(`[moved-away] ${id}: agent not stopped:`, err)
22
+ );
23
+ releasePorts(id);
24
+ const fate = await settleWorktree(s, null, true).catch(
25
+ (err): WorktreeFate => ({
26
+ action: "kept",
27
+ why: `it couldn't be checked: ${err instanceof Error ? err.message : err}`,
28
+ path: s.worktree_path ?? "",
29
+ })
30
+ );
31
+ archiveSession(id);
32
+ if (fate.action === "kept")
33
+ console.warn(
34
+ `[moved-away] ${id}: worktree kept at ${fate.path}: ${fate.why}`
35
+ );
36
+ return fate;
37
+ }
@@ -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,
@@ -30,6 +30,9 @@ export const TASK_CAPABILITIES = [
30
30
  // Holds an orchestrator's task for the machine that sent it, and answers
31
31
  // its messages, reads, stops and done (lib/tasks/held.ts).
32
32
  "orchestrated-move",
33
+ // A held task's chat facts and its messages for its orchestrator.
34
+ "held-chat-facts",
35
+ "held-replies",
33
36
  ] as const;
34
37
 
35
38
  export interface HostTasks {
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.518",
3
+ "version": "0.3.520",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"