@saadnvd1/agent-os 0.3.482 → 0.3.484

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 (38) hide show
  1. package/README.md +33 -7
  2. package/app/api/bus/stop/route.ts +26 -0
  3. package/app/api/fresh-start/route.ts +3 -1
  4. package/app/api/stall/route.ts +18 -0
  5. package/components/SessionList/RowParts.tsx +6 -0
  6. package/components/SessionList/SessionList.types.ts +1 -0
  7. package/components/Workspaces/FreshStartForm.tsx +21 -9
  8. package/components/Workspaces/StallForm.tsx +101 -0
  9. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  10. package/data/stall/index.ts +33 -0
  11. package/lib/chat/activity.ts +80 -6
  12. package/lib/chat/compact-context.ts +55 -0
  13. package/lib/chat/driver.ts +4 -0
  14. package/lib/chat/drivers/claude-approvals.ts +32 -0
  15. package/lib/chat/drivers/claude.ts +4 -0
  16. package/lib/chat/events.ts +1 -1
  17. package/lib/chat/refresh.ts +19 -6
  18. package/lib/chat/registry.ts +7 -0
  19. package/lib/chat/runner.ts +2 -0
  20. package/lib/chat/socket.ts +2 -2
  21. package/lib/chat/stale.ts +5 -4
  22. package/lib/chat/stall-limits.ts +5 -0
  23. package/lib/chat/stall.ts +50 -0
  24. package/lib/chat/stop.ts +67 -0
  25. package/lib/chat/worker/host.ts +30 -4
  26. package/lib/chat/worker/protocol.ts +2 -1
  27. package/lib/chat/working-state.ts +111 -0
  28. package/lib/db/migrations.ts +13 -0
  29. package/lib/orchestrator/act.ts +3 -9
  30. package/lib/orchestrator/conditions.ts +11 -0
  31. package/lib/orchestrator/facts.ts +5 -1
  32. package/lib/orchestrator/tools.ts +1 -1
  33. package/lib/sessions/stop.ts +28 -0
  34. package/lib/sidebar/shelves.ts +2 -0
  35. package/lib/stacks/restack.ts +7 -13
  36. package/lib/status/collect.ts +4 -1
  37. package/lib/usage/turns.ts +11 -4
  38. package/package.json +1 -1
@@ -6,8 +6,9 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the size at which chats and
10
- * tasks compact (lib/chat/stale).
9
+ * App-wide, in Settings → Workspaces, with the two sizes at which chats and
10
+ * tasks compact: mid-turn (Claude Code itself, high so a job keeps its
11
+ * files) and between turns (lib/chat/stale, earlier, nothing in flight).
11
12
  */
12
13
 
13
14
  import { db } from "../db";
@@ -20,16 +21,22 @@ export const TASK_KEY = "chat.task_refresh_tokens";
20
21
  export const COMPACT_KEY = "chat.stale_compact_tokens";
21
22
  export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
22
23
  export const DEFAULT_COMPACT_TOKENS = 150_000;
24
+ // Claude Code's own mid-turn auto-compact window: high, because a summary
25
+ // mid-job costs the agent its files and its place.
26
+ export const AUTO_COMPACT_KEY = "chat.auto_compact_tokens";
27
+ export const DEFAULT_AUTO_COMPACT_TOKENS = 300_000;
23
28
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
24
29
 
25
30
  export interface RefreshSettings {
26
31
  // Context tokens at which a conversation starts over; 0 is off.
27
32
  orchestratorTokens: number;
28
33
  taskTokens: number;
29
- // Context tokens at which a chat's or task's agent compacts: Claude
30
- // Code's own auto-compact window, and between turns a chat over it (or
31
- // idle past its cache) is compacted before the next message. 0 is off.
34
+ // Between turns: a chat over this size (or idle past its cache) is
35
+ // compacted before its next message. 0 is off.
32
36
  compactTokens: number;
37
+ // Mid-turn: Claude Code's own auto-compact window for a chat's or task's
38
+ // agent. 0 is off.
39
+ autoCompactTokens: number;
33
40
  }
34
41
 
35
42
  function read(key: string, fallback: number): number {
@@ -45,6 +52,7 @@ export function refreshSettings(): RefreshSettings {
45
52
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
46
53
  taskTokens: read(TASK_KEY, 0),
47
54
  compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
55
+ autoCompactTokens: read(AUTO_COMPACT_KEY, DEFAULT_AUTO_COMPACT_TOKENS),
48
56
  };
49
57
  }
50
58
 
@@ -79,6 +87,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
79
87
  checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
80
88
  if (input.compactTokens !== undefined)
81
89
  checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
90
+ if (input.autoCompactTokens !== undefined)
91
+ checked.push([
92
+ AUTO_COMPACT_KEY,
93
+ checkRefreshTokens(input.autoCompactTokens),
94
+ ]);
82
95
  db.transaction(() => {
83
96
  for (const [key, n] of checked) put.run(key, String(n));
84
97
  })();
@@ -119,7 +132,7 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
119
132
  .get(sessionId) as
120
133
  | { role: string | null; model_provider: string | null }
121
134
  | undefined;
122
- const tokens = refreshSettings().compactTokens;
135
+ const tokens = refreshSettings().autoCompactTokens;
123
136
  if (row?.role === "orchestrator" || tokens <= 0) return {};
124
137
  const window = compactThreshold(
125
138
  Math.max(AUTO_COMPACT_MIN, tokens),
@@ -34,6 +34,13 @@ export interface Live {
34
34
 
35
35
  export interface ChatActivity {
36
36
  turnStartedAt?: number;
37
+ // When the turn last produced anything (a token, an item, a tool result)
38
+ // and what that was; unset between turns. Silence from here is a stall.
39
+ lastAt?: number;
40
+ lastWhat?: string;
41
+ // Messages the user side has sent this conversation: a change while a stop
42
+ // waits means a new turn began, which the stop must leave alone.
43
+ sends?: number;
37
44
  // Running tool calls by id: their label and when they started.
38
45
  tools: Map<string, { label: string; since: number }>;
39
46
  // Running background tasks (not housekeeping ones).
@@ -697,6 +697,8 @@ export async function undoChat(
697
697
  reply({ type: "undone", from, text: target.text });
698
698
  }
699
699
 
700
+ // A plain Esc: ends the turn the way the agent would, and leaves a queue
701
+ // behind it alone. Stop (./stop stopChatTurn) is the one that must end it.
700
702
  export async function interruptChat(sessionId: string): Promise<void> {
701
703
  registry.live.get(sessionId)?.worker.command({ type: "interrupt" });
702
704
  }
@@ -16,7 +16,6 @@ import {
16
16
  chatToolBody,
17
17
  deleteQueuedChat,
18
18
  editQueuedChat,
19
- interruptChat,
20
19
  moveQueuedChat,
21
20
  refreshCapabilities,
22
21
  respondChat,
@@ -29,6 +28,7 @@ import {
29
28
  undoChat,
30
29
  watchChat,
31
30
  } from "./runner";
31
+ import { stopChatTurn } from "./stop";
32
32
  import { clientSend } from "./client-send";
33
33
  import { searchChat } from "./search";
34
34
  import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
@@ -175,7 +175,7 @@ function onChatMessage(
175
175
  .catch(() => [])
176
176
  .then(answer)
177
177
  );
178
- } else if (msg.type === "interrupt") run(interruptChat(sessionId));
178
+ } else if (msg.type === "interrupt") run(stopChatTurn(sessionId));
179
179
  else if (msg.type === "set_model") run(setChatModel(sessionId, msg.model));
180
180
  else if (msg.type === "set_access") run(setChatAccess(sessionId, msg.access));
181
181
  else if (msg.type === "set_plan") run(setChatPlan(sessionId, !!msg.plan));
package/lib/chat/stale.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Compacting between turns. A chat's agent compacts itself at the compact
3
- * threshold (Claude Code's auto-compact window, set at launch by
4
- * lib/agents/launch). Between turns AgentOS catches what it didn't: a chat
5
- * over the threshold is compacted before the next message, and so is one
2
+ * Compacting between turns. A chat's agent compacts itself mid-turn at its
3
+ * own, higher window (Claude Code's auto-compact, set at launch by
4
+ * lib/agents/launch). Between turns nothing is in flight, so AgentOS
5
+ * compacts earlier: a chat over the compact threshold is compacted before
6
+ * the next message, and so is one
6
7
  * whose prompt cache has expired, since that message would re-read the
7
8
  * whole history at the cache-write price (one summary turn, then a small
8
9
  * cache write instead of a huge one). The one check every send path uses:
@@ -0,0 +1,5 @@
1
+ // The range the stall alert may take (lib/chat/stall), shared with the
2
+ // settings form.
3
+ export const DEFAULT_STALL_MINUTES = 8;
4
+ export const MIN_STALL_MINUTES = 2;
5
+ export const MAX_STALL_MINUTES = 120;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stall alerts: a chat turn silent for this long (no token, tool call or
3
+ * result) is flagged "stalled" in the UI and reported once to its workspace's
4
+ * orchestrator (lib/orchestrator/conditions). App-wide, in Settings →
5
+ * Workspaces. 0 is off.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "./stall-limits";
14
+
15
+ export const STALL_KEY = "chat.stall_minutes";
16
+
17
+ export function stallMinutes(): number {
18
+ const row = db
19
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
20
+ .get(STALL_KEY) as { value: string } | undefined;
21
+ const n = row ? Number(row.value) : DEFAULT_STALL_MINUTES;
22
+ return Number.isInteger(n) && n >= 0 ? n : DEFAULT_STALL_MINUTES;
23
+ }
24
+
25
+ export const stallThresholdMs = () => stallMinutes() * 60_000;
26
+
27
+ // 0 (off) or a whole number of minutes in range. Throws a plain message
28
+ // otherwise.
29
+ export function checkStallMinutes(value: unknown): number {
30
+ if (value === 0) return 0;
31
+ if (
32
+ typeof value !== "number" ||
33
+ !Number.isInteger(value) ||
34
+ value < MIN_STALL_MINUTES ||
35
+ value > MAX_STALL_MINUTES
36
+ )
37
+ throw new Error(
38
+ `The stall alert is off or between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ );
40
+ return value;
41
+ }
42
+
43
+ export function setStallMinutes(value: unknown): number {
44
+ const minutes = checkStallMinutes(value);
45
+ db.prepare(
46
+ `INSERT INTO settings (key, value) VALUES (?, ?)
47
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
48
+ ).run(STALL_KEY, String(minutes));
49
+ return minutes;
50
+ }
package/lib/chat/stop.ts CHANGED
@@ -68,3 +68,70 @@ export async function stopChatAtTurnEnd(
68
68
  await sleep(POLL_MS);
69
69
  }
70
70
  }
71
+
72
+ export type TurnStop =
73
+ | { stopped: true; killed: boolean }
74
+ | { stopped: false; reason: string };
75
+
76
+ export interface TurnStopOptions {
77
+ // How long the turn gets to end after the interrupt before its agent is
78
+ // killed.
79
+ graceMs?: number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ }
82
+
83
+ const TURN_POLL_MS = 250;
84
+ // Idle has to hold this long: the CLI answers an interruption with a short
85
+ // turn of its own, which the host cuts short, and that must be over too.
86
+ const SETTLE_MS = 2500;
87
+
88
+ // The one way every caller (the stop button, the orchestrator's stop, aos
89
+ // stop, a restack) ends the turn in progress. The turn is interrupted, even
90
+ // while the model is thinking before any tool call; if it hasn't ended within
91
+ // the grace period the agent is killed. The conversation and worktree are
92
+ // kept, and the next message runs as the next turn.
93
+ export async function stopChatTurn(
94
+ sessionId: string,
95
+ { graceMs = 8000, sleep = defaultSleep }: TurnStopOptions = {}
96
+ ): Promise<TurnStop> {
97
+ let state;
98
+ try {
99
+ await registry.connecting.get(sessionId);
100
+ state = await chatStateNow(sessionId);
101
+ } catch {
102
+ return {
103
+ stopped: false,
104
+ reason: "its chat worker is running but isn't answering",
105
+ };
106
+ }
107
+ if (!state || state === "idle") return { stopped: true, killed: false };
108
+ // Its agent is asked a question: killing it would lose the card.
109
+ if (state === "waiting")
110
+ return {
111
+ stopped: false,
112
+ reason:
113
+ "it's waiting on an approval or question in its chat; answer or dismiss that first",
114
+ };
115
+ const sendsAtStop = registry.live.get(sessionId)?.activity.sends ?? 0;
116
+ registry.live
117
+ .get(sessionId)
118
+ ?.worker.command({ type: "interrupt", hard: true });
119
+ let idleFor = 0;
120
+ for (let waited = 0; waited < graceMs + SETTLE_MS; waited += TURN_POLL_MS) {
121
+ await sleep(TURN_POLL_MS);
122
+ // A message sent meanwhile has started a new turn: not ours to kill.
123
+ if ((registry.live.get(sessionId)?.activity.sends ?? 0) !== sendsAtStop)
124
+ return { stopped: true, killed: false };
125
+ const now = await chatStateNow(sessionId).catch(
126
+ () => "unreachable" as const
127
+ );
128
+ if (now === null || now === "idle") {
129
+ idleFor += TURN_POLL_MS;
130
+ if (idleFor >= SETTLE_MS) return { stopped: true, killed: false };
131
+ } else idleFor = 0;
132
+ if (waited >= graceMs && idleFor === 0) break;
133
+ }
134
+ stopChat(sessionId);
135
+ await waitForExit(sessionId);
136
+ return { stopped: true, killed: true };
137
+ }
@@ -41,6 +41,8 @@ import {
41
41
  } from "../../artifacts/tools";
42
42
  import type { RoleExtras } from "./extras";
43
43
  import { compactThreshold, refreshSettings, refreshedNote } from "../refresh";
44
+ import { compactContext } from "../compact-context";
45
+ import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
44
46
  import { compactionDue, staleLabel, type Staleness } from "../stale";
45
47
  import type { WorkerCommand, WorkerEvent } from "./protocol";
46
48
 
@@ -48,6 +50,10 @@ import type { WorkerCommand, WorkerEvent } from "./protocol";
48
50
  // still counts as part of the stop: the agent starts what it queued the
49
51
  // moment a turn ends.
50
52
  const AFTER_STOP_MS = 2000;
53
+ // After a hard stop (Stop, the orchestrator's stop, aos stop), how long the
54
+ // agent's own wind-down turn is cut short and any sender's message reads as
55
+ // the next turn.
56
+ const HARD_STOP_MS = 30_000;
51
57
 
52
58
  // How long a retiring worker waits, once at rest with nothing queued, for
53
59
  // the agent's guess at the next message before it goes.
@@ -102,6 +108,7 @@ export class ChatHost {
102
108
  // should read next.
103
109
  private stopping: "turn" | "after" | null = null;
104
110
  private stoppedAt = 0;
111
+ private hardStopAt = 0;
105
112
  // On an older build than the server's (a "retire"): it closes at the next
106
113
  // turn boundary, once the agent holds no message it hasn't run and runs
107
114
  // no background work, so a current worker resumes the conversation.
@@ -164,6 +171,7 @@ export class ChatHost {
164
171
  this.atRest = driver.atRest !== false;
165
172
  const cwd = session.working_directory.replace(/^~/, os.homedir());
166
173
  const env = { ...agentEnv(session.id), ...extras.env };
174
+ const orchestrator = session.role === "orchestrator";
167
175
  // Every chat can show visuals, asked for like any tool under its access
168
176
  // setting (they read files and reach the web). Not an orchestrator: it
169
177
  // reads other sessions' text, and a browser would be a way out for it.
@@ -196,6 +204,7 @@ export class ChatHost {
196
204
  sessionRunningBrief(session.id),
197
205
  BUS_BRIEF,
198
206
  visuals && VISUALS_BRIEF,
207
+ !orchestrator && COMPACT_BRIEF,
199
208
  ]
200
209
  .filter(Boolean)
201
210
  .join("\n\n"),
@@ -211,6 +220,10 @@ export class ChatHost {
211
220
  disallowedTools: extras.disallowedTools,
212
221
  permissionMode: extras.permissionMode,
213
222
  toolGuard: extras.toolGuard,
223
+ // Not an orchestrator: it starts a fresh conversation instead.
224
+ compactContext: orchestrator
225
+ ? undefined
226
+ : () => compactContext(session.id),
214
227
  });
215
228
  this.suggestion = session.chat_suggestion ?? null;
216
229
  try {
@@ -319,6 +332,10 @@ export class ChatHost {
319
332
  this.settleCompactedLine(e.context.usedTokens);
320
333
  this.refreshIfFull(e.context.usedTokens);
321
334
  } else if (e.type === "turn_start") {
335
+ // The agent starting a turn of its own right after a hard stop
336
+ // (the CLI winds down with a reply to the interruption) is cut
337
+ // short, so what's sent next isn't queued behind it.
338
+ if (this.hardStopped()) void this.conversation.interrupt();
322
339
  this.turnsStarted++;
323
340
  this.turnStartedAt ??= Date.now();
324
341
  if (this.behind) this.behind--;
@@ -429,6 +446,7 @@ export class ChatHost {
429
446
  ): void {
430
447
  this.sent.add(m.id);
431
448
  this.currentTurn = m.id;
449
+ this.hardStopAt = 0;
432
450
  if (this.busy()) this.behind++;
433
451
  // Sent now, it stops the running turn, which still ends after this.
434
452
  if (!this.busy()) now = false;
@@ -554,9 +572,7 @@ export class ChatHost {
554
572
  // Another agent's message doesn't speak for the reader.
555
573
  if (
556
574
  this.stopping &&
557
- !cmd.from &&
558
- !cmd.peer &&
559
- !cmd.origin &&
575
+ (this.hardStopped() || (!cmd.from && !cmd.peer && !cmd.origin)) &&
560
576
  this.busy() &&
561
577
  !listQueue(this.session.id).length
562
578
  ) {
@@ -616,6 +632,12 @@ export class ChatHost {
616
632
  return;
617
633
  case "interrupt":
618
634
  await this.modeChange.catch(() => {});
635
+ if (cmd.hard) {
636
+ this.hardStopAt = Date.now();
637
+ this.behind = 0;
638
+ this.stopping = "turn";
639
+ return this.conversation.interrupt();
640
+ }
619
641
  if (!this.busy()) return this.conversation.interrupt();
620
642
  // What's queued goes on once the turn stops: it goes with the stop,
621
643
  // so it's what the agent reads next.
@@ -655,6 +677,10 @@ export class ChatHost {
655
677
  }
656
678
  }
657
679
 
680
+ private hardStopped(): boolean {
681
+ return this.hardStopAt > 0 && Date.now() - this.hardStopAt < HARD_STOP_MS;
682
+ }
683
+
658
684
  // A retiring worker goes once it's at a turn boundary: no turn running,
659
685
  // no message handed to the agent still to run, no background work (it
660
686
  // lives in the agent, and would be lost). With nothing queued it first
@@ -725,7 +751,7 @@ export class ChatHost {
725
751
  };
726
752
  this.currentTurn = null;
727
753
  this.stopping = null;
728
- this.conversation.send("/compact");
754
+ this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
729
755
  this.setState("running");
730
756
  console.error(
731
757
  `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
@@ -46,7 +46,8 @@ export type WorkerCommand =
46
46
  // Send the queue if no turn is running; a running one sends it as it ends.
47
47
  | { type: "drain" }
48
48
  | { type: "files"; reqId: string; query: string }
49
- | { type: "interrupt" }
49
+ // hard: a stop that must end the turn (and the agent's wind-down after it).
50
+ | { type: "interrupt"; hard?: boolean }
50
51
  | { type: "set_model"; model: string }
51
52
  | { type: "set_access"; access: ChatAccess }
52
53
  | { type: "set_plan"; plan: boolean }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What an agent needs to keep its place across a compaction. Claude Code's
3
+ * summary decides for itself what mattered, and a long job's task, its scope
4
+ * changes and the files it was editing are exactly what it dropped: the
5
+ * agent re-read them and then rewrote whole files to be sure. So the summary
6
+ * is told what to keep (COMPACT_INSTRUCTIONS), and right after it AgentOS
7
+ * hands back a note built from its own records (workingStateNote), which
8
+ * doesn't depend on what the summary kept.
9
+ *
10
+ * Pure, so the rules are tested without a database (lib/chat/compact-context
11
+ * gathers the inputs).
12
+ */
13
+
14
+ import type { ChatItem } from "./events";
15
+
16
+ export const COMPACT_INSTRUCTIONS = [
17
+ "Keep, verbatim where short: the task or request you are working on and any changes to its scope.",
18
+ "List every file you created or edited, by absolute path, and what you changed in each.",
19
+ "State what is done, what is in progress, and the exact next step.",
20
+ "Keep the decisions made and why, and any commands or checks still to run.",
21
+ "After the summary, do not re-read whole files you were editing: search for the spot and edit it in place, and never rewrite a whole large file to change a part of it.",
22
+ ].join(" ");
23
+
24
+ // In the system prompt, which the summary request reuses and a summary
25
+ // can't drop.
26
+ export const COMPACT_BRIEF = `If your context is compacted, the summary must: ${COMPACT_INSTRUCTIONS}`;
27
+
28
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
29
+ const MAX_FILES = 20;
30
+ const MAX_PROMPT = 8_000;
31
+ const MAX_REQUEST = 1_500;
32
+ const MAX_NOTE = 12_000;
33
+
34
+ type Todo = Extract<ChatItem, { kind: "todos" }>["todos"][number];
35
+
36
+ export interface WorkingState {
37
+ // The task's own prompt, when it is one, and its scope changes (oldest
38
+ // first).
39
+ taskPrompt?: string | null;
40
+ amendments?: string[];
41
+ // Tool calls and the latest todos and user messages, oldest first.
42
+ tools?: Extract<ChatItem, { kind: "tool" }>[];
43
+ todos?: Todo[];
44
+ requests?: string[];
45
+ // `git status --short`, when the directory is a repository.
46
+ gitStatus?: string;
47
+ }
48
+
49
+ const cut = (text: string, max: number) =>
50
+ text.length > max ? `${text.slice(0, max)}\n… (cut)` : text;
51
+
52
+ // The path a file-changing tool call is about, newest first, each once.
53
+ export function editedFiles(
54
+ tools: Extract<ChatItem, { kind: "tool" }>[]
55
+ ): string[] {
56
+ const seen = new Set<string>();
57
+ for (const t of [...tools].reverse()) {
58
+ if (!EDIT_TOOLS.has(t.name) || t.status === "error") continue;
59
+ const input = (t.input ?? {}) as Record<string, unknown>;
60
+ const path = input.file_path ?? input.notebook_path ?? t.diff?.path;
61
+ if (typeof path === "string" && path) seen.add(path);
62
+ if (seen.size >= MAX_FILES) break;
63
+ }
64
+ return [...seen];
65
+ }
66
+
67
+ const MARK: Record<Todo["status"], string> = {
68
+ completed: "done",
69
+ in_progress: "in progress",
70
+ pending: "next",
71
+ };
72
+
73
+ export function workingStateNote(s: WorkingState): string {
74
+ const parts: string[] = [];
75
+ const prompt = s.taskPrompt?.trim();
76
+ if (prompt) parts.push(`## Your task\n${cut(prompt, MAX_PROMPT)}`);
77
+ const amendments = (s.amendments ?? []).filter((a) => a.trim());
78
+ if (amendments.length)
79
+ parts.push(
80
+ `## Scope changes since it started (oldest first; they change the task above)\n${amendments
81
+ .map((a, i) => `${i + 1}. ${cut(a.trim(), MAX_REQUEST)}`)
82
+ .join("\n")}`
83
+ );
84
+ const files = editedFiles(s.tools ?? []);
85
+ if (files.length)
86
+ parts.push(
87
+ `## Files you were editing (newest first)\n${files.map((f) => `- ${f}`).join("\n")}`
88
+ );
89
+ if (s.todos?.length)
90
+ parts.push(
91
+ `## Your todo list\n${s.todos.map((t) => `- [${MARK[t.status]}] ${t.text}`).join("\n")}`
92
+ );
93
+ const requests = (s.requests ?? [])
94
+ .map((r) => r.trim())
95
+ // The task's own prompt is already above.
96
+ .filter((r) => r && !(prompt && r.startsWith(prompt.slice(0, 200))));
97
+ if (requests.length)
98
+ parts.push(
99
+ `## Latest from the reader\n${requests.map((r) => cut(r, MAX_REQUEST)).join("\n\n")}`
100
+ );
101
+ if (s.gitStatus?.trim())
102
+ parts.push(`## git status\n${cut(s.gitStatus.trim(), 1_500)}`);
103
+ if (!parts.length) return "";
104
+ parts.push(
105
+ "This note is from AgentOS, rebuilt from its own records after the context was compacted; the summary has the rest. Carry on from the next step. Don't re-read whole files you were editing (search for the spot), and edit in place rather than rewriting a whole file."
106
+ );
107
+ return cut(
108
+ `(Your context was just compacted. Where you were:)\n\n${parts.join("\n\n")}`,
109
+ MAX_NOTE
110
+ );
111
+ }
@@ -1459,6 +1459,19 @@ const migrations: Migration[] = [
1459
1459
  );
1460
1460
  })(),
1461
1461
  },
1462
+ {
1463
+ id: 64,
1464
+ name: "chat_compactions_phase",
1465
+ up: (db) => {
1466
+ // Whether the agent compacted itself mid-turn or AgentOS (or a typed
1467
+ // /compact) did between turns; null on rows from before.
1468
+ const columns = db
1469
+ .prepare(`PRAGMA table_info(chat_compactions)`)
1470
+ .all() as { name: string }[];
1471
+ if (!columns.some((c) => c.name === "phase"))
1472
+ db.exec(`ALTER TABLE chat_compactions ADD COLUMN phase TEXT`);
1473
+ },
1474
+ },
1462
1475
  ];
1463
1476
 
1464
1477
  // `upTo`: stop after this id (tests that start from an older database).
@@ -6,9 +6,9 @@
6
6
 
7
7
  import { parseOverride, type ModelOverride } from "../models/resolve";
8
8
  import { sendMessage } from "../bus";
9
- import { chatState, interruptChat } from "../chat/runner";
9
+ import { chatState } from "../chat/runner";
10
+ import { stopSession } from "../sessions/stop";
10
11
  import { isRemoteHost } from "../hosts";
11
- import { endTmuxSession } from "../tmux/end";
12
12
  import { spawnSession } from "../agents/spawn";
13
13
  import {
14
14
  DEFAULT_START_VIEW,
@@ -226,11 +226,5 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
226
226
  // Stops the agent and keeps everything else: a chat's turn is interrupted,
227
227
  // a terminal's tmux session ends. Its worktree and branch stay.
228
228
  export async function stop(workspaceId: string, ref: string): Promise<string> {
229
- const s = findWorkspaceSession(workspaceId, ref);
230
- if (s.view === "chat") {
231
- await interruptChat(s.id);
232
- return `Stopped ${s.name}'s turn.`;
233
- }
234
- await endTmuxSession(s.host_id, s.tmux_name);
235
- return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
229
+ return stopSession(findWorkspaceSession(workspaceId, ref));
236
230
  }
@@ -109,6 +109,17 @@ function sessionConditions(
109
109
  true
110
110
  );
111
111
 
112
+ // Keyed on when it last did anything, so it is one event per stall and
113
+ // anything the session does after it makes the next stall a new one.
114
+ if (f.stalled)
115
+ add(
116
+ `stalled:${f.stalled.since}`,
117
+ `${who}: stalled: no output for ${Math.floor(f.stalled.silentMs / 60000)}m mid-turn (last: ${untrusted(f.name, f.stalled.last)}). Read it; stop it if it's stuck.`,
118
+ false,
119
+ false,
120
+ true
121
+ );
122
+
112
123
  // A quick change is the orchestrator's own: nothing to tell it.
113
124
  const shouldPR = (!!f.task || !!f.branch) && !f.quick;
114
125
  const finished = !!f.task && isFinished(f.task.state);
@@ -10,7 +10,7 @@ import { statusDetector } from "../status-detector";
10
10
  import { hostLink } from "../hosts/remote-api";
11
11
  import { peerStatus } from "../hosts/peer-sessions";
12
12
  import { chatState } from "../chat/runner";
13
- import { chatActivityLine } from "../chat/activity";
13
+ import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
14
14
  import { lastUserTask } from "../chat/store";
15
15
  import { needsYou } from "../needs-you";
16
16
  import { programSummary } from "../program-status/store";
@@ -32,6 +32,8 @@ export interface SessionFacts {
32
32
  // Really stopped on an answer: a chat's approval or question card, or a
33
33
  // terminal at its prompt. A chat that merely finished doesn't count.
34
34
  needsInput: boolean;
35
+ // A chat turn silent past the stall threshold (lib/chat/stall).
36
+ stalled?: Stall | null;
35
37
  // Last sign of life, in ms.
36
38
  lastActive: number;
37
39
  // Work that should end in a PR: a task, or a session on its own branch.
@@ -249,6 +251,8 @@ export async function sessionFacts(
249
251
  status,
250
252
  activity,
251
253
  needsInput,
254
+ stalled:
255
+ s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
252
256
  lastActive: Math.max(
253
257
  sqliteMs(s.updated_at),
254
258
  statusDetector.getTimestamp(s.tmux_name) * 1000,
@@ -75,7 +75,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
75
75
  stack_status: "One stack's items, PRs and progress.",
76
76
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
77
77
  drop: "Reject a task: close its PR and remove its worktree. Logged with the reason.",
78
- stop: "Stop a session's agent, keeping its worktree and branch.",
78
+ 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.",
79
79
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
80
80
  add_project:
81
81
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
@@ -0,0 +1,28 @@
1
+ import { endTmuxSession } from "../tmux/end";
2
+ import { hostLink } from "../hosts/remote-api";
3
+ import { stopChatTurn } from "../chat/stop";
4
+ import type { Session } from "../db";
5
+
6
+ // Stops a session's agent and keeps everything else, the same for every
7
+ // caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
8
+ // whatever it is doing, a terminal's tmux session ends. The worktree, branch
9
+ // and conversation stay, and the next message runs as the next turn. Throws
10
+ // when the chat can't be shown to have stopped.
11
+ export async function stopSession(
12
+ s: Pick<Session, "id" | "name" | "view" | "host_id" | "tmux_name">
13
+ ): Promise<string> {
14
+ if (s.view === "chat") {
15
+ if (hostLink(s.host_id))
16
+ throw new Error(
17
+ `Couldn't stop ${s.name}: it runs on another machine; stop it there.`
18
+ );
19
+ const out = await stopChatTurn(s.id);
20
+ if (!out.stopped)
21
+ throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
22
+ return out.killed
23
+ ? `Stopped ${s.name}: its agent didn't answer the interrupt, so it was ended. Its conversation and worktree are kept; your next message runs as its next turn.`
24
+ : `Stopped ${s.name}'s turn. Your next message runs as its next turn.`;
25
+ }
26
+ await endTmuxSession(s.host_id, s.tmux_name);
27
+ return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
28
+ }
@@ -28,6 +28,7 @@ export interface RowStatus {
28
28
  status?: string;
29
29
  need?: SessionNeed | null;
30
30
  unread?: boolean;
31
+ stalled?: boolean;
31
32
  asks?: number;
32
33
  // A program's own message (OSC 7501): untrusted, plain text only.
33
34
  detail?: string | null;
@@ -230,6 +231,7 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
230
231
  a.status === b.status &&
231
232
  (a.need ?? null) === (b.need ?? null) &&
232
233
  !!a.unread === !!b.unread &&
234
+ !!a.stalled === !!b.stalled &&
233
235
  a.asks === b.asks &&
234
236
  (a.detail ?? null) === (b.detail ?? null)
235
237
  );