@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
package/README.md CHANGED
@@ -323,11 +323,11 @@ The ring in the tab bar (in the composer on a phone) is how full the
323
323
  conversation's context is, measured against the room before auto-compact:
324
324
  amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
325
325
 
326
- Chats and tasks compact at 150k tokens of context by default (Settings →
327
- Workspaces → Compact; off sets it to never), not near the model's window:
328
- AgentOS launches Claude Code with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to
329
- that size (its floor is 100k), so the agent compacts itself mid-turn. Between
330
- turns AgentOS catches what it didn't: a chat over the threshold is compacted
326
+ Two thresholds (Settings → Workspaces → Compact; 0 turns one off). Mid-turn,
327
+ the agent compacts itself at 300k by default: AgentOS launches Claude Code
328
+ with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to that size (its floor is 100k),
329
+ high enough that a long job isn't cut in half. Between
330
+ turns AgentOS catches what it didn't: a chat over its 150k threshold is compacted
331
331
  before the next message, and so is one idle past its prompt cache (5 minutes
332
332
  or an hour, read from the agent's own usage), whose next message would
333
333
  re-read the whole conversation at the cache-write price. A chip above the
@@ -339,6 +339,13 @@ shows one quiet line with the context before and after. An orchestrator
339
339
  isn't compacted (a summary would lose the fence around what it read from
340
340
  tasks and CI): it starts a fresh conversation from its brief and the current
341
341
  state at its fresh-start threshold, or earlier once idle past its cache.
342
+ What survives a compaction: the summary is asked to keep the task, scope
343
+ changes, every edited file by path, what's done, in progress and next, and
344
+ the decisions. Right after it, AgentOS hands back a note built from its own
345
+ records (a task's brief and scope changes, the files it edited, its todos,
346
+ the latest message and `git status`), which doesn't depend on what the
347
+ summary kept, and tells the agent not to re-read or rewrite whole files.
348
+ Terminal agents don't get the note yet.
342
349
  On a desktop browser the screen button attaches a screenshot of a screen or
343
350
  window you pick; on a phone, attach a screenshot as a photo.
344
351
 
@@ -349,13 +356,31 @@ turn ends. **Gate reviews** below it shows what the orchestrator's
349
356
  independent reviewer and scope check cost, by PR: they run as `claude -p`
350
357
  and leave no transcript, so each run's tokens, model and duration are read
351
358
  from its JSON result into `check_runs`, and the review's verdict says them
352
- too. Every compaction is kept in `chat_compactions` (who ran it, and the
353
- context before and after), so cost per task can be compared by date.
359
+ too. Every compaction is kept in `chat_compactions` (who ran it, whether it
360
+ fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
354
361
 
355
362
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
356
363
  menu, dialog or the command list is open (those close first). On a phone the
357
364
  Stop button does the same. A stopped turn keeps what it wrote.
358
365
 
366
+ Stop ends the turn even while the model is still thinking, before any tool
367
+ call: the turn is interrupted, and if it hasn't ended within a few seconds the
368
+ agent process is ended and restarted for the next message (not if the chat is
369
+ waiting on an approval or question, or if a new message has started a new
370
+ turn meanwhile). The conversation and
371
+ worktree are kept, and whatever is sent next (yours, another agent's, the
372
+ orchestrator's) runs as the next turn, never queued behind the stopped one.
373
+ The UI, the orchestrator's `stop` tool and `aos stop` all take this one path
374
+ and report "stopped" or a real error.
375
+
376
+ A turn that has produced nothing (no token, tool call or result) for 8 minutes
377
+ shows as **Stalled** in the session list, and its workspace's orchestrator gets
378
+ one event naming the session, how long it has been silent and its last action.
379
+ It fires once per stall and starts over when the session does something. A
380
+ foreground tool call gets four times as long, and a session waiting on a
381
+ background command it started isn't flagged. Set the time (2 to 120 minutes, 0 for off)
382
+ in Settings → Workspaces → Stall alerts.
383
+
359
384
  The access picker under the composer sets what the agent may do on its own:
360
385
  **Ask first** (an approval card in the chat for anything not already allowed),
361
386
  **Accept edits** (edits files, asks before other commands) or **Full access**
@@ -845,6 +870,7 @@ aos schedule run <name> # run a schedule now
845
870
  aos schedule add --session <s> --every 30m "prompt" # check in on a session
846
871
  aos notify "text" # push a message to your phone
847
872
  aos heavy -- npm test # run it now; says first what else heavy is running
873
+ aos stop <session> # stop its turn now; the next message runs as a new turn
848
874
  aos done <session> # finished: merge through the gates, archive
849
875
  aos done --all-idle # the same for every idle session around you
850
876
  aos docs [query] # LumifyHub pages, when the workspace is linked
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { resolveSession } from "@/lib/bus";
3
+ import { getDoneTarget } from "@/lib/done";
4
+ import { inScope, scopeOf } from "@/lib/done/bulk";
5
+ import { stopSession } from "@/lib/sessions/stop";
6
+
7
+ // `aos stop <session>`, from inside a session: ends the turn in progress
8
+ // (or the terminal agent) and keeps the conversation and worktree. Reaches
9
+ // only the caller's workspace (or its project when it has none).
10
+ export async function POST(request: NextRequest) {
11
+ try {
12
+ const { from = null, session } = await request.json();
13
+ if (typeof from !== "string" || !from)
14
+ throw new Error("aos stop runs from inside an AgentOS session");
15
+ const caller = getDoneTarget(from);
16
+ const target = resolveSession(String(session ?? ""));
17
+ if (!inScope(scopeOf(caller), target))
18
+ throw new Error(
19
+ `${target.name} isn't in ${caller.name}'s workspace or project; aos stop reaches only those`
20
+ );
21
+ return NextResponse.json({ summary: await stopSession(target) });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 409 });
25
+ }
26
+ }
@@ -3,7 +3,8 @@ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
3
3
 
4
4
  // When a chat's conversation starts over (lib/chat/refresh): context
5
5
  // tokens for orchestrators and for tasks, and at which chats and tasks
6
- // compact (lib/chat/stale); 0 for never.
6
+ // compact: between turns (lib/chat/stale) and mid-turn, by the agent itself;
7
+ // 0 for never.
7
8
  export async function GET() {
8
9
  return NextResponse.json(refreshSettings());
9
10
  }
@@ -16,6 +17,7 @@ export async function PUT(request: NextRequest) {
16
17
  taskTokens: body.taskTokens,
17
18
  // Its name before it also set the agents' own auto-compact.
18
19
  compactTokens: body.compactTokens ?? body.staleCompactTokens,
20
+ autoCompactTokens: body.autoCompactTokens,
19
21
  });
20
22
  return NextResponse.json(refreshSettings());
21
23
  } catch (error) {
@@ -0,0 +1,18 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setStallMinutes, stallMinutes } from "@/lib/chat/stall";
3
+
4
+ // How long a chat turn may be silent before it's flagged stalled and its
5
+ // workspace's orchestrator is told (lib/chat/stall); 0 for never.
6
+ export async function GET() {
7
+ return NextResponse.json({ minutes: stallMinutes() });
8
+ }
9
+
10
+ export async function PUT(request: NextRequest) {
11
+ try {
12
+ const body = await request.json();
13
+ return NextResponse.json({ minutes: setStallMinutes(body.minutes) });
14
+ } catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ return NextResponse.json({ error: message }, { status: 400 });
17
+ }
18
+ }
@@ -41,6 +41,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
41
41
  </span>
42
42
  );
43
43
  }
44
+ if (row.status?.stalled)
45
+ return (
46
+ <span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
47
+ Stalled
48
+ </span>
49
+ );
44
50
  if (row.unread) return <UnreadDot />;
45
51
  return null;
46
52
  }
@@ -9,6 +9,7 @@ export interface SessionStatus {
9
9
  asks?: number;
10
10
  need?: SessionNeed | null;
11
11
  unread?: boolean;
12
+ stalled?: boolean;
12
13
  }
13
14
 
14
15
  export interface SessionListProps {
@@ -19,7 +19,7 @@ export function FreshStartForm() {
19
19
  return <p className="text-destructive text-sm">{error.message}</p>;
20
20
  return (
21
21
  <Form
22
- key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
22
+ key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}`}
23
23
  saved={data}
24
24
  />
25
25
  );
@@ -30,12 +30,15 @@ function Form({ saved }: { saved: RefreshSettings }) {
30
30
  const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
31
  const task = useThreshold(saved.taskTokens, 200);
32
32
  const compact = useThreshold(saved.compactTokens, 150);
33
- const error = orchestrator.error ?? task.error ?? compact.error;
33
+ const midTurn = useThreshold(saved.autoCompactTokens, 300);
34
+ const error =
35
+ orchestrator.error ?? task.error ?? compact.error ?? midTurn.error;
34
36
  const dirty =
35
37
  !error &&
36
38
  (orchestrator.tokens !== saved.orchestratorTokens ||
37
39
  task.tokens !== saved.taskTokens ||
38
- compact.tokens !== saved.compactTokens);
40
+ compact.tokens !== saved.compactTokens ||
41
+ midTurn.tokens !== saved.autoCompactTokens);
39
42
 
40
43
  return (
41
44
  <form
@@ -47,6 +50,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
47
50
  orchestratorTokens: orchestrator.tokens,
48
51
  taskTokens: task.tokens,
49
52
  compactTokens: compact.tokens,
53
+ autoCompactTokens: midTurn.tokens,
50
54
  },
51
55
  {
52
56
  onSuccess: () => toast.success("Context settings saved"),
@@ -67,15 +71,23 @@ function Form({ saved }: { saved: RefreshSettings }) {
67
71
  <ThresholdRow label="Tasks" field={task} />
68
72
  <h3 className="pt-2 text-sm font-medium">Compact</h3>
69
73
  <p className="text-muted-foreground text-sm">
70
- Chats and tasks compact when their context passes this size: Claude Code
71
- does it itself, and between turns AgentOS compacts a chat over it, or
72
- idle past its prompt cache, before the next message. The composer offers
73
- to first. Orchestrators start fresh instead.
74
+ Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
75
+ own context when a job runs long: set this high, since it happens while
76
+ the agent is holding its files. Between turns, nothing is in flight, so
77
+ AgentOS compacts a chat over the smaller size, or idle past its prompt
78
+ cache, before the next message; the composer offers to first. Either way
79
+ AgentOS hands the agent its task, scope changes, files and todos back.
80
+ Orchestrators start fresh instead.
74
81
  </p>
75
82
  <ThresholdRow
76
- label="Chats and tasks"
83
+ label="Mid-turn"
84
+ field={midTurn}
85
+ switchLabel="Let the agent compact itself mid-turn"
86
+ />
87
+ <ThresholdRow
88
+ label="Between turns"
77
89
  field={compact}
78
- switchLabel="Compact chats and tasks at this size"
90
+ switchLabel="Compact chats and tasks between turns"
79
91
  />
80
92
  {error && <p className="text-destructive text-xs">{error}</p>}
81
93
  <div className="flex justify-end">
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { useStallQuery, useUpdateStall } from "@/data/stall";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "@/lib/chat/stall-limits";
14
+
15
+ // How long a turn may produce nothing before it's flagged stalled and the
16
+ // workspace's orchestrator is told: app-wide.
17
+ export function StallForm() {
18
+ const { data, isPending, isError, error } = useStallQuery();
19
+ if (isPending)
20
+ return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
21
+ if (isError)
22
+ return <p className="text-destructive text-sm">{error.message}</p>;
23
+ return <Form key={data.minutes} saved={data.minutes} />;
24
+ }
25
+
26
+ function Form({ saved }: { saved: number }) {
27
+ const update = useUpdateStall();
28
+ const [on, setOn] = useState(saved > 0);
29
+ const [text, setText] = useState(
30
+ String(saved > 0 ? saved : DEFAULT_STALL_MINUTES)
31
+ );
32
+ const n = Number(text);
33
+ const valid =
34
+ Number.isInteger(n) && n >= MIN_STALL_MINUTES && n <= MAX_STALL_MINUTES;
35
+ const minutes = on ? n : 0;
36
+ const error =
37
+ on && !valid
38
+ ? `Between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ : null;
40
+ const dirty = !error && minutes !== saved;
41
+
42
+ return (
43
+ <form
44
+ onSubmit={(e) => {
45
+ e.preventDefault();
46
+ if (error) return;
47
+ update.mutate(minutes, {
48
+ onSuccess: () => toast.success("Stall alert saved"),
49
+ onError: (err) => toast.error(err.message),
50
+ });
51
+ }}
52
+ className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
53
+ >
54
+ <h3 className="text-sm font-medium">Stalled turns</h3>
55
+ <p className="text-muted-foreground text-sm">
56
+ A turn that produces nothing for this long (no text, tool call or
57
+ result) shows as stalled, and the workspace&apos;s orchestrator is told
58
+ once. A long command that is still printing, or a background command the
59
+ agent is waiting on, isn&apos;t a stall.
60
+ </p>
61
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
62
+ Flag stalled turns
63
+ <Switch
64
+ checked={on}
65
+ onCheckedChange={setOn}
66
+ aria-label="Flag stalled turns"
67
+ />
68
+ </label>
69
+ {on && (
70
+ <label className="flex items-center justify-between gap-3 text-sm">
71
+ <span className="text-muted-foreground">After</span>
72
+ <span className="flex items-center gap-2">
73
+ <Input
74
+ type="number"
75
+ inputMode="numeric"
76
+ min={MIN_STALL_MINUTES}
77
+ max={MAX_STALL_MINUTES}
78
+ value={text}
79
+ onChange={(e) => setText(e.target.value)}
80
+ aria-label="Stall threshold, minutes"
81
+ aria-invalid={!!error}
82
+ className="h-11 w-24 text-base sm:h-9 md:text-sm"
83
+ />
84
+ <span className="text-muted-foreground">minutes</span>
85
+ </span>
86
+ </label>
87
+ )}
88
+ {error && <p className="text-destructive text-xs">{error}</p>}
89
+ <div className="flex justify-end">
90
+ <Button
91
+ type="submit"
92
+ size="sm"
93
+ className="h-11 sm:h-8"
94
+ disabled={!!error || !dirty || update.isPending}
95
+ >
96
+ Save
97
+ </Button>
98
+ </div>
99
+ </form>
100
+ );
101
+ }
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
+ import { StallForm } from "./StallForm";
8
9
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
9
10
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
10
11
 
@@ -53,6 +54,7 @@ export function WorkspaceSettings() {
53
54
  </section>
54
55
  ))}
55
56
  <FreshStartForm />
57
+ <StallForm />
56
58
  <AwayWindowForm />
57
59
  </div>
58
60
  );
@@ -0,0 +1,33 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+
3
+ // How long a turn may be silent before it's stalled (lib/chat/stall).
4
+ export const stallKeys = { all: ["stall"] as const };
5
+
6
+ async function json<T>(res: Response): Promise<T> {
7
+ const data = await res.json().catch(() => ({}));
8
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
9
+ return data;
10
+ }
11
+
12
+ export function useStallQuery() {
13
+ return useQuery({
14
+ queryKey: stallKeys.all,
15
+ queryFn: async () => json<{ minutes: number }>(await fetch("/api/stall")),
16
+ staleTime: 60000,
17
+ });
18
+ }
19
+
20
+ export function useUpdateStall() {
21
+ const queryClient = useQueryClient();
22
+ return useMutation({
23
+ mutationFn: async (minutes: number) =>
24
+ json<{ minutes: number }>(
25
+ await fetch("/api/stall", {
26
+ method: "PUT",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ minutes }),
29
+ })
30
+ ),
31
+ onSuccess: (data) => queryClient.setQueryData(stallKeys.all, data),
32
+ });
33
+ }
@@ -1,23 +1,48 @@
1
- import type { ChatState } from "./events";
1
+ import type { ChatItem, ChatState } from "./events";
2
2
  import { formatElapsed } from "./elapsed";
3
3
  import { registry, type Live } from "./registry";
4
+ import { stallThresholdMs } from "./stall";
4
5
  import { listItems } from "./store";
5
6
  import type { WorkerEvent } from "./worker/protocol";
6
7
 
8
+ const lastWhat = (i: ChatItem): string =>
9
+ i.kind === "tool"
10
+ ? i.status === "running"
11
+ ? i.title
12
+ : `finished ${i.title}`
13
+ : i.kind === "assistant"
14
+ ? "writing a reply"
15
+ : i.kind === "reasoning"
16
+ ? "thinking"
17
+ : i.kind === "user"
18
+ ? "your message"
19
+ : i.kind;
20
+
7
21
  // Keeps a conversation's "what it's doing now" current from its events.
8
22
  export function track(live: Live, e: WorkerEvent): void {
9
23
  const a = live.activity;
10
24
  if (e.type === "state") {
11
- if (e.state === "running" && !a.turnStartedAt) a.turnStartedAt = Date.now();
25
+ if (e.state === "running" && !a.turnStartedAt) {
26
+ a.turnStartedAt = Date.now();
27
+ a.lastAt = a.turnStartedAt;
28
+ a.lastWhat = "starting the turn";
29
+ }
12
30
  if (e.state === "idle") {
13
31
  a.turnStartedAt = undefined;
32
+ a.lastAt = a.lastWhat = undefined;
14
33
  a.tools.clear();
15
34
  }
16
35
  } else if (e.type === "item" && e.item.kind === "turn_end") {
17
36
  // A queued message starts the next turn with no idle in between.
18
37
  a.turnStartedAt = undefined;
38
+ a.lastAt = a.lastWhat = undefined;
19
39
  a.tools.clear();
20
- } else if (e.type === "item" && e.item.kind === "tool") {
40
+ } else if (e.type === "item" || e.type === "delta") {
41
+ a.lastAt = Date.now();
42
+ a.lastWhat = e.type === "delta" ? "writing" : lastWhat(e.item);
43
+ }
44
+ if (e.type === "item" && e.item.kind === "user") a.sends = (a.sends ?? 0) + 1;
45
+ if (e.type === "item" && e.item.kind === "tool") {
21
46
  if (e.item.status === "running")
22
47
  a.tools.set(e.item.id, { label: e.item.title, since: e.item.createdAt });
23
48
  else a.tools.delete(e.item.id);
@@ -35,6 +60,11 @@ export function restoreActivity(sessionId: string, state: ChatState) {
35
60
  activity.turnStartedAt = items.findLast(
36
61
  (i) => i.kind === "user"
37
62
  )?.createdAt;
63
+ if (state !== "idle") {
64
+ const last = items.at(-1);
65
+ activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
66
+ activity.lastWhat = last ? lastWhat(last) : undefined;
67
+ }
38
68
  for (const i of items) {
39
69
  if (i.kind === "tool" && i.status === "running" && state !== "idle")
40
70
  activity.tools.set(i.id, { label: i.title, since: i.createdAt });
@@ -44,11 +74,50 @@ export function restoreActivity(sessionId: string, state: ChatState) {
44
74
  return activity;
45
75
  }
46
76
 
77
+ export interface Stall {
78
+ silentMs: number;
79
+ // What it last did.
80
+ last: string;
81
+ // When that was: one stall per value, so a condition keyed on it fires
82
+ // once and a new one starts only after activity resumes.
83
+ since: number;
84
+ }
85
+
86
+ // A turn that has produced nothing (no token, item or result) for the
87
+ // threshold. Never one waiting on an answer, nor one with a background task
88
+ // running: a command it started can legitimately be quiet.
89
+ const TOOL_STALL_FACTOR = 4;
90
+
91
+ export function stallOf(
92
+ live: Live,
93
+ now: number,
94
+ thresholdMs: number
95
+ ): Stall | null {
96
+ const a = live.activity;
97
+ if (live.state !== "running" || thresholdMs <= 0 || a.tasks.size > 0)
98
+ return null;
99
+ // A foreground tool call (a long test run) says nothing while it works:
100
+ // it gets a much longer allowance before it counts as stuck.
101
+ if (a.tools.size > 0) thresholdMs *= TOOL_STALL_FACTOR;
102
+ const since = a.lastAt ?? a.turnStartedAt;
103
+ if (!since || now - since < thresholdMs) return null;
104
+ return { silentMs: now - since, last: a.lastWhat ?? "unknown", since };
105
+ }
106
+
107
+ // Whether a chat on this machine is stalled now, by the app-wide threshold.
108
+ export function chatStall(sessionId: string): Stall | null {
109
+ const live = registry.live.get(sessionId);
110
+ return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
111
+ }
112
+
47
113
  // One line on what a chat session is doing, for the session list and
48
114
  // notifications: the step running now and since when.
49
- export function chatActivity(
50
- sessionId: string
51
- ): { label: string; since?: number; background: number } | null {
115
+ export function chatActivity(sessionId: string): {
116
+ label: string;
117
+ since?: number;
118
+ background: number;
119
+ stalled?: Stall;
120
+ } | null {
52
121
  const live = registry.live.get(sessionId);
53
122
  if (!live) return null;
54
123
  const a = live.activity;
@@ -57,6 +126,9 @@ export function chatActivity(
57
126
  return { label: "Needs your answer", background };
58
127
  if (live.state !== "running")
59
128
  return background ? { label: "Idle", background } : null;
129
+ const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
130
+ if (stalled)
131
+ return { label: "Stalled", since: stalled.since, background, stalled };
60
132
  const tool = [...a.tools.values()].at(-1);
61
133
  return tool
62
134
  ? { label: tool.label, since: tool.since, background }
@@ -71,6 +143,8 @@ export function chatActivityLine(sessionId: string): string | null {
71
143
  ? `${a.background} running in background`
72
144
  : "";
73
145
  if (a.label === "Idle") return background || null;
146
+ if (a.stalled)
147
+ return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
74
148
  const parts = [a.label];
75
149
  if (a.since) parts.push(formatElapsed(Date.now() - a.since));
76
150
  if (background) parts.push(background);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The note an agent gets back right after a compaction, gathered from
3
+ * AgentOS's records (lib/chat/working-state has the rules). From the
4
+ * database each time rather than tracked in memory, so it is right after a
5
+ * deploy restarted the worker too.
6
+ */
7
+
8
+ import os from "node:os";
9
+ import { statusPorcelain } from "../git-exec";
10
+ import { db, type Session } from "../db";
11
+ import { amendmentsOf } from "../orchestrator/amendments";
12
+ import { lastItems } from "./store";
13
+ import { workingStateNote } from "./working-state";
14
+
15
+ async function gitStatus(cwd: string): Promise<string> {
16
+ try {
17
+ const out = await statusPorcelain(cwd, {
18
+ timeout: 3_000,
19
+ maxBuffer: 64 * 1024,
20
+ });
21
+ return out.split("\n").slice(0, 40).join("\n");
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ // Empty when the session is gone or there is nothing to say.
28
+ export async function compactContext(sessionId: string): Promise<string> {
29
+ const session = db
30
+ .prepare(
31
+ `SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
32
+ )
33
+ .get(sessionId) as
34
+ | Pick<Session, "working_directory" | "task_prompt" | "role">
35
+ | undefined;
36
+ // An orchestrator starts fresh instead of compacting.
37
+ if (!session || session.role === "orchestrator") return "";
38
+ const tools = lastItems(sessionId, 400, "tool").filter(
39
+ (i) => i.kind === "tool"
40
+ );
41
+ const todos = lastItems(sessionId, 1, "todos").find(
42
+ (i) => i.kind === "todos"
43
+ );
44
+ const user = lastItems(sessionId, 1, "user").find((i) => i.kind === "user");
45
+ return workingStateNote({
46
+ taskPrompt: session.task_prompt,
47
+ amendments: amendmentsOf(sessionId).map((a) => a.text),
48
+ tools,
49
+ todos: todos?.todos,
50
+ requests: user ? [user.text] : [],
51
+ gitStatus: await gitStatus(
52
+ session.working_directory.replace(/^~/, os.homedir())
53
+ ),
54
+ });
55
+ }
@@ -41,6 +41,10 @@ export interface ChatStartOptions {
41
41
  // Checked before every tool call, whatever the permissions say: why it's
42
42
  // refused, or null to let it go on to them.
43
43
  toolGuard?: ToolGuard;
44
+ // The note handed back to the agent right after its context is compacted
45
+ // (lib/chat/compact-context), empty for none. Only Claude Code has a hook
46
+ // for it; other drivers ignore it.
47
+ compactContext?: () => Promise<string>;
44
48
  }
45
49
 
46
50
  export type ToolGuard = (
@@ -275,3 +275,35 @@ export function preToolUseHooks(
275
275
  ...(guard ? [{ hooks: [guardHook(guard)] }] : []),
276
276
  ];
277
277
  }
278
+
279
+ // After a compaction Claude Code starts the session again with source
280
+ // "compact", and what a SessionStart hook returns as additionalContext is
281
+ // added to the context: the one way to hand the agent something back that
282
+ // doesn't depend on what its summary kept. A failure here never blocks it.
283
+ export function compactedHooks(
284
+ context: () => Promise<string>
285
+ ): HookCallbackMatcher[] {
286
+ return [
287
+ {
288
+ matcher: "compact",
289
+ hooks: [
290
+ async () => {
291
+ try {
292
+ const additionalContext = await context();
293
+ return additionalContext
294
+ ? {
295
+ hookSpecificOutput: {
296
+ hookEventName: "SessionStart",
297
+ additionalContext,
298
+ },
299
+ }
300
+ : {};
301
+ } catch (e) {
302
+ console.error("building the compaction note:", e);
303
+ return {};
304
+ }
305
+ },
306
+ ],
307
+ },
308
+ ];
309
+ }
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
10
10
  import { InputQueue } from "../queue";
11
11
  import {
12
12
  Approvals,
13
+ compactedHooks,
13
14
  isPlanFile,
14
15
  preToolUseHooks,
15
16
  sdkMode,
@@ -193,6 +194,9 @@ export const claudeDriver: ChatDriver = {
193
194
  disallowedTools: options.disallowedTools,
194
195
  hooks: {
195
196
  PreToolUse: preToolUseHooks(approvals, options.toolGuard),
197
+ ...(options.compactContext && {
198
+ SessionStart: compactedHooks(options.compactContext),
199
+ }),
196
200
  },
197
201
  settings: { attribution: NO_ATTRIBUTION },
198
202
  enableFileCheckpointing: true,
@@ -354,7 +354,7 @@ export type ChatClientMessage =
354
354
  // `during`: the user message whose turn was running when it was tapped.
355
355
  | { type: "queue_send_now"; id: string; during?: string }
356
356
  | { type: "files"; reqId: string; query: string }
357
- | { type: "interrupt" }
357
+ | { type: "interrupt"; hard?: boolean }
358
358
  | { type: "set_model"; model: string }
359
359
  | { type: "set_access"; access: ChatAccess }
360
360
  | { type: "set_plan"; plan: boolean }