@saadnvd1/agent-os 0.3.501 → 0.3.502
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/views/types.ts +2 -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/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -1
- 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}
|
|
@@ -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">
|
|
@@ -13,6 +13,8 @@ export interface SessionStatus {
|
|
|
13
13
|
asks?: number;
|
|
14
14
|
need?: SessionNeed | null;
|
|
15
15
|
unread?: boolean;
|
|
16
|
+
// A chat summarising its context; status stays "running".
|
|
17
|
+
compacting?: { since: number; before?: number };
|
|
16
18
|
claudeSessionId?: string | null;
|
|
17
19
|
// From a program's OSC 7501 report: untrusted, rendered as plain text.
|
|
18
20
|
detail?: string | null;
|
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 }
|
|
@@ -25,7 +25,7 @@ export function ciWord(pr: TaskPR): string {
|
|
|
25
25
|
export const shortId = (id: string) => id.slice(0, 8);
|
|
26
26
|
|
|
27
27
|
function sessionLine(f: SessionFacts): string {
|
|
28
|
-
const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${STATUS_WORD[f.status]}`;
|
|
28
|
+
const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${f.compacting ? "compacting context" : STATUS_WORD[f.status]}`;
|
|
29
29
|
const parts = [
|
|
30
30
|
f.activity ? `${head}, ${untrusted(f.name, f.activity)}` : head,
|
|
31
31
|
];
|
|
@@ -9,7 +9,7 @@ import { db, type Session, type StackItemStatus } from "../db";
|
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
10
|
import { hostLink } from "../hosts/remote-api";
|
|
11
11
|
import { peerStatus } from "../hosts/peer-sessions";
|
|
12
|
-
import { chatState } from "../chat/runner";
|
|
12
|
+
import { chatCompacting, chatState } from "../chat/runner";
|
|
13
13
|
import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
|
|
14
14
|
import { lastUserTask } from "../chat/store";
|
|
15
15
|
import { needsYou } from "../needs-you";
|
|
@@ -35,6 +35,8 @@ export interface SessionFacts {
|
|
|
35
35
|
needsInput: boolean;
|
|
36
36
|
// A chat turn silent past the stall threshold (lib/chat/stall).
|
|
37
37
|
stalled?: Stall | null;
|
|
38
|
+
// Its agent is summarising the context: not stuck, not plain work.
|
|
39
|
+
compacting?: boolean;
|
|
38
40
|
// Last sign of life, in ms.
|
|
39
41
|
lastActive: number;
|
|
40
42
|
// Work that should end in a PR: a task, or a session on its own branch.
|
|
@@ -274,6 +276,8 @@ export async function sessionFacts(
|
|
|
274
276
|
needsInput,
|
|
275
277
|
stalled:
|
|
276
278
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
279
|
+
compacting:
|
|
280
|
+
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
277
281
|
lastActive: Math.max(
|
|
278
282
|
sqliteMs(s.updated_at),
|
|
279
283
|
statusDetector.getTimestamp(s.tmux_name) * 1000,
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface RowStatus {
|
|
|
30
30
|
need?: SessionNeed | null;
|
|
31
31
|
unread?: boolean;
|
|
32
32
|
stalled?: boolean;
|
|
33
|
+
// A chat summarising its context (status stays "running").
|
|
34
|
+
compacting?: { since: number; before?: number };
|
|
33
35
|
asks?: number;
|
|
34
36
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
35
37
|
detail?: string | null;
|
|
@@ -41,6 +43,8 @@ export interface SidebarRow {
|
|
|
41
43
|
need: SessionNeed | null;
|
|
42
44
|
unread: boolean;
|
|
43
45
|
working: boolean;
|
|
46
|
+
// Working, but summarising its context, not on the task.
|
|
47
|
+
compacting?: boolean;
|
|
44
48
|
// Orchestration workers, nested under their conductor.
|
|
45
49
|
workers: SidebarRow[];
|
|
46
50
|
// Its task's state, when it's a task.
|
|
@@ -120,6 +124,7 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
120
124
|
need: needOf(session, status, input.tasks[session.id]),
|
|
121
125
|
unread: !!status?.unread,
|
|
122
126
|
working: status?.status === "running",
|
|
127
|
+
compacting: !!status?.compacting,
|
|
123
128
|
workers: [],
|
|
124
129
|
task: input.tasks[session.id],
|
|
125
130
|
quick: [],
|
|
@@ -256,6 +261,8 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
|
|
|
256
261
|
(a.need ?? null) === (b.need ?? null) &&
|
|
257
262
|
!!a.unread === !!b.unread &&
|
|
258
263
|
!!a.stalled === !!b.stalled &&
|
|
264
|
+
(a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
|
|
265
|
+
(a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
|
|
259
266
|
a.asks === b.asks &&
|
|
260
267
|
(a.detail ?? null) === (b.detail ?? null)
|
|
261
268
|
);
|
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,7 @@ import {
|
|
|
30
30
|
terminalStatus,
|
|
31
31
|
} from "../needs-you";
|
|
32
32
|
import type { SessionNeed } from "../sidebar/shelves";
|
|
33
|
+
import type { Compacting } from "../chat/events";
|
|
33
34
|
import { openAskCount } from "../orchestrator/asks";
|
|
34
35
|
import { lastUserTask } from "../chat/store";
|
|
35
36
|
import { hostExecFile, isRemoteHost } from "../hosts";
|
|
@@ -62,6 +63,8 @@ export interface SessionStatusResponse {
|
|
|
62
63
|
unread?: boolean;
|
|
63
64
|
// A chat turn silent past the stall threshold (lib/chat/stall).
|
|
64
65
|
stalled?: boolean;
|
|
66
|
+
// A chat whose agent is summarising its context ("status" stays running).
|
|
67
|
+
compacting?: Compacting;
|
|
65
68
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
66
69
|
detail?: string | null;
|
|
67
70
|
progress?: number | null;
|
|
@@ -467,6 +470,9 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
467
470
|
need: chatNeed(session, state, asks),
|
|
468
471
|
unread: isUnread(session, state),
|
|
469
472
|
...(chatStall(session.id) && { stalled: true }),
|
|
473
|
+
...(chatCompacting(session.id) && {
|
|
474
|
+
compacting: chatCompacting(session.id)!,
|
|
475
|
+
}),
|
|
470
476
|
...(session.role === "orchestrator" && { asks }),
|
|
471
477
|
};
|
|
472
478
|
}
|