@saadnvd1/agent-os 0.3.529 → 0.3.531

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.
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import { pushPlanReady } from "../notify/push/events";
9
- import { handoffAfterPlan } from "../models/plan-step";
9
+ import {
10
+ handoffAfterPlan,
11
+ planTurnStarted,
12
+ recheckPlanning,
13
+ type PlanHandoffEnv,
14
+ } from "../models/plan-step";
10
15
  import { randomUUID } from "crypto";
11
16
  import os from "os";
12
17
  import { db, type Session } from "../db";
@@ -149,6 +154,18 @@ function retireIfStale(sessionId: string, live: Live): void {
149
154
  );
150
155
  }
151
156
 
157
+ // What the plan step needs to know of a chat, read when it asks.
158
+ function planEnv(sessionId: string): PlanHandoffEnv {
159
+ return {
160
+ backgroundRunning: () =>
161
+ (registry.live.get(sessionId)?.activity.tasks.size ?? 0) > 0,
162
+ turnRunning: () => {
163
+ const state = registry.live.get(sessionId)?.state;
164
+ return state === "running" || state === "waiting";
165
+ },
166
+ };
167
+ }
168
+
152
169
  function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
153
170
  track(live, e);
154
171
  if (e.type === "item" && e.item.kind === "task")
@@ -169,6 +186,7 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
169
186
  emit(sessionId, e);
170
187
  } else if (e.type === "state") {
171
188
  live.state = e.state;
189
+ if (e.state === "running") planTurnStarted(sessionId);
172
190
  // Whatever ended the turn ended the compaction too.
173
191
  if (e.state === "idle" || e.state === "error")
174
192
  setCompacting(sessionId, live, null);
@@ -177,8 +195,12 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
177
195
  live.retiring = undefined;
178
196
  retireIfStale(sessionId, live);
179
197
  if (e.state === "idle")
180
- void handoffAfterPlan(sessionId, (id, m) => sendQueuedNow(id, m)).catch(
181
- (err) => console.error(`Plan handoff for ${sessionId} failed:`, err)
198
+ void handoffAfterPlan(
199
+ sessionId,
200
+ (id, m) => sendQueuedNow(id, m),
201
+ planEnv(sessionId)
202
+ ).catch((err) =>
203
+ console.error(`Plan handoff for ${sessionId} failed:`, err)
182
204
  );
183
205
  } else if (e.type === "suggestion") {
184
206
  emit(sessionId, e);
@@ -940,6 +962,7 @@ export async function reattachChats(): Promise<void> {
940
962
  if (!live.has(id)) await resumeInterrupted(id);
941
963
  for (const id of queuedSessions(Date.now() - RESUME_WITHIN_MS))
942
964
  if (!live.has(id)) await resumeQueue(id);
965
+ await recheckPlanning((id, m) => sendQueuedNow(id, m), planEnv);
943
966
  }
944
967
 
945
968
  // Asks a chat's worker to end at its next turn boundary, so the next message
@@ -9,7 +9,7 @@
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import { db, type Session } from "../db";
12
- import { enqueue } from "../chat/queued";
12
+ import { deleteQueued, enqueue } from "../chat/queued";
13
13
  import { applySessionModel } from "./session-model";
14
14
  import {
15
15
  resolveRole,
@@ -31,9 +31,17 @@ export function planPrompt(task: string, only = false): string {
31
31
  return `${task}
32
32
 
33
33
  ---
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}.`;
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. Don't end your turn while subagents or background commands you started are still running; wait for their results and write the plan in the same turn. End the turn once the plan is written: ${after}.`;
35
35
  }
36
36
 
37
+ export const PLAN_RETRY_PROMPT = `Write the plan to \`${PLAN_FILE}\` now, plan only.`;
38
+ export const planRetryMessageId = (sessionId: string) =>
39
+ `plan-retry-${sessionId}`;
40
+
41
+ // How long a planning turn may sit ended on background work before it's
42
+ // treated as having nothing running and re-prompted.
43
+ export const PLAN_BACKGROUND_WAIT_MS = 20 * 60 * 1000;
44
+
37
45
  export function implementPrompt(planned: boolean, amendment?: string): string {
38
46
  const base = planned
39
47
  ? `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.`
@@ -80,7 +88,7 @@ export function planSummary(text: string, max = 5): string[] {
80
88
  }
81
89
 
82
90
  // Tells the orchestrator that started a task its plan is written, once.
83
- function announcePlan(s: Session, ref: string | null): void {
91
+ function announcePlan(s: Session, ref: string | null, retried = false): void {
84
92
  if (!s.started_by && !s.held_by_device) return;
85
93
  const workspaceId = sessionWorkspace(s);
86
94
  // A task held for another machine's orchestrator can't reach it: the line
@@ -91,7 +99,8 @@ function announcePlan(s: Session, ref: string | null): void {
91
99
  const next = ref
92
100
  ? `implementing on ${ref}`
93
101
  : "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" : ""}.`;
102
+ const again = retried ? " (asked once more for it first)" : "";
103
+ let head = `Task "${s.name}": no plan file was written${again}; ${next}${ref ? " anyway" : ""}.`;
95
104
  try {
96
105
  const summary = planSummary(fs.readFileSync(file, "utf8"));
97
106
  head = `Task "${s.name}": plan written to ${file}; ${next}.`;
@@ -128,7 +137,8 @@ async function startImplementing(
128
137
  s: Session,
129
138
  from: "planning" | "awaiting",
130
139
  send: Send,
131
- amendment?: string
140
+ amendment?: string,
141
+ retried = false
132
142
  ): Promise<string | null> {
133
143
  const sessionId = s.id;
134
144
  const routed = resolveRole("implement", {
@@ -158,7 +168,7 @@ async function startImplementing(
158
168
  if (!claimed) return null;
159
169
  if (from === "planning") {
160
170
  try {
161
- announcePlan(s, routed.ref);
171
+ announcePlan(s, routed.ref, retried);
162
172
  } catch (e) {
163
173
  console.error("plan-done event failed:", e);
164
174
  }
@@ -168,12 +178,57 @@ async function startImplementing(
168
178
  return routed.ref;
169
179
  }
170
180
 
181
+ // Per planning task, kept in memory: when its turn first ended on background
182
+ // work, and whether it has been asked once more for the plan. A restart
183
+ // forgets both, which costs at most one more re-prompt.
184
+ const waiting = new Map<
185
+ string,
186
+ { since: number; retried: boolean; timer?: ReturnType<typeof setTimeout> }
187
+ >();
188
+
189
+ export interface PlanHandoffEnv {
190
+ // Whether the session's agent still runs subagents or background shells.
191
+ backgroundRunning?: () => boolean;
192
+ // Whether the chat is in the middle of a turn right now.
193
+ turnRunning?: () => boolean;
194
+ waitMs?: number;
195
+ now?: () => number;
196
+ }
197
+
198
+ function stateOf(sessionId: string) {
199
+ let w = waiting.get(sessionId);
200
+ if (!w) waiting.set(sessionId, (w = { since: 0, retried: false }));
201
+ return w;
202
+ }
203
+
204
+ function forget(sessionId: string): void {
205
+ clearTimeout(waiting.get(sessionId)?.timer);
206
+ waiting.delete(sessionId);
207
+ }
208
+
209
+ // A task dropped, stopped or done: nothing left to wait for.
210
+ export const forgetPlanWait = forget;
211
+
212
+ // A new turn began (the agent resumed with its background results): whatever
213
+ // the wait was counting is stale, and the deadline restarts at the next idle.
214
+ export function planTurnStarted(sessionId: string): void {
215
+ const w = waiting.get(sessionId);
216
+ if (!w) return;
217
+ clearTimeout(w.timer);
218
+ w.timer = undefined;
219
+ w.since = 0;
220
+ }
221
+
171
222
  // Called when a chat goes idle. A task whose plan turn has ended (it has a
172
223
  // recorded plan turn) moves to implementing, once; a plan-only task instead
173
- // waits for approval (approvePlan), holding no agent process.
224
+ // waits for approval (approvePlan), holding no agent process. A turn that
225
+ // ended with no plan file isn't the end of planning while its background
226
+ // work runs (the agent resumes with the results): it waits, up to a limit,
227
+ // then asks once for the plan before falling back to today's behaviour.
174
228
  export async function handoffAfterPlan(
175
229
  sessionId: string,
176
- send: Send
230
+ send: Send,
231
+ env: PlanHandoffEnv = {}
177
232
  ): Promise<boolean> {
178
233
  const planned = db
179
234
  .prepare(
@@ -182,18 +237,74 @@ export async function handoffAfterPlan(
182
237
  .get(sessionId);
183
238
  if (!planned) return false;
184
239
  const s = getSession(sessionId);
185
- if (s?.plan_phase !== "planning" || !s.worktree_path) return false;
240
+ if (s?.plan_phase !== "planning" || !s.worktree_path) {
241
+ forget(sessionId);
242
+ return false;
243
+ }
244
+ if (s.task_status !== "running" || s.archived_at) {
245
+ forget(sessionId);
246
+ return false;
247
+ }
248
+ // Mid-turn, the turn's own end decides.
249
+ if (env.turnRunning?.()) return false;
250
+ if (!fs.existsSync(path.join(s.worktree_path, PLAN_FILE))) {
251
+ const w = stateOf(sessionId);
252
+ const now = (env.now ?? Date.now)();
253
+ const limit = env.waitMs ?? PLAN_BACKGROUND_WAIT_MS;
254
+ if (env.backgroundRunning?.()) {
255
+ if (!w.since) w.since = now;
256
+ if (now - w.since < limit) {
257
+ clearTimeout(w.timer);
258
+ // No further turn may come to re-check this; the timer does.
259
+ w.timer = setTimeout(
260
+ () =>
261
+ void handoffAfterPlan(sessionId, send, env).catch((e) =>
262
+ console.error(`Plan handoff for ${sessionId} failed:`, e)
263
+ ),
264
+ Math.max(limit - (now - w.since), 1) + 1
265
+ );
266
+ w.timer.unref?.();
267
+ return false;
268
+ }
269
+ }
270
+ if (!w.retried) {
271
+ w.retried = true;
272
+ w.since = 0;
273
+ clearTimeout(w.timer);
274
+ const id = planRetryMessageId(sessionId);
275
+ enqueue(sessionId, {
276
+ id,
277
+ text: PLAN_RETRY_PROMPT,
278
+ origin: { kind: "decision", label: "Plan step" },
279
+ });
280
+ try {
281
+ await send(sessionId, id);
282
+ } catch (e) {
283
+ // Not left queued behind a turn that may write the plan itself.
284
+ deleteQueued(sessionId, id);
285
+ throw e;
286
+ }
287
+ return false;
288
+ }
289
+ }
290
+ // A re-prompt still queued is stale now.
291
+ deleteQueued(sessionId, planRetryMessageId(sessionId));
292
+ const retried = waiting.get(sessionId)?.retried ?? false;
293
+ forget(sessionId);
186
294
  if (!s.plan_only)
187
- return (await startImplementing(s, "planning", send)) !== null;
188
- const waiting = db
295
+ return (
296
+ (await startImplementing(s, "planning", send, undefined, retried)) !==
297
+ null
298
+ );
299
+ const awaiting = db
189
300
  .prepare(
190
301
  `UPDATE sessions SET plan_phase = 'awaiting'
191
302
  WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
192
303
  )
193
304
  .run(sessionId).changes;
194
- if (!waiting) return false;
305
+ if (!awaiting) return false;
195
306
  try {
196
- announcePlan(s, null);
307
+ announcePlan(s, null, retried);
197
308
  } catch (e) {
198
309
  console.error("plan-done event failed:", e);
199
310
  }
@@ -207,6 +318,26 @@ export async function handoffAfterPlan(
207
318
  return true;
208
319
  }
209
320
 
321
+ // On server start: tasks still planning get the same check an idle chat
322
+ // does, since the idle that would have run it may have come and gone while
323
+ // the server was down. `envFor` reads the live chat's state.
324
+ export async function recheckPlanning(
325
+ send: Send,
326
+ envFor: (sessionId: string) => PlanHandoffEnv
327
+ ): Promise<void> {
328
+ const rows = db
329
+ .prepare(
330
+ `SELECT id FROM sessions
331
+ WHERE plan_phase = 'planning' AND task_status = 'running'
332
+ AND archived_at IS NULL AND worktree_path IS NOT NULL`
333
+ )
334
+ .all() as { id: string }[];
335
+ for (const { id } of rows)
336
+ await handoffAfterPlan(id, send, envFor(id)).catch((e) =>
337
+ console.error(`Plan re-check for ${id} failed:`, e)
338
+ );
339
+ }
340
+
210
341
  // approvePlan, sending through the chat runner (imported when needed: it
211
342
  // imports this file).
212
343
  export async function approvePlanNow(sessionId: string, message?: string) {
@@ -55,21 +55,21 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
55
55
  level: "active",
56
56
  defaultOn: true,
57
57
  label: "CI and review problems",
58
- word: "Update",
58
+ word: "CI or review failed",
59
59
  link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
60
60
  },
61
61
  merged: {
62
62
  level: "passive",
63
63
  defaultOn: false,
64
64
  label: "Merged",
65
- word: "Update",
65
+ word: "Merged",
66
66
  link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
67
67
  },
68
68
  agents: {
69
69
  level: "active",
70
70
  defaultOn: true,
71
71
  label: "Messages from agents",
72
- word: "Update",
72
+ word: "Agent message",
73
73
  link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
74
74
  },
75
75
  digest: {
@@ -83,7 +83,7 @@ export const CATEGORIES: Record<PushCategory, CategoryDef> = {
83
83
  level: "active",
84
84
  defaultOn: true,
85
85
  label: "Machine alerts",
86
- word: "Update",
86
+ word: "Machine alert",
87
87
  link: () => "/machines",
88
88
  },
89
89
  };
@@ -3,6 +3,7 @@ import { stopChat } from "../chat/runner";
3
3
  import { waitForExit } from "../chat/worker/client";
4
4
  import { endTmuxSession } from "../tmux/end";
5
5
  import { releaseOnEnd } from "../simulator";
6
+ import { forgetPlanWait } from "../models/plan-step";
6
7
 
7
8
  type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
8
9
 
@@ -10,6 +11,7 @@ type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
10
11
  // chat session can still own one). The one call that delete, done, drop and
11
12
  // finish share.
12
13
  export async function endSessionAgent(s: Agent): Promise<void> {
14
+ forgetPlanWait(s.id);
13
15
  stopChat(s.id);
14
16
  // The task's simulator goes with it; no row, no work.
15
17
  releaseOnEnd(s.id, { remove: true });
@@ -4,6 +4,7 @@ import { stopChatTurn } from "../chat/stop";
4
4
  import type { Session } from "../db";
5
5
  import { stopHeldTask } from "../tasks/held-remote";
6
6
  import { releaseOnEnd } from "../simulator";
7
+ import { forgetPlanWait } from "../models/plan-step";
7
8
 
8
9
  // Stops a session's agent and keeps everything else, the same for every
9
10
  // caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
@@ -31,6 +32,7 @@ export async function stopSession(
31
32
  throw new Error(
32
33
  `Couldn't stop ${s.name}: it runs on another machine; stop it there.`
33
34
  );
35
+ forgetPlanWait(s.id);
34
36
  const out = await stopChatTurn(s.id);
35
37
  if (!out.stopped)
36
38
  throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.529",
3
+ "version": "0.3.531",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"