@saadnvd1/agent-os 0.3.501 → 0.3.502

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.
package/README.md CHANGED
@@ -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
+ While a chat compacts (any trigger, Claude, Pi or Codex) it is shown as
343
+ compacting, not just working: a pulsing blue dot and a "Compacting" chip in
344
+ the sidebar, and "Compacting context… 182k tokens" above the composer. The
345
+ status API (`/api/sessions/status`) keeps `status: "running"` and adds
346
+ `compacting: { since, before? }`; the orchestrator's `sessions` tool says
347
+ "compacting context". It clears when the compaction ends, errors, is
348
+ stopped, or the worker goes away.
342
349
  What survives a compaction: the summary is asked to keep the task, scope
343
350
  changes, every edited file by path, what's done, in progress and next, and
344
351
  the decisions. Right after it, AgentOS hands back a note built from its own
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { currentActivity } from "./Activity";
3
+
4
+ describe("currentActivity while compacting", () => {
5
+ it("says so, with the size going in and when it began", () => {
6
+ expect(
7
+ currentActivity([], "running", { since: 5, before: 182_000 })
8
+ ).toEqual({
9
+ label: "Compacting context… 182k tokens",
10
+ since: 5,
11
+ compacting: true,
12
+ });
13
+ expect(currentActivity([], "running", { since: 5 })?.label).toBe(
14
+ "Compacting context…"
15
+ );
16
+ });
17
+
18
+ it("is plain work without it, and nothing once the turn is over", () => {
19
+ expect(currentActivity([], "running")?.compacting).toBeUndefined();
20
+ expect(currentActivity([], "idle", { since: 5 })).toBeNull();
21
+ });
22
+ });
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { Loader2, MessageCircleQuestion, Square } from "lucide-react";
4
- import type { ChatItem, ChatState } from "@/lib/chat/events";
3
+ import { Loader2, MessageCircleQuestion, Shrink, Square } from "lucide-react";
4
+ import type { ChatItem, ChatState, Compacting } from "@/lib/chat/events";
5
+ import { formatTokens } from "@/lib/chat/context";
5
6
  import { formatElapsed } from "@/lib/chat/elapsed";
6
7
  import { useNow } from "./useNow";
7
8
 
@@ -11,10 +12,17 @@ type Tool = Extract<ChatItem, { kind: "tool" }>;
11
12
  // running step, else whether it's writing or thinking.
12
13
  export function currentActivity(
13
14
  items: ChatItem[],
14
- state: ChatState
15
- ): { label: string; since?: number } | null {
15
+ state: ChatState,
16
+ compacting: Compacting | null = null
17
+ ): { label: string; since?: number; compacting?: boolean } | null {
16
18
  if (state === "waiting") return { label: "Waiting for your answer" };
17
19
  if (state !== "running") return null;
20
+ if (compacting)
21
+ return {
22
+ label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
23
+ since: compacting.since,
24
+ compacting: true,
25
+ };
18
26
  const lastUser = items.findLast((i) => i.kind === "user");
19
27
  const tool = items.findLast(
20
28
  (i): i is Tool => i.kind === "tool" && i.status === "running"
@@ -39,13 +47,15 @@ export function currentActivity(
39
47
  export function ActivityLine({
40
48
  items,
41
49
  state,
50
+ compacting = null,
42
51
  onStop,
43
52
  }: {
44
53
  items: ChatItem[];
45
54
  state: ChatState;
55
+ compacting?: Compacting | null;
46
56
  onStop: () => void;
47
57
  }) {
48
- const activity = currentActivity(items, state);
58
+ const activity = currentActivity(items, state, compacting);
49
59
  const now = useNow(!!activity?.since);
50
60
  if (!activity) return null;
51
61
  const waiting = state === "waiting";
@@ -53,6 +63,8 @@ export function ActivityLine({
53
63
  <div className="text-muted-foreground flex min-h-9 items-center gap-2 px-2 text-xs">
54
64
  {waiting ? (
55
65
  <MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
66
+ ) : activity.compacting ? (
67
+ <Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
56
68
  ) : (
57
69
  <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
58
70
  )}
@@ -263,6 +263,7 @@ export function ChatPanel({
263
263
  suggestion,
264
264
  requestFiles,
265
265
  search,
266
+ compacting,
266
267
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
267
268
  const startFresh = useFreshStart();
268
269
  const { isMobile } = useViewport();
@@ -542,7 +543,12 @@ export function ChatPanel({
542
543
  )}
543
544
  </div>
544
545
  <div className="mx-auto w-full max-w-3xl px-3 pb-3">
545
- <ActivityLine items={items} state={state} onStop={interrupt} />
546
+ <ActivityLine
547
+ items={items}
548
+ state={state}
549
+ compacting={compacting}
550
+ onStop={interrupt}
551
+ />
546
552
  <BackgroundTasks
547
553
  items={tasks}
548
554
  outputs={taskOutputs}
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from "react";
4
4
  import { NEED_LABEL, type SidebarRow } from "@/lib/sidebar/shelves";
5
5
  import { cn } from "@/lib/utils";
6
6
 
7
- // Amber when it needs you, red when it failed, green while it works.
7
+ // Amber when it needs you, red when it failed, green while it works, and a
8
+ // pulsing blue while it summarises its context.
8
9
  export function RowDot({ row }: { row: SidebarRow }) {
9
10
  return (
10
11
  <span
@@ -15,9 +16,11 @@ export function RowDot({ row }: { row: SidebarRow }) {
15
16
  ? "bg-red-500"
16
17
  : row.need
17
18
  ? "bg-amber-500"
18
- : row.working
19
- ? "bg-green-500 ring-[3px] ring-green-500/25"
20
- : "bg-muted-foreground/35"
19
+ : row.compacting
20
+ ? "animate-pulse bg-blue-500 ring-[3px] ring-blue-500/25"
21
+ : row.working
22
+ ? "bg-green-500 ring-[3px] ring-green-500/25"
23
+ : "bg-muted-foreground/35"
21
24
  )}
22
25
  />
23
26
  );
@@ -41,6 +44,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
41
44
  </span>
42
45
  );
43
46
  }
47
+ if (row.compacting)
48
+ return (
49
+ <span className="rounded-md bg-blue-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-blue-700 dark:text-blue-400">
50
+ Compacting
51
+ </span>
52
+ );
44
53
  if (row.status?.stalled)
45
54
  return (
46
55
  <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">
@@ -10,6 +10,7 @@ export interface SessionStatus {
10
10
  need?: SessionNeed | null;
11
11
  unread?: boolean;
12
12
  stalled?: boolean;
13
+ compacting?: { since: number; before?: number };
13
14
  }
14
15
 
15
16
  export interface SessionListProps {
@@ -13,6 +13,8 @@ export interface SessionStatus {
13
13
  asks?: number;
14
14
  need?: SessionNeed | null;
15
15
  unread?: boolean;
16
+ // A chat summarising its context; status stays "running".
17
+ compacting?: { since: number; before?: number };
16
18
  claudeSessionId?: string | null;
17
19
  // From a program's OSC 7501 report: untrusted, rendered as plain text.
18
20
  detail?: string | null;
@@ -13,6 +13,7 @@ import type {
13
13
  ChatModel,
14
14
  ChatServerMessage,
15
15
  ChatState,
16
+ Compacting,
16
17
  FileSuggestion,
17
18
  QueuedMessage,
18
19
  ToolBody,
@@ -138,6 +139,7 @@ export function useChat(
138
139
  return () => void visibleBodies.current.delete(id);
139
140
  }, []);
140
141
  const [state, setState] = useState<ChatState>("idle");
142
+ const [compacting, setCompacting] = useState<Compacting | null>(null);
141
143
  const [connected, setConnected] = useState(false);
142
144
  const [commands, setCommands] = useState<ChatCommand[]>([]);
143
145
  const [models, setModels] = useState<ChatModel[]>([]);
@@ -218,6 +220,7 @@ export function useChat(
218
220
  setToolBodies((prev) => keptBodies(prev, before, m.items));
219
221
  setLoadingOlder(false);
220
222
  setState(m.state);
223
+ setCompacting(m.compacting ?? null);
221
224
  setQueue(m.queue ?? []);
222
225
  setSuggestion(m.suggestion ?? null);
223
226
  } else if (m.type === "queue") {
@@ -230,8 +233,11 @@ export function useChat(
230
233
  } else if (m.type === "search") {
231
234
  searchRequests.current.get(m.reqId)?.(m);
232
235
  searchRequests.current.delete(m.reqId);
236
+ } else if (m.type === "compacting") {
237
+ setCompacting(m.compacting);
233
238
  } else if (m.type === "state") {
234
239
  setState(m.state);
240
+ if (m.state === "idle" || m.state === "error") setCompacting(null);
235
241
  } else if (m.type === "capabilities") {
236
242
  setRefreshingCommands(false);
237
243
  setCommands(m.commands);
@@ -469,6 +475,7 @@ export function useChat(
469
475
  requestToolBody,
470
476
  pinToolBody,
471
477
  state,
478
+ compacting,
472
479
  connected,
473
480
  commands,
474
481
  refreshCommands,
@@ -1,4 +1,5 @@
1
1
  import type { ChatItem, ChatState } from "./events";
2
+ import { formatTokens } from "./context";
2
3
  import { formatElapsed } from "./elapsed";
3
4
  import { registry, type Live } from "./registry";
4
5
  import { stallThresholdMs } from "./stall";
@@ -94,7 +95,13 @@ export function stallOf(
94
95
  thresholdMs: number
95
96
  ): Stall | null {
96
97
  const a = live.activity;
97
- if (live.state !== "running" || thresholdMs <= 0 || a.tasks.size > 0)
98
+ // A compaction is silent for a while, by nature.
99
+ if (
100
+ live.state !== "running" ||
101
+ live.compacting ||
102
+ thresholdMs <= 0 ||
103
+ a.tasks.size > 0
104
+ )
98
105
  return null;
99
106
  // A foreground tool call (a long test run) says nothing while it works:
100
107
  // it gets a much longer allowance before it counts as stuck.
@@ -110,6 +117,8 @@ export function chatStall(sessionId: string): Stall | null {
110
117
  return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
111
118
  }
112
119
 
120
+ export const COMPACTING_LABEL = "Compacting context";
121
+
113
122
  // One line on what a chat session is doing, for the session list and
114
123
  // notifications: the step running now and since when.
115
124
  export function chatActivity(sessionId: string): {
@@ -117,6 +126,8 @@ export function chatActivity(sessionId: string): {
117
126
  since?: number;
118
127
  background: number;
119
128
  stalled?: Stall;
129
+ // The context size going into a compaction under way.
130
+ compacting?: { before?: number };
120
131
  } | null {
121
132
  const live = registry.live.get(sessionId);
122
133
  if (!live) return null;
@@ -126,6 +137,13 @@ export function chatActivity(sessionId: string): {
126
137
  return { label: "Needs your answer", background };
127
138
  if (live.state !== "running")
128
139
  return background ? { label: "Idle", background } : null;
140
+ if (live.compacting)
141
+ return {
142
+ label: COMPACTING_LABEL,
143
+ since: live.compacting.since,
144
+ background,
145
+ compacting: { before: live.compacting.before },
146
+ };
129
147
  const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
130
148
  if (stalled)
131
149
  return { label: "Stalled", since: stalled.since, background, stalled };
@@ -145,7 +163,11 @@ export function chatActivityLine(sessionId: string): string | null {
145
163
  if (a.label === "Idle") return background || null;
146
164
  if (a.stalled)
147
165
  return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
148
- const parts = [a.label];
166
+ const parts = [
167
+ a.compacting?.before
168
+ ? `${a.label} (${formatTokens(a.compacting.before)} tokens)`
169
+ : a.label,
170
+ ];
149
171
  if (a.since) parts.push(formatElapsed(Date.now() - a.since));
150
172
  if (background) parts.push(background);
151
173
  return parts.join(" · ");
@@ -224,6 +224,13 @@ export class ClaudeMapper {
224
224
  case "task_progress":
225
225
  case "task_notification":
226
226
  return this.task(m);
227
+ case "status": {
228
+ // "compacting" while the context is summarised, null once it's done.
229
+ const status = (m as { status?: string | null }).status;
230
+ return status === "compacting" || status === null
231
+ ? [{ type: "compacting", active: status === "compacting" }]
232
+ : [];
233
+ }
227
234
  case "compact_boundary":
228
235
  return [
229
236
  {
@@ -69,6 +69,8 @@ export class CodexMapper {
69
69
  }
70
70
 
71
71
  private started(id: string, item: P): void {
72
+ if (item.type === "contextCompaction")
73
+ return this.emit({ type: "compacting", active: true });
72
74
  const start = toolStart(item);
73
75
  if (start) this.items.tool(id, start);
74
76
  }
@@ -82,11 +84,13 @@ export class CodexMapper {
82
84
  );
83
85
  if (item.type === "reasoning")
84
86
  return this.items.finishText(id, "reasoning");
85
- if (item.type === "contextCompaction")
87
+ if (item.type === "contextCompaction") {
88
+ this.emit({ type: "compacting", active: false });
86
89
  return this.emit({
87
90
  type: "item",
88
91
  item: { id: randomUUID(), kind: "compacted", createdAt: Date.now() },
89
92
  });
93
+ }
90
94
  const start = toolStart(item);
91
95
  if (!start) return;
92
96
  if (!this.items.hasTool(id)) this.items.tool(id, start);
@@ -90,7 +90,11 @@ export class PiMapper {
90
90
  e.isError ? "error" : "done",
91
91
  resultText(e.result)
92
92
  );
93
+ case "compaction_start":
94
+ return this.emit({ type: "compacting", active: true });
93
95
  case "compaction_end":
96
+ // Failed or not, it's over.
97
+ this.emit({ type: "compacting", active: false });
94
98
  if (!e.result) return;
95
99
  return this.emit({
96
100
  type: "item",
@@ -267,6 +267,13 @@ export interface FileSuggestion {
267
267
  // Waiting: a turn is paused on the reader (an approval or a question).
268
268
  export type ChatState = "idle" | "running" | "waiting" | "error";
269
269
 
270
+ // A compaction in progress, alongside a "running" state: when it began and
271
+ // the context size going in, when known.
272
+ export interface Compacting {
273
+ since: number;
274
+ before?: number;
275
+ }
276
+
270
277
  export interface UndoPreview {
271
278
  canUndo: boolean;
272
279
  error?: string;
@@ -290,7 +297,9 @@ export type DriverEvent =
290
297
  | { type: "context"; context: ChatContext } // context window use
291
298
  | { type: "turn_start" } // the agent started a turn (maybe one nobody sent)
292
299
  | { type: "at_rest" } // it has run every message it was given
293
- | { type: "state"; state: ChatState };
300
+ | { type: "state"; state: ChatState }
301
+ // The agent started (true) or finished (false) compacting its context.
302
+ | { type: "compacting"; active: boolean };
294
303
 
295
304
  // A tool call's output and diff, loaded when it's opened.
296
305
  export interface ToolBody {
@@ -310,6 +319,7 @@ export type ChatServerMessage =
310
319
  // Background tasks from before the page, for the composer's list.
311
320
  tasks: ChatItem[];
312
321
  state: ChatState;
322
+ compacting?: Compacting | null;
313
323
  queue: QueuedMessage[];
314
324
  suggestion: string | null;
315
325
  }
@@ -334,6 +344,7 @@ export type ChatServerMessage =
334
344
  | { type: "item"; item: ChatItem }
335
345
  | { type: "delta"; id: string; text: string }
336
346
  | { type: "state"; state: ChatState }
347
+ | { type: "compacting"; compacting: Compacting | null }
337
348
  | {
338
349
  type: "capabilities";
339
350
  commands: ChatCommand[];
@@ -7,12 +7,15 @@ import type {
7
7
  ChatModel,
8
8
  ChatServerMessage,
9
9
  ChatState,
10
+ Compacting,
10
11
  } from "./events";
11
12
 
12
13
  // A conversation whose worker this server is connected to.
13
14
  export interface Live {
14
15
  worker: WorkerClient;
15
16
  state: ChatState;
17
+ // Set while its agent compacts the context, over a "running" state.
18
+ compacting?: Compacting | null;
16
19
  // Streamed items carry their latest text here, ahead of SQLite.
17
20
  streaming: Map<string, ChatItem>;
18
21
  // What the turn is doing right now, for the session list.
@@ -82,7 +85,11 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
82
85
  registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
83
86
  // Idle, running, waiting and back, and a finished turn (unread): the
84
87
  // sidebar hears at once.
85
- if (m.type === "state" || (m.type === "item" && m.item.kind === "turn_end"))
88
+ if (
89
+ m.type === "state" ||
90
+ m.type === "compacting" ||
91
+ (m.type === "item" && m.item.kind === "turn_end")
92
+ )
86
93
  notifyStatusChanged();
87
94
  }
88
95
 
@@ -15,6 +15,7 @@ import type {
15
15
  ChatContext,
16
16
  ChatImage,
17
17
  ChatState,
18
+ Compacting,
18
19
  SentBy,
19
20
  UndoPreview,
20
21
  } from "./events";
@@ -159,8 +160,14 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
159
160
  const item = live.streaming.get(e.id);
160
161
  if (item && "text" in item) item.text += e.text;
161
162
  emit(sessionId, e);
163
+ } else if (e.type === "compacting") {
164
+ live.compacting = e.compacting;
165
+ emit(sessionId, e);
162
166
  } else if (e.type === "state") {
163
167
  live.state = e.state;
168
+ // Whatever ended the turn ended the compaction too.
169
+ if (e.state === "idle" || e.state === "error")
170
+ setCompacting(sessionId, live, null);
164
171
  emit(sessionId, e);
165
172
  clearTimeout(live.retiring);
166
173
  live.retiring = undefined;
@@ -187,6 +194,16 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
187
194
  }
188
195
  }
189
196
 
197
+ function setCompacting(
198
+ sessionId: string,
199
+ live: Live,
200
+ compacting: Compacting | null
201
+ ): void {
202
+ if (!live.compacting && !compacting) return;
203
+ live.compacting = compacting;
204
+ emit(sessionId, { type: "compacting", compacting });
205
+ }
206
+
190
207
  function emitQueue(sessionId: string): void {
191
208
  emit(sessionId, { type: "queue", queue: listQueue(sessionId) });
192
209
  }
@@ -236,6 +253,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
236
253
  onClose: (detached: boolean) => {
237
254
  if (live && registry.live.get(sessionId) === live) {
238
255
  registry.live.delete(sessionId);
256
+ emit(sessionId, { type: "compacting", compacting: null });
239
257
  emit(sessionId, { type: "state", state: "idle" });
240
258
  }
241
259
  // It retired itself for a current worker, or ended its agent's
@@ -276,6 +294,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
276
294
  live = {
277
295
  worker: client,
278
296
  state: hello.state,
297
+ compacting: hello.compacting ?? null,
279
298
  streaming: new Map(hello.streaming.map((i) => [i.id, i])),
280
299
  activity,
281
300
  build: hello.build,
@@ -299,6 +318,8 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
299
318
  if (live.canPlan)
300
319
  client.command({ type: "set_plan", plan: !!saved.chat_plan });
301
320
  emit(sessionId, { type: "state", state: live.state });
321
+ if (live.compacting)
322
+ emit(sessionId, { type: "compacting", compacting: live.compacting });
302
323
  return live;
303
324
  })();
304
325
  registry.connecting.set(sessionId, connecting);
@@ -770,6 +791,8 @@ export function stopChat(sessionId: string): void {
770
791
  const live = registry.live.get(sessionId);
771
792
  if (live) {
772
793
  registry.live.delete(sessionId);
794
+ if (live.compacting)
795
+ emit(sessionId, { type: "compacting", compacting: null });
773
796
  live.worker.command({ type: "close" });
774
797
  live.worker.detach();
775
798
  return;
@@ -787,6 +810,9 @@ export async function releaseChat(sessionId: string): Promise<void> {
787
810
  await resumeQueue(sessionId);
788
811
  }
789
812
 
813
+ export const chatCompacting = (sessionId: string): Compacting | null =>
814
+ registry.live.get(sessionId)?.compacting ?? null;
815
+
790
816
  export function chatState(sessionId: string): ChatState | null {
791
817
  return registry.live.get(sessionId)?.state ?? null;
792
818
  }
@@ -851,6 +877,7 @@ export function watchChat(
851
877
  hasMore: page.hasMore,
852
878
  tasks: present(sessionId, tasks),
853
879
  state: live?.state ?? "idle",
880
+ compacting: live?.compacting ?? null,
854
881
  queue: listQueue(sessionId),
855
882
  suggestion: getSession(sessionId).chat_suggestion ?? null,
856
883
  });
@@ -17,6 +17,7 @@ import type {
17
17
  ChatImage,
18
18
  ChatItem,
19
19
  ChatState,
20
+ Compacting,
20
21
  SentBy,
21
22
  UsageTotals,
22
23
  } from "../events";
@@ -151,6 +152,9 @@ export class ChatHost {
151
152
  private context: ChatContext | null = null;
152
153
  // A compaction run ahead of a message (compactFirst): the message waits
153
154
  // at the front of the queue until it ends.
155
+ // The agent compacting its context now, whoever started it: told to
156
+ // the server so it shows in place of plain "working".
157
+ compactingNow: Compacting | null = null;
154
158
  private compacting: {
155
159
  // Set when it ran because the chat was stale.
156
160
  idleMs?: number;
@@ -277,6 +281,17 @@ export class ChatHost {
277
281
  this.emit({ type: "item", item });
278
282
  }
279
283
 
284
+ private setCompacting(on: boolean, before?: number): void {
285
+ if (on === !!this.compactingNow) return;
286
+ this.compactingNow = on
287
+ ? {
288
+ since: Date.now(),
289
+ before: before ?? this.compacting?.before ?? this.context?.usedTokens,
290
+ }
291
+ : null;
292
+ this.emit({ type: "compacting", compacting: this.compactingNow });
293
+ }
294
+
280
295
  private setState(state: ChatState): void {
281
296
  this.state = state;
282
297
  this.emit({ type: "state", state });
@@ -301,6 +316,8 @@ export class ChatHost {
301
316
  // a failed one printed still shows.
302
317
  if (this.compacting?.done && e.item.kind === "command_output")
303
318
  continue;
319
+ if (e.item.kind === "compacted" || e.item.kind === "error")
320
+ this.setCompacting(false);
304
321
  if (e.item.kind === "compacted") {
305
322
  if (this.compacting) e.item = this.compactedItem(e.item);
306
323
  else this.logCompacted(e.item, e.item.trigger ?? "manual");
@@ -383,7 +400,12 @@ export class ChatHost {
383
400
  this.currentTurn = null;
384
401
  this.setState("running");
385
402
  }
403
+ } else if (e.type === "compacting") {
404
+ this.setCompacting(e.active);
386
405
  } else if (e.type === "state") {
406
+ // A stop, an error or the end of the turn ends a compaction too.
407
+ if (e.state === "idle" || e.state === "error")
408
+ this.setCompacting(false);
387
409
  if (e.state === "idle") {
388
410
  flushItems();
389
411
  // A turn cut off mid-sentence (Esc, Stop) keeps what it streamed.
@@ -435,6 +457,7 @@ export class ChatHost {
435
457
  createdAt: Date.now(),
436
458
  });
437
459
  } finally {
460
+ this.setCompacting(false);
438
461
  this.settleStreaming();
439
462
  this.closeTools();
440
463
  this.showHandedOver();
@@ -502,6 +525,7 @@ export class ChatHost {
502
525
  `UPDATE sessions SET chat_restarted = 0, chat_tools_digest = ? WHERE id = ?`
503
526
  ).run(this.toolsDigest, this.session.id);
504
527
  }
528
+ if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
505
529
  const checkpoint = now
506
530
  ? this.conversation.send(text, m.images, { now })
507
531
  : this.conversation.send(text, m.images);
@@ -807,6 +831,7 @@ export class ChatHost {
807
831
  this.currentTurn = null;
808
832
  this.stopping = null;
809
833
  this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
834
+ this.setCompacting(true, stale.tokens);
810
835
  this.setState("running");
811
836
  console.error(
812
837
  `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
@@ -931,6 +956,7 @@ export class ChatHost {
931
956
  clearTimeout(this.retireTimer);
932
957
  clearTimeout(this.restTimer);
933
958
  this.closed = true;
959
+ this.setCompacting(false);
934
960
  flushItems();
935
961
  this.onClose();
936
962
  this.conversation.close();
@@ -74,6 +74,7 @@ async function main(sessionId: string) {
74
74
  version: PROTOCOL_VERSION,
75
75
  build: buildId(),
76
76
  state: host.state,
77
+ compacting: host.compactingNow,
77
78
  streaming: [...host.streaming.values()],
78
79
  caps: ["plan", "queue", "retire", "fresh"],
79
80
  } satisfies WorkerEvent)}\n`
@@ -12,6 +12,7 @@ import type {
12
12
  ChatImage,
13
13
  ChatItem,
14
14
  ChatState,
15
+ Compacting,
15
16
  DriverEvent,
16
17
  FileSuggestion,
17
18
  ChatOrigin,
@@ -72,6 +73,8 @@ export type WorkerEvent =
72
73
  // The build of the server that started it (lib/build).
73
74
  build?: string;
74
75
  state: ChatState;
76
+ // A compaction under way (an older worker never says: not compacting).
77
+ compacting?: Compacting | null;
75
78
  streaming: ChatItem[];
76
79
  // Commands it understands beyond the first protocol ("plan"), so the
77
80
  // server never trusts a worker from an older build with one it drops.
@@ -87,9 +90,12 @@ export type WorkerEvent =
87
90
  | "call_usage"
88
91
  | "rate_limit"
89
92
  | "suggestion"
90
- | "at_rest";
93
+ | "at_rest"
94
+ | "compacting";
91
95
  }
92
96
  >
97
+ // Compacting began (with when, and the size going in) or ended (null).
98
+ | { type: "compacting"; compacting: Compacting | null }
93
99
  // The agent's guess at the next message, or null once it's stale.
94
100
  | { type: "suggestion"; text: string | null }
95
101
  | { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
@@ -25,7 +25,7 @@ export function ciWord(pr: TaskPR): string {
25
25
  export const shortId = (id: string) => id.slice(0, 8);
26
26
 
27
27
  function sessionLine(f: SessionFacts): string {
28
- const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${STATUS_WORD[f.status]}`;
28
+ const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${f.compacting ? "compacting context" : STATUS_WORD[f.status]}`;
29
29
  const parts = [
30
30
  f.activity ? `${head}, ${untrusted(f.name, f.activity)}` : head,
31
31
  ];
@@ -9,7 +9,7 @@ import { db, type Session, type StackItemStatus } from "../db";
9
9
  import { statusDetector } from "../status-detector";
10
10
  import { hostLink } from "../hosts/remote-api";
11
11
  import { peerStatus } from "../hosts/peer-sessions";
12
- import { chatState } from "../chat/runner";
12
+ import { chatCompacting, chatState } from "../chat/runner";
13
13
  import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
14
14
  import { lastUserTask } from "../chat/store";
15
15
  import { needsYou } from "../needs-you";
@@ -35,6 +35,8 @@ export interface SessionFacts {
35
35
  needsInput: boolean;
36
36
  // A chat turn silent past the stall threshold (lib/chat/stall).
37
37
  stalled?: Stall | null;
38
+ // Its agent is summarising the context: not stuck, not plain work.
39
+ compacting?: boolean;
38
40
  // Last sign of life, in ms.
39
41
  lastActive: number;
40
42
  // Work that should end in a PR: a task, or a session on its own branch.
@@ -274,6 +276,8 @@ export async function sessionFacts(
274
276
  needsInput,
275
277
  stalled:
276
278
  s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
279
+ compacting:
280
+ s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
277
281
  lastActive: Math.max(
278
282
  sqliteMs(s.updated_at),
279
283
  statusDetector.getTimestamp(s.tmux_name) * 1000,
@@ -30,6 +30,8 @@ export interface RowStatus {
30
30
  need?: SessionNeed | null;
31
31
  unread?: boolean;
32
32
  stalled?: boolean;
33
+ // A chat summarising its context (status stays "running").
34
+ compacting?: { since: number; before?: number };
33
35
  asks?: number;
34
36
  // A program's own message (OSC 7501): untrusted, plain text only.
35
37
  detail?: string | null;
@@ -41,6 +43,8 @@ export interface SidebarRow {
41
43
  need: SessionNeed | null;
42
44
  unread: boolean;
43
45
  working: boolean;
46
+ // Working, but summarising its context, not on the task.
47
+ compacting?: boolean;
44
48
  // Orchestration workers, nested under their conductor.
45
49
  workers: SidebarRow[];
46
50
  // Its task's state, when it's a task.
@@ -120,6 +124,7 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
120
124
  need: needOf(session, status, input.tasks[session.id]),
121
125
  unread: !!status?.unread,
122
126
  working: status?.status === "running",
127
+ compacting: !!status?.compacting,
123
128
  workers: [],
124
129
  task: input.tasks[session.id],
125
130
  quick: [],
@@ -256,6 +261,8 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
256
261
  (a.need ?? null) === (b.need ?? null) &&
257
262
  !!a.unread === !!b.unread &&
258
263
  !!a.stalled === !!b.stalled &&
264
+ (a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
265
+ (a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
259
266
  a.asks === b.asks &&
260
267
  (a.detail ?? null) === (b.detail ?? null)
261
268
  );
@@ -20,7 +20,7 @@ import {
20
20
  getSessionIdFromName,
21
21
  } from "../providers/registry";
22
22
  import { getDb, type Session } from "../db";
23
- import { chatState } from "../chat/runner";
23
+ import { chatCompacting, chatState } from "../chat/runner";
24
24
  import { chatActivityLine, chatStall } from "../chat/activity";
25
25
  import {
26
26
  chatNeed,
@@ -30,6 +30,7 @@ import {
30
30
  terminalStatus,
31
31
  } from "../needs-you";
32
32
  import type { SessionNeed } from "../sidebar/shelves";
33
+ import type { Compacting } from "../chat/events";
33
34
  import { openAskCount } from "../orchestrator/asks";
34
35
  import { lastUserTask } from "../chat/store";
35
36
  import { hostExecFile, isRemoteHost } from "../hosts";
@@ -62,6 +63,8 @@ export interface SessionStatusResponse {
62
63
  unread?: boolean;
63
64
  // A chat turn silent past the stall threshold (lib/chat/stall).
64
65
  stalled?: boolean;
66
+ // A chat whose agent is summarising its context ("status" stays running).
67
+ compacting?: Compacting;
65
68
  // A program's own message (OSC 7501): untrusted, plain text only.
66
69
  detail?: string | null;
67
70
  progress?: number | null;
@@ -467,6 +470,9 @@ async function collect(): Promise<StatusSnapshot> {
467
470
  need: chatNeed(session, state, asks),
468
471
  unread: isUnread(session, state),
469
472
  ...(chatStall(session.id) && { stalled: true }),
473
+ ...(chatCompacting(session.id) && {
474
+ compacting: chatCompacting(session.id)!,
475
+ }),
470
476
  ...(session.role === "orchestrator" && { asks }),
471
477
  };
472
478
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.501",
3
+ "version": "0.3.502",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"