@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 +7 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +2 -0
- package/components/PrStageBadge.tsx +69 -0
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +3 -0
- package/components/SessionList/SessionRow.tsx +2 -0
- package/components/views/types.ts +4 -0
- package/data/chat/useChat.ts +7 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +7 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +12 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +26 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +7 -1
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/checks.ts +2 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +6 -1
- package/lib/orchestrator/task-state.ts +30 -7
- package/lib/sidebar/shelves.ts +12 -0
- package/lib/status/collect.ts +13 -1
- package/lib/status/pr-stages.ts +55 -0
- package/lib/tasks/pr-stage.ts +92 -0
- package/lib/tasks/session.ts +13 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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.
|
|
19
|
-
? "bg-
|
|
20
|
-
:
|
|
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;
|
package/data/chat/useChat.ts
CHANGED
|
@@ -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,
|
package/lib/chat/activity.ts
CHANGED
|
@@ -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
|
-
|
|
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 = [
|
|
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",
|
package/lib/chat/events.ts
CHANGED
|
@@ -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[];
|
package/lib/chat/registry.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
package/lib/chat/runner.ts
CHANGED
|
@@ -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
|
});
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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();
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
141
|
-
|
|
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
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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
|
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -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]}`;
|
package/lib/tasks/session.ts
CHANGED
|
@@ -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 = ?`
|