@saadnvd1/agent-os 0.3.501 → 0.3.503

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}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SessionPrStage } from "@/components/PrStageBadge";
3
4
  import { ViewSwitch } from "@/components/Chat/ViewSwitch";
4
5
  import { ContextMeter } from "@/components/Chat/ContextMeter";
5
6
  import { Button } from "@/components/ui/button";
@@ -155,6 +156,7 @@ export function DesktopTabBar({
155
156
  <div className="flex shrink-0 items-center gap-1.5">
156
157
  <HostBadge hostId={session?.host_id} />
157
158
  <ContextMeter sessionId={session?.id} />
159
+ <SessionPrStage sessionId={session?.id} />
158
160
  <ViewSwitch session={session} />
159
161
  {session?.working_directory && (
160
162
  <PaneViewToggle
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { SessionPrStage } from "@/components/PrStageBadge";
3
4
  import { ViewSwitch } from "@/components/Chat/ViewSwitch";
4
5
  import { paletteActions } from "@/stores/palette";
5
6
  import { newDraft } from "@/stores/drafts";
@@ -317,6 +318,7 @@ export function MobileTabBar({
317
318
  >
318
319
  <Plus className="h-4 w-4" />
319
320
  </Button>
321
+ <SessionPrStage sessionId={session?.id} />
320
322
  <ViewSwitch session={session} />
321
323
 
322
324
  {/* View mode toggle */}
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { Loader2 } from "lucide-react";
5
+ import { statusKeys } from "@/data/sessions/keys";
6
+ import {
7
+ PR_STAGE_BUSY,
8
+ prStageText,
9
+ type PrStage,
10
+ type PrStageName,
11
+ } from "@/lib/tasks/pr-stage";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ const TONE: Record<PrStageName, string> = {
15
+ merged: "bg-purple-500/15 text-purple-700 dark:text-purple-400",
16
+ conflicts: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
17
+ "ci-failed": "bg-red-500/15 text-red-700 dark:text-red-400",
18
+ "ci-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
19
+ "ci-settling": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
20
+ "review-running": "bg-blue-500/15 text-blue-700 dark:text-blue-400",
21
+ "review-blocked": "bg-red-500/15 text-red-700 dark:text-red-400",
22
+ waiting: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
23
+ armed: "bg-green-500/15 text-green-700 dark:text-green-400",
24
+ open: "bg-muted text-muted-foreground",
25
+ };
26
+
27
+ // Where a task's PR is in the gates, linking to the PR. `compact` drops the
28
+ // "PR #n" for the sidebar row's tight space (the number stays in the title).
29
+ export function PrStageBadge({
30
+ stage,
31
+ compact = false,
32
+ }: {
33
+ stage?: PrStage;
34
+ compact?: boolean;
35
+ }) {
36
+ if (!stage) return null;
37
+ const text = prStageText(stage);
38
+ return (
39
+ <a
40
+ href={stage.url}
41
+ target="_blank"
42
+ rel="noreferrer"
43
+ title={`${text}${stage.sha ? ` · ${stage.sha}` : ""}`}
44
+ aria-label={text}
45
+ onClick={(e) => e.stopPropagation()}
46
+ className={cn(
47
+ "inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold whitespace-nowrap hover:underline",
48
+ TONE[stage.stage]
49
+ )}
50
+ >
51
+ {PR_STAGE_BUSY.has(stage.stage) && (
52
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
53
+ )}
54
+ {compact ? text.replace("PR ", "") : text}
55
+ </a>
56
+ );
57
+ }
58
+
59
+ // The badge for one session, read from the status the sidebar already holds.
60
+ export function SessionPrStage({ sessionId }: { sessionId?: string | null }) {
61
+ const { data } = useQuery<{
62
+ statuses: Record<string, { prStage?: PrStage }>;
63
+ }>({ queryKey: statusKeys.all, enabled: false });
64
+ return (
65
+ <PrStageBadge
66
+ stage={sessionId ? data?.statuses[sessionId]?.prStage : undefined}
67
+ />
68
+ );
69
+ }
@@ -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">
@@ -1,3 +1,4 @@
1
+ import type { PrStage } from "@/lib/tasks/pr-stage";
1
2
  import type { SessionNeed } from "@/lib/sidebar/shelves";
2
3
 
3
4
  export interface SessionStatus {
@@ -10,6 +11,8 @@ export interface SessionStatus {
10
11
  need?: SessionNeed | null;
11
12
  unread?: boolean;
12
13
  stalled?: boolean;
14
+ compacting?: { since: number; before?: number };
15
+ prStage?: PrStage;
13
16
  }
14
17
 
15
18
  export interface SessionListProps {
@@ -12,6 +12,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
12
12
  import { useRowContext } from "./RowContext";
13
13
  import { SessionRowMenu } from "./SessionRowMenu";
14
14
  import { RowShell, type RowMenuKind } from "./RowShell";
15
+ import { PrStageBadge } from "@/components/PrStageBadge";
15
16
  import { RowBadge, RowDot, RowRename } from "./RowParts";
16
17
  import { RowSubtitle } from "./RowSubtitle";
17
18
  import { OrchestratorAsks } from "./OrchestratorAsks";
@@ -96,6 +97,7 @@ export const SessionRow = memo(function SessionRow({
96
97
  trailing={
97
98
  <>
98
99
  <RowBadge row={row} />
100
+ {!row.need && <PrStageBadge stage={row.status?.prStage} compact />}
99
101
  {!row.need && <TimeAgo at={session.updated_at} />}
100
102
  </>
101
103
  }
@@ -1,3 +1,4 @@
1
+ import type { PrStage } from "@/lib/tasks/pr-stage";
1
2
  import type { SessionNeed } from "@/lib/sidebar/shelves";
2
3
  import type { Session } from "@/lib/db";
3
4
  import type { ProjectWithDevServers } from "@/lib/projects";
@@ -13,6 +14,9 @@ export interface SessionStatus {
13
14
  asks?: number;
14
15
  need?: SessionNeed | null;
15
16
  unread?: boolean;
17
+ // A chat summarising its context; status stays "running".
18
+ compacting?: { since: number; before?: number };
19
+ prStage?: PrStage;
16
20
  claudeSessionId?: string | null;
17
21
  // From a program's OSC 7501 report: untrusted, rendered as plain text.
18
22
  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 }
@@ -12,6 +12,7 @@
12
12
  * database, so it survives a restart.
13
13
  */
14
14
 
15
+ import { notifyStatusChanged } from "../status/hub";
15
16
  import { db, type Session } from "../db";
16
17
  import type { TaskPR } from "../tasks/state";
17
18
  import { latestApproval } from "./ask-approvals";
@@ -37,6 +38,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
37
38
  merge_armed_seen = NULL WHERE id = ?`
38
39
  ).run(sessionId);
39
40
  looked.delete(sessionId);
41
+ notifyStatusChanged();
40
42
  }
41
43
 
42
44
  // The workspace a task's PR merges through when armed: its project's, with
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { db } from "../db";
8
+ import { notifyStatusChanged } from "../status/hub";
8
9
 
9
10
  // ci: when the check count on a commit last changed, for settling.
10
11
  export type CheckKind = "review" | "scope" | "ci";
@@ -57,6 +58,7 @@ export function putCheck(row: {
57
58
  row.status,
58
59
  row.detail ?? null
59
60
  );
61
+ notifyStatusChanged();
60
62
  return getCheck(row.sessionId, row.sha, row.kind)!;
61
63
  }
62
64
 
@@ -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.
@@ -198,6 +200,7 @@ export async function statusOf(s: Session): Promise<{
198
200
 
199
201
  // A commit's time never changes: read once per sha.
200
202
  const committed = new Map<string, number>();
203
+ export const knownCommitTime = (sha: string) => committed.get(sha) ?? 0;
201
204
 
202
205
  export async function settleIn(
203
206
  workspaceId: string,
@@ -274,6 +277,8 @@ export async function sessionFacts(
274
277
  needsInput,
275
278
  stalled:
276
279
  s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
280
+ compacting:
281
+ s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
277
282
  lastActive: Math.max(
278
283
  sqliteMs(s.updated_at),
279
284
  statusDetector.getTimestamp(s.tmux_name) * 1000,
@@ -114,6 +114,20 @@ async function peerChatWaiting(
114
114
  // check has registered on it for 2 minutes. Seconds still to wait.
115
115
  export const SETTLE_S = 120;
116
116
 
117
+ // When the check count last changed, as ciSettleIn stored it.
118
+ const lastSeen = (detail: string | null | undefined) =>
119
+ detail ? (JSON.parse(detail) as { count: number; at: number }) : null;
120
+
121
+ const settleLeft = (
122
+ last: { count: number; at: number } | null,
123
+ checkCount: number,
124
+ committedAt: number,
125
+ now: number
126
+ ) => {
127
+ const changedAt = last && last.count === checkCount ? last.at : now;
128
+ return Math.max(0, Math.max(committedAt, changedAt) + SETTLE_S - now);
129
+ };
130
+
117
131
  export function ciSettleIn(input: {
118
132
  workspaceId: string;
119
133
  taskId: string;
@@ -123,11 +137,7 @@ export function ciSettleIn(input: {
123
137
  now?: number;
124
138
  }): number {
125
139
  const now = Math.floor((input.now ?? Date.now()) / 1000);
126
- const seen = getCheck(input.taskId, input.sha, "ci");
127
- const last = seen?.detail
128
- ? (JSON.parse(seen.detail) as { count: number; at: number })
129
- : null;
130
- const changedAt = last && last.count === input.checkCount ? last.at : now;
140
+ const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
131
141
  if (!last || last.count !== input.checkCount)
132
142
  putCheck({
133
143
  workspaceId: input.workspaceId,
@@ -137,8 +147,21 @@ export function ciSettleIn(input: {
137
147
  status: "pass",
138
148
  detail: JSON.stringify({ count: input.checkCount, at: now }),
139
149
  });
140
- const settledAt = Math.max(input.committedAt, changedAt) + SETTLE_S;
141
- return Math.max(0, settledAt - now);
150
+ return settleLeft(last, input.checkCount, input.committedAt, now);
151
+ }
152
+
153
+ // The same answer from what ciSettleIn stored, writing nothing: for a
154
+ // status that only looks.
155
+ export function ciSettleLeft(input: {
156
+ taskId: string;
157
+ sha: string;
158
+ checkCount: number;
159
+ committedAt: number;
160
+ now?: number;
161
+ }): number {
162
+ const now = Math.floor((input.now ?? Date.now()) / 1000);
163
+ const last = lastSeen(getCheck(input.taskId, input.sha, "ci")?.detail);
164
+ return settleLeft(last, input.checkCount, input.committedAt, now);
142
165
  }
143
166
 
144
167
  // A head with no checks for this long after its PR became mergeable, in a
@@ -2,6 +2,7 @@
2
2
  // orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
3
3
  import type { Session } from "@/lib/db/types";
4
4
  import { fromSqliteTime } from "@/lib/session-meta";
5
+ import type { PrStage } from "@/lib/tasks/pr-stage";
5
6
  import type { TaskState } from "@/lib/tasks/state";
6
7
 
7
8
  // Why a session is blocked on you.
@@ -30,7 +31,10 @@ export interface RowStatus {
30
31
  need?: SessionNeed | null;
31
32
  unread?: boolean;
32
33
  stalled?: boolean;
34
+ // A chat summarising its context (status stays "running").
35
+ compacting?: { since: number; before?: number };
33
36
  asks?: number;
37
+ prStage?: PrStage;
34
38
  // A program's own message (OSC 7501): untrusted, plain text only.
35
39
  detail?: string | null;
36
40
  }
@@ -41,6 +45,8 @@ export interface SidebarRow {
41
45
  need: SessionNeed | null;
42
46
  unread: boolean;
43
47
  working: boolean;
48
+ // Working, but summarising its context, not on the task.
49
+ compacting?: boolean;
44
50
  // Orchestration workers, nested under their conductor.
45
51
  workers: SidebarRow[];
46
52
  // Its task's state, when it's a task.
@@ -120,6 +126,7 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
120
126
  need: needOf(session, status, input.tasks[session.id]),
121
127
  unread: !!status?.unread,
122
128
  working: status?.status === "running",
129
+ compacting: !!status?.compacting,
123
130
  workers: [],
124
131
  task: input.tasks[session.id],
125
132
  quick: [],
@@ -256,7 +263,12 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
256
263
  (a.need ?? null) === (b.need ?? null) &&
257
264
  !!a.unread === !!b.unread &&
258
265
  !!a.stalled === !!b.stalled &&
266
+ (a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
267
+ (a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
259
268
  a.asks === b.asks &&
269
+ (a.prStage?.stage ?? null) === (b.prStage?.stage ?? null) &&
270
+ (a.prStage?.number ?? null) === (b.prStage?.number ?? null) &&
271
+ (a.prStage?.sha ?? null) === (b.prStage?.sha ?? null) &&
260
272
  (a.detail ?? null) === (b.detail ?? null)
261
273
  );
262
274
  }
@@ -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,9 @@ import {
30
30
  terminalStatus,
31
31
  } from "../needs-you";
32
32
  import type { SessionNeed } from "../sidebar/shelves";
33
+ import type { PrStage } from "../tasks/pr-stage";
34
+ import { addPrStages } from "./pr-stages";
35
+ import type { Compacting } from "../chat/events";
33
36
  import { openAskCount } from "../orchestrator/asks";
34
37
  import { lastUserTask } from "../chat/store";
35
38
  import { hostExecFile, isRemoteHost } from "../hosts";
@@ -62,6 +65,10 @@ export interface SessionStatusResponse {
62
65
  unread?: boolean;
63
66
  // A chat turn silent past the stall threshold (lib/chat/stall).
64
67
  stalled?: boolean;
68
+ // A chat whose agent is summarising its context ("status" stays running).
69
+ compacting?: Compacting;
70
+ // Where a task's PR is in the gates (lib/tasks/pr-stage.ts).
71
+ prStage?: PrStage;
65
72
  // A program's own message (OSC 7501): untrusted, plain text only.
66
73
  detail?: string | null;
67
74
  progress?: number | null;
@@ -467,10 +474,15 @@ async function collect(): Promise<StatusSnapshot> {
467
474
  need: chatNeed(session, state, asks),
468
475
  unread: isUnread(session, state),
469
476
  ...(chatStall(session.id) && { stalled: true }),
477
+ ...(chatCompacting(session.id) && {
478
+ compacting: chatCompacting(session.id)!,
479
+ }),
470
480
  ...(session.role === "orchestrator" && { asks }),
471
481
  };
472
482
  }
473
483
 
484
+ addPrStages(statusMap);
485
+
474
486
  // Cleanup old trackers
475
487
  statusDetector.cleanup();
476
488
 
@@ -0,0 +1,55 @@
1
+ // A task's PR stage for the sidebar, from state AgentOS already holds: the
2
+ // PR as the last poll saw it, the stored review and CI checks, escalations,
3
+ // asks and the armed flag. Nothing here asks GitHub.
4
+ import { db, type Session } from "../db";
5
+ import { cachedPR, storedPR } from "../tasks/session";
6
+ import { derivePrStage } from "../tasks/pr-stage";
7
+ import { getCheck } from "../orchestrator/checks";
8
+ import { holdingEscalations } from "../orchestrator/gates";
9
+ import { openBySubject, workSubject } from "../orchestrator/asks";
10
+ import { ciSettleLeft } from "../orchestrator/task-state";
11
+ import { knownCommitTime } from "../orchestrator/facts";
12
+ import type { SessionStatusResponse } from "./collect";
13
+
14
+ type TaskRow = Session & { workspace_id: string };
15
+
16
+ export function addPrStages(
17
+ statuses: Record<string, SessionStatusResponse>
18
+ ): void {
19
+ const tasks = db
20
+ .prepare(
21
+ `SELECT s.*, p.workspace_id FROM sessions s
22
+ JOIN projects p ON p.id = s.project_id
23
+ WHERE s.task_status = 'running' AND s.pr_number IS NOT NULL
24
+ AND s.archived_at IS NULL`
25
+ )
26
+ .all() as TaskRow[];
27
+ for (const t of tasks) {
28
+ const status = statuses[t.id];
29
+ if (!status) continue;
30
+ try {
31
+ const pr = cachedPR(t.id) ?? storedPR(t);
32
+ const head = pr?.head;
33
+ const stage = derivePrStage({
34
+ pr,
35
+ settleIn:
36
+ pr?.checks === "pass" && head
37
+ ? ciSettleLeft({
38
+ taskId: t.id,
39
+ sha: head,
40
+ checkCount: pr.checkCount ?? 0,
41
+ committedAt: knownCommitTime(head),
42
+ })
43
+ : 0,
44
+ review: head ? (getCheck(t.id, head, "review")?.status ?? null) : null,
45
+ held:
46
+ holdingEscalations(t.id).length > 0 ||
47
+ !!openBySubject(t.workspace_id, workSubject(t.id)),
48
+ armed: !!t.merge_armed_at,
49
+ });
50
+ if (stage) status.prStage = stage;
51
+ } catch (error) {
52
+ console.error(`[status] PR stage of ${t.id.slice(0, 8)}:`, error);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,92 @@
1
+ // Where a task's PR is in the gates, as one word for the UI. Pure: the
2
+ // inputs are what AgentOS already tracks for the PR (tasks/state.ts,
3
+ // orchestrator/checks.ts), so nothing here asks GitHub or a model.
4
+
5
+ export const PR_STAGES = [
6
+ "merged",
7
+ "conflicts",
8
+ "ci-failed",
9
+ "ci-running",
10
+ "ci-settling",
11
+ "review-running",
12
+ "review-blocked",
13
+ "waiting",
14
+ "armed",
15
+ "open",
16
+ ] as const;
17
+
18
+ export type PrStageName = (typeof PR_STAGES)[number];
19
+
20
+ export interface PrStage {
21
+ stage: PrStageName;
22
+ number: number;
23
+ url: string;
24
+ // The PR's head, 7 characters.
25
+ sha?: string;
26
+ }
27
+
28
+ export interface PrStageInput {
29
+ pr: {
30
+ number: number;
31
+ url: string;
32
+ state: "OPEN" | "MERGED" | "CLOSED";
33
+ checks: "pass" | "fail" | "pending" | "none";
34
+ head?: string;
35
+ mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
36
+ } | null;
37
+ // Seconds until CI on the head counts as settled (0 or unset: settled).
38
+ settleIn?: number;
39
+ // The newest review of the head commit.
40
+ review?: "running" | "pass" | "block" | "error" | null;
41
+ // Escalated to Saad, or an ask of his open on it.
42
+ held?: boolean;
43
+ armed?: boolean;
44
+ }
45
+
46
+ // In priority order: the first that applies wins, so a conflict hides the
47
+ // CI behind it and a failed CI hides a review that would be moot.
48
+ export function derivePrStage(input: PrStageInput): PrStage | null {
49
+ const { pr } = input;
50
+ if (!pr || pr.state === "CLOSED") return null;
51
+ const base = {
52
+ number: pr.number,
53
+ url: pr.url,
54
+ ...(pr.head && { sha: pr.head.slice(0, 7) }),
55
+ };
56
+ const at = (stage: PrStageName): PrStage => ({ stage, ...base });
57
+ if (pr.state === "MERGED") return at("merged");
58
+ if (pr.mergeable === "CONFLICTING") return at("conflicts");
59
+ if (pr.checks === "fail") return at("ci-failed");
60
+ if (pr.checks === "pending") return at("ci-running");
61
+ if (pr.checks === "pass" && (input.settleIn ?? 0) > 0)
62
+ return at("ci-settling");
63
+ if (input.review === "running") return at("review-running");
64
+ if (input.review === "block") return at("review-blocked");
65
+ if (input.held) return at("waiting");
66
+ if (input.armed) return at("armed");
67
+ return at("open");
68
+ }
69
+
70
+ // Stages still moving on their own: the badge spins for these.
71
+ export const PR_STAGE_BUSY: ReadonlySet<PrStageName> = new Set([
72
+ "conflicts",
73
+ "ci-running",
74
+ "ci-settling",
75
+ "review-running",
76
+ ]);
77
+
78
+ export const PR_STAGE_LABEL: Record<PrStageName, string> = {
79
+ merged: "merged",
80
+ conflicts: "rebasing",
81
+ "ci-failed": "CI failed",
82
+ "ci-running": "CI running",
83
+ "ci-settling": "CI settling",
84
+ "review-running": "reviewing",
85
+ "review-blocked": "review blocked",
86
+ waiting: "waiting on you",
87
+ armed: "armed",
88
+ open: "open",
89
+ };
90
+
91
+ export const prStageText = (s: Pick<PrStage, "stage" | "number">) =>
92
+ `PR #${s.number} · ${PR_STAGE_LABEL[s.stage]}`;
@@ -9,6 +9,7 @@ import { getProject } from "../projects";
9
9
  import { findPRStrict } from "./gh";
10
10
  import { currentBranch, git } from "../git-exec";
11
11
  import { lookupPR } from "./pr-poll";
12
+ import { notifyStatusChanged } from "../status/hub";
12
13
  import type { TaskPR } from "./state";
13
14
 
14
15
  export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
@@ -128,6 +129,15 @@ export const isoUTC = (t: string) =>
128
129
 
129
130
  const prCache = new Map<string, { at: number; pr: TaskPR | null }>();
130
131
 
132
+ const prSignature = (pr: TaskPR | null | undefined) =>
133
+ pr
134
+ ? `${pr.number}|${pr.state}|${pr.checks}|${pr.head}|${pr.mergeable}|${pr.checkCount}`
135
+ : "";
136
+
137
+ // The PR as the last poll saw it, with no gh call; undefined before one.
138
+ export const cachedPR = (sessionId: string): TaskPR | null | undefined =>
139
+ prCache.get(sessionId)?.pr;
140
+
131
141
  // strict: a gh failure throws instead of reading as "no PR".
132
142
  export async function prFor(
133
143
  session: Session,
@@ -167,7 +177,10 @@ export async function prFor(
167
177
  }
168
178
  }
169
179
  if (renamed && !refused) moveBranch(session, renamed);
180
+ const before = prCache.get(session.id)?.pr;
170
181
  prCache.set(session.id, { at: Date.now(), pr });
182
+ // A PR whose stage inputs moved is news for the sidebar's badge.
183
+ if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
171
184
  if (pr) {
172
185
  db.prepare(
173
186
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
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.503",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"