@saadnvd1/agent-os 0.3.499 → 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.
Files changed (64) hide show
  1. package/README.md +77 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Chat/Activity.test.ts +22 -0
  4. package/components/Chat/Activity.tsx +17 -5
  5. package/components/Chat/ChatPanel.tsx +7 -1
  6. package/components/Devices/ConnectCard.tsx +10 -12
  7. package/components/SessionList/RowParts.tsx +13 -4
  8. package/components/SessionList/SessionList.types.ts +1 -0
  9. package/components/Tasks/TaskRow.tsx +2 -0
  10. package/components/Usage/RatchetView.tsx +249 -0
  11. package/components/Usage/UsageDialog.tsx +7 -1
  12. package/components/Usage/WindowCard.tsx +2 -1
  13. package/components/views/types.ts +2 -0
  14. package/data/chat/useChat.ts +7 -0
  15. package/data/usage/index.ts +28 -0
  16. package/lib/chat/activity.ts +24 -2
  17. package/lib/chat/drivers/claude-mapper.ts +8 -0
  18. package/lib/chat/drivers/claude.ts +46 -0
  19. package/lib/chat/drivers/codex-mapper.ts +5 -1
  20. package/lib/chat/drivers/pi-mapper.ts +4 -0
  21. package/lib/chat/events.ts +15 -1
  22. package/lib/chat/registry.ts +8 -1
  23. package/lib/chat/runner.ts +27 -0
  24. package/lib/chat/worker/host.ts +47 -0
  25. package/lib/chat/worker/main.ts +1 -0
  26. package/lib/chat/worker/protocol.ts +14 -1
  27. package/lib/connect/cli.ts +51 -72
  28. package/lib/connect/cloudflared.ts +172 -0
  29. package/lib/connect/config.ts +75 -26
  30. package/lib/connect/control-api.ts +107 -48
  31. package/lib/connect/enrol.ts +33 -107
  32. package/lib/connect/serve.ts +177 -94
  33. package/lib/connect/supervise.ts +281 -0
  34. package/lib/db/migrations.ts +72 -0
  35. package/lib/orchestrator/describe.ts +1 -1
  36. package/lib/orchestrator/facts.ts +5 -1
  37. package/lib/security/auth.ts +20 -4
  38. package/lib/security/gate.ts +8 -5
  39. package/lib/security/reach.ts +1 -1
  40. package/lib/sidebar/shelves.ts +7 -0
  41. package/lib/status/collect.ts +7 -1
  42. package/lib/tasks/index.ts +8 -0
  43. package/lib/usage/calls.ts +284 -0
  44. package/lib/usage/check-runs.ts +40 -19
  45. package/lib/usage/config.ts +56 -0
  46. package/lib/usage/nightly.ts +157 -0
  47. package/lib/usage/quality.ts +239 -0
  48. package/lib/usage/ratchet.ts +275 -0
  49. package/lib/usage/rate-limits.ts +192 -0
  50. package/lib/usage/rollup.ts +90 -0
  51. package/lib/usage/span.ts +152 -0
  52. package/lib/usage/transcript-import.ts +226 -0
  53. package/lib/usage/weights.ts +64 -0
  54. package/lib/usage/windows.ts +27 -2
  55. package/package.json +2 -4
  56. package/server.ts +7 -4
  57. package/stores/usageUi.ts +3 -3
  58. package/lib/connect/cert-check.ts +0 -32
  59. package/lib/connect/cloudflare-dns.ts +0 -56
  60. package/lib/connect/frames.ts +0 -72
  61. package/lib/connect/identity.ts +0 -98
  62. package/lib/connect/mux.ts +0 -110
  63. package/lib/connect/stream.ts +0 -106
  64. package/lib/connect/tunnel.ts +0 -98
@@ -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,
@@ -2,6 +2,9 @@ import { useQuery } from "@tanstack/react-query";
2
2
  import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
3
3
  import type { UsageWindows } from "@/lib/usage/windows";
4
4
  import type { SpendByModel } from "@/lib/usage/by-model";
5
+ import type { Rollup, Span } from "@/lib/usage/span";
6
+ import type { Baseline, Supporting } from "@/lib/usage/ratchet";
7
+ import type { UsageConfig } from "@/lib/usage/config";
5
8
 
6
9
  export const usageKeys = {
7
10
  all: ["usage"] as const,
@@ -31,3 +34,28 @@ export function useUsageQuery(range: UsageRange, enabled = true) {
31
34
  placeholderData: (prev) => prev,
32
35
  });
33
36
  }
37
+
38
+ export interface RollupsResponse {
39
+ rollups: Rollup[];
40
+ total: Span;
41
+ baseline: Baseline | null;
42
+ labels: Record<keyof Supporting, string>;
43
+ config: UsageConfig;
44
+ alerts: { kind: string; openedAt: number; message: string }[];
45
+ windows: UsageWindows;
46
+ }
47
+
48
+ export function useUsageRollupsQuery(enabled = true) {
49
+ return useQuery({
50
+ queryKey: [...usageKeys.all, "rollups"] as const,
51
+ queryFn: async () => {
52
+ const res = await fetch("/api/usage/rollups");
53
+ const data = await res.json();
54
+ if (!res.ok) throw new Error(data.error || "Request failed");
55
+ return data as RollupsResponse;
56
+ },
57
+ enabled,
58
+ refetchInterval: enabled ? 5 * 60_000 : false,
59
+ placeholderData: (prev) => prev,
60
+ });
61
+ }
@@ -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(" · ");
@@ -30,6 +30,7 @@ export type ClaudeMessage = {
30
30
  usage?: {
31
31
  input_tokens?: number;
32
32
  output_tokens?: number;
33
+ cache_read_input_tokens?: number;
33
34
  cache_creation_input_tokens?: number;
34
35
  cache_creation?: {
35
36
  ephemeral_5m_input_tokens?: number;
@@ -223,6 +224,13 @@ export class ClaudeMapper {
223
224
  case "task_progress":
224
225
  case "task_notification":
225
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
+ }
226
234
  case "compact_boundary":
227
235
  return [
228
236
  {
@@ -21,6 +21,7 @@ import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
21
21
  import { SuggestionTrace } from "./suggestion-trace";
22
22
  import { redact } from "../../orchestrator/untrusted";
23
23
  import { NO_ATTRIBUTION } from "../../agents/attribution";
24
+ import { callFromAssistant } from "../../usage/calls";
24
25
 
25
26
  // Claude Code through the Agent SDK, signed in with the user's own Claude
26
27
  // Code login. Asks for approval through chat cards unless given full access,
@@ -245,6 +246,7 @@ export const claudeDriver: ChatDriver = {
245
246
  )
246
247
  .catch(() => {});
247
248
 
249
+ const agents = new Map<string, string>();
248
250
  void (async () => {
249
251
  try {
250
252
  for await (const message of q) {
@@ -252,6 +254,7 @@ export const claudeDriver: ChatDriver = {
252
254
  if (m.session_id) sessionId = m.session_id;
253
255
  planFile = writtenPlanFile(m) ?? planFile;
254
256
  trace.message(m);
257
+ for (const e of usageEvents(m, agents)) out.push(e);
255
258
  for (const e of mapper.map(m)) out.push(e);
256
259
  if (m.type === "assistant" && !m.parent_tool_use_id)
257
260
  cacheTtl = cacheTtlMs(m.message?.usage?.cache_creation) ?? cacheTtl;
@@ -369,3 +372,46 @@ export const claudeDriver: ChatDriver = {
369
372
  };
370
373
  },
371
374
  };
375
+
376
+ // The tokens of each response, subagents' too (the mapper drops those), and
377
+ // the CLI's limit readings, for the usage ledger. `agents` maps a subagent's
378
+ // parent tool call to its type, learned from the call that started it.
379
+ export function usageEvents(
380
+ m: ClaudeMessage,
381
+ agents: Map<string, string>,
382
+ at = Date.now()
383
+ ): DriverEvent[] {
384
+ if (m.type === "rate_limit_event")
385
+ return [
386
+ {
387
+ type: "rate_limit",
388
+ info: (m as { rate_limit_info?: unknown }).rate_limit_info,
389
+ },
390
+ ];
391
+ if (m.type !== "assistant") return [];
392
+ const content = m.message?.content;
393
+ if (!m.parent_tool_use_id && Array.isArray(content))
394
+ for (const b of content as unknown as {
395
+ type?: string;
396
+ id?: string;
397
+ name?: string;
398
+ input?: { subagent_type?: unknown };
399
+ }[])
400
+ if (
401
+ b.type === "tool_use" &&
402
+ b.id &&
403
+ (b.name === "Agent" || b.name === "Task") &&
404
+ typeof b.input?.subagent_type === "string"
405
+ )
406
+ agents.set(b.id, b.input.subagent_type);
407
+ const agentType = m.parent_tool_use_id
408
+ ? (agents.get(m.parent_tool_use_id) ?? null)
409
+ : null;
410
+ const kind = !m.parent_tool_use_id
411
+ ? "main"
412
+ : agentType?.startsWith("review-")
413
+ ? "review"
414
+ : "sub";
415
+ const call = callFromAssistant(m.message, kind, agentType, at);
416
+ return call ? [{ type: "call_usage", call }] : [];
417
+ }
@@ -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",
@@ -1,3 +1,4 @@
1
+ import type { CallUsage } from "../usage/calls";
1
2
  import type { SearchResult } from "./find";
2
3
 
3
4
  /**
@@ -266,6 +267,13 @@ export interface FileSuggestion {
266
267
  // Waiting: a turn is paused on the reader (an approval or a question).
267
268
  export type ChatState = "idle" | "running" | "waiting" | "error";
268
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
+
269
277
  export interface UndoPreview {
270
278
  canUndo: boolean;
271
279
  error?: string;
@@ -284,10 +292,14 @@ export type DriverEvent =
284
292
  | { type: "suggestion"; text: string } // the agent's guess at the next message
285
293
  | { type: "usage"; totals: UsageTotals } // running totals after a turn
286
294
  | { type: "usage_start"; totals: UsageTotals } // the totals it started from
295
+ | { type: "call_usage"; call: CallUsage } // one response's tokens
296
+ | { type: "rate_limit"; info: unknown } // the CLI's limit reading
287
297
  | { type: "context"; context: ChatContext } // context window use
288
298
  | { type: "turn_start" } // the agent started a turn (maybe one nobody sent)
289
299
  | { type: "at_rest" } // it has run every message it was given
290
- | { 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 };
291
303
 
292
304
  // A tool call's output and diff, loaded when it's opened.
293
305
  export interface ToolBody {
@@ -307,6 +319,7 @@ export type ChatServerMessage =
307
319
  // Background tasks from before the page, for the composer's list.
308
320
  tasks: ChatItem[];
309
321
  state: ChatState;
322
+ compacting?: Compacting | null;
310
323
  queue: QueuedMessage[];
311
324
  suggestion: string | null;
312
325
  }
@@ -331,6 +344,7 @@ export type ChatServerMessage =
331
344
  | { type: "item"; item: ChatItem }
332
345
  | { type: "delta"; id: string; text: string }
333
346
  | { type: "state"; state: ChatState }
347
+ | { type: "compacting"; compacting: Compacting | null }
334
348
  | {
335
349
  type: "capabilities";
336
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";
@@ -48,6 +49,8 @@ import {
48
49
  refreshedNote,
49
50
  releaseConversation,
50
51
  } from "../refresh";
52
+ import { recordCall, sessionContext } from "../../usage/calls";
53
+ import { parseRateLimit, recordReadings } from "../../usage/rate-limits";
51
54
  import { compactContext } from "../compact-context";
52
55
  import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
53
56
  import { compactionDue, staleLabel, type Staleness } from "../stale";
@@ -149,6 +152,9 @@ export class ChatHost {
149
152
  private context: ChatContext | null = null;
150
153
  // A compaction run ahead of a message (compactFirst): the message waits
151
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;
152
158
  private compacting: {
153
159
  // Set when it ran because the chat was stale.
154
160
  idleMs?: number;
@@ -275,11 +281,33 @@ export class ChatHost {
275
281
  this.emit({ type: "item", item });
276
282
  }
277
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
+
278
295
  private setState(state: ChatState): void {
279
296
  this.state = state;
280
297
  this.emit({ type: "state", state });
281
298
  }
282
299
 
300
+ // The usage ledger is a measure: a failed write never touches a turn.
301
+ private ledger(write: () => void): void {
302
+ try {
303
+ write();
304
+ } catch (error) {
305
+ if (!this.ledgerFailed) console.error("[usage] ledger write:", error);
306
+ this.ledgerFailed = true;
307
+ }
308
+ }
309
+ private ledgerFailed = false;
310
+
283
311
  private async pump(): Promise<void> {
284
312
  try {
285
313
  for await (const e of this.conversation.events) {
@@ -288,6 +316,8 @@ export class ChatHost {
288
316
  // a failed one printed still shows.
289
317
  if (this.compacting?.done && e.item.kind === "command_output")
290
318
  continue;
319
+ if (e.item.kind === "compacted" || e.item.kind === "error")
320
+ this.setCompacting(false);
291
321
  if (e.item.kind === "compacted") {
292
322
  if (this.compacting) e.item = this.compactedItem(e.item);
293
323
  else this.logCompacted(e.item, e.item.trigger ?? "manual");
@@ -329,6 +359,14 @@ export class ChatHost {
329
359
  );
330
360
  this.turnStartedAt = null;
331
361
  this.turnsRecorded++;
362
+ } else if (e.type === "call_usage") {
363
+ this.ledger(() =>
364
+ recordCall(e.call, sessionContext(this.session.id), "live")
365
+ );
366
+ } else if (e.type === "rate_limit") {
367
+ this.ledger(() =>
368
+ recordReadings(parseRateLimit(e.info), "event", this.session.id)
369
+ );
332
370
  } else if (e.type === "usage_start") {
333
371
  // Too late once a turn was measured without it.
334
372
  if (!this.turnsRecorded) this.usage = e.totals;
@@ -362,7 +400,12 @@ export class ChatHost {
362
400
  this.currentTurn = null;
363
401
  this.setState("running");
364
402
  }
403
+ } else if (e.type === "compacting") {
404
+ this.setCompacting(e.active);
365
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);
366
409
  if (e.state === "idle") {
367
410
  flushItems();
368
411
  // A turn cut off mid-sentence (Esc, Stop) keeps what it streamed.
@@ -414,6 +457,7 @@ export class ChatHost {
414
457
  createdAt: Date.now(),
415
458
  });
416
459
  } finally {
460
+ this.setCompacting(false);
417
461
  this.settleStreaming();
418
462
  this.closeTools();
419
463
  this.showHandedOver();
@@ -481,6 +525,7 @@ export class ChatHost {
481
525
  `UPDATE sessions SET chat_restarted = 0, chat_tools_digest = ? WHERE id = ?`
482
526
  ).run(this.toolsDigest, this.session.id);
483
527
  }
528
+ if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
484
529
  const checkpoint = now
485
530
  ? this.conversation.send(text, m.images, { now })
486
531
  : this.conversation.send(text, m.images);
@@ -786,6 +831,7 @@ export class ChatHost {
786
831
  this.currentTurn = null;
787
832
  this.stopping = null;
788
833
  this.conversation.send(`/compact ${COMPACT_INSTRUCTIONS}`);
834
+ this.setCompacting(true, stale.tokens);
789
835
  this.setState("running");
790
836
  console.error(
791
837
  `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
@@ -910,6 +956,7 @@ export class ChatHost {
910
956
  clearTimeout(this.retireTimer);
911
957
  clearTimeout(this.restTimer);
912
958
  this.closed = true;
959
+ this.setCompacting(false);
913
960
  flushItems();
914
961
  this.onClose();
915
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.
@@ -80,9 +83,19 @@ export type WorkerEvent =
80
83
  | Exclude<
81
84
  DriverEvent,
82
85
  {
83
- type: "resume_id" | "usage" | "usage_start" | "suggestion" | "at_rest";
86
+ type:
87
+ | "resume_id"
88
+ | "usage"
89
+ | "usage_start"
90
+ | "call_usage"
91
+ | "rate_limit"
92
+ | "suggestion"
93
+ | "at_rest"
94
+ | "compacting";
84
95
  }
85
96
  >
97
+ // Compacting began (with when, and the size going in) or ended (null).
98
+ | { type: "compacting"; compacting: Compacting | null }
86
99
  // The agent's guess at the next message, or null once it's stale.
87
100
  | { type: "suggestion"; text: string | null }
88
101
  | { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }