@saadnvd1/agent-os 0.3.482 → 0.3.484
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 +33 -7
- package/app/api/bus/stop/route.ts +26 -0
- package/app/api/fresh-start/route.ts +3 -1
- package/app/api/stall/route.ts +18 -0
- package/components/SessionList/RowParts.tsx +6 -0
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/Workspaces/FreshStartForm.tsx +21 -9
- package/components/Workspaces/StallForm.tsx +101 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/stall/index.ts +33 -0
- package/lib/chat/activity.ts +80 -6
- package/lib/chat/compact-context.ts +55 -0
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +32 -0
- package/lib/chat/drivers/claude.ts +4 -0
- package/lib/chat/events.ts +1 -1
- package/lib/chat/refresh.ts +19 -6
- package/lib/chat/registry.ts +7 -0
- package/lib/chat/runner.ts +2 -0
- package/lib/chat/socket.ts +2 -2
- package/lib/chat/stale.ts +5 -4
- package/lib/chat/stall-limits.ts +5 -0
- package/lib/chat/stall.ts +50 -0
- package/lib/chat/stop.ts +67 -0
- package/lib/chat/worker/host.ts +30 -4
- package/lib/chat/worker/protocol.ts +2 -1
- package/lib/chat/working-state.ts +111 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/orchestrator/act.ts +3 -9
- package/lib/orchestrator/conditions.ts +11 -0
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/sessions/stop.ts +28 -0
- package/lib/sidebar/shelves.ts +2 -0
- package/lib/stacks/restack.ts +7 -13
- package/lib/status/collect.ts +4 -1
- package/lib/usage/turns.ts +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -323,11 +323,11 @@ The ring in the tab bar (in the composer on a phone) is how full the
|
|
|
323
323
|
conversation's context is, measured against the room before auto-compact:
|
|
324
324
|
amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
turns AgentOS catches what it didn't: a chat over
|
|
326
|
+
Two thresholds (Settings → Workspaces → Compact; 0 turns one off). Mid-turn,
|
|
327
|
+
the agent compacts itself at 300k by default: AgentOS launches Claude Code
|
|
328
|
+
with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to that size (its floor is 100k),
|
|
329
|
+
high enough that a long job isn't cut in half. Between
|
|
330
|
+
turns AgentOS catches what it didn't: a chat over its 150k threshold is compacted
|
|
331
331
|
before the next message, and so is one idle past its prompt cache (5 minutes
|
|
332
332
|
or an hour, read from the agent's own usage), whose next message would
|
|
333
333
|
re-read the whole conversation at the cache-write price. A chip above the
|
|
@@ -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
|
+
What survives a compaction: the summary is asked to keep the task, scope
|
|
343
|
+
changes, every edited file by path, what's done, in progress and next, and
|
|
344
|
+
the decisions. Right after it, AgentOS hands back a note built from its own
|
|
345
|
+
records (a task's brief and scope changes, the files it edited, its todos,
|
|
346
|
+
the latest message and `git status`), which doesn't depend on what the
|
|
347
|
+
summary kept, and tells the agent not to re-read or rewrite whole files.
|
|
348
|
+
Terminal agents don't get the note yet.
|
|
342
349
|
On a desktop browser the screen button attaches a screenshot of a screen or
|
|
343
350
|
window you pick; on a phone, attach a screenshot as a photo.
|
|
344
351
|
|
|
@@ -349,13 +356,31 @@ turn ends. **Gate reviews** below it shows what the orchestrator's
|
|
|
349
356
|
independent reviewer and scope check cost, by PR: they run as `claude -p`
|
|
350
357
|
and leave no transcript, so each run's tokens, model and duration are read
|
|
351
358
|
from its JSON result into `check_runs`, and the review's verdict says them
|
|
352
|
-
too. Every compaction is kept in `chat_compactions` (who ran it,
|
|
353
|
-
context before and after), so cost per task can be compared by date.
|
|
359
|
+
too. Every compaction is kept in `chat_compactions` (who ran it, whether it
|
|
360
|
+
fired `mid_turn` or `between_turns`, and the context before and after), so cost per task can be compared by date.
|
|
354
361
|
|
|
355
362
|
**Esc** stops a running turn, as it does in Claude Code's terminal, unless a
|
|
356
363
|
menu, dialog or the command list is open (those close first). On a phone the
|
|
357
364
|
Stop button does the same. A stopped turn keeps what it wrote.
|
|
358
365
|
|
|
366
|
+
Stop ends the turn even while the model is still thinking, before any tool
|
|
367
|
+
call: the turn is interrupted, and if it hasn't ended within a few seconds the
|
|
368
|
+
agent process is ended and restarted for the next message (not if the chat is
|
|
369
|
+
waiting on an approval or question, or if a new message has started a new
|
|
370
|
+
turn meanwhile). The conversation and
|
|
371
|
+
worktree are kept, and whatever is sent next (yours, another agent's, the
|
|
372
|
+
orchestrator's) runs as the next turn, never queued behind the stopped one.
|
|
373
|
+
The UI, the orchestrator's `stop` tool and `aos stop` all take this one path
|
|
374
|
+
and report "stopped" or a real error.
|
|
375
|
+
|
|
376
|
+
A turn that has produced nothing (no token, tool call or result) for 8 minutes
|
|
377
|
+
shows as **Stalled** in the session list, and its workspace's orchestrator gets
|
|
378
|
+
one event naming the session, how long it has been silent and its last action.
|
|
379
|
+
It fires once per stall and starts over when the session does something. A
|
|
380
|
+
foreground tool call gets four times as long, and a session waiting on a
|
|
381
|
+
background command it started isn't flagged. Set the time (2 to 120 minutes, 0 for off)
|
|
382
|
+
in Settings → Workspaces → Stall alerts.
|
|
383
|
+
|
|
359
384
|
The access picker under the composer sets what the agent may do on its own:
|
|
360
385
|
**Ask first** (an approval card in the chat for anything not already allowed),
|
|
361
386
|
**Accept edits** (edits files, asks before other commands) or **Full access**
|
|
@@ -845,6 +870,7 @@ aos schedule run <name> # run a schedule now
|
|
|
845
870
|
aos schedule add --session <s> --every 30m "prompt" # check in on a session
|
|
846
871
|
aos notify "text" # push a message to your phone
|
|
847
872
|
aos heavy -- npm test # run it now; says first what else heavy is running
|
|
873
|
+
aos stop <session> # stop its turn now; the next message runs as a new turn
|
|
848
874
|
aos done <session> # finished: merge through the gates, archive
|
|
849
875
|
aos done --all-idle # the same for every idle session around you
|
|
850
876
|
aos docs [query] # LumifyHub pages, when the workspace is linked
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resolveSession } from "@/lib/bus";
|
|
3
|
+
import { getDoneTarget } from "@/lib/done";
|
|
4
|
+
import { inScope, scopeOf } from "@/lib/done/bulk";
|
|
5
|
+
import { stopSession } from "@/lib/sessions/stop";
|
|
6
|
+
|
|
7
|
+
// `aos stop <session>`, from inside a session: ends the turn in progress
|
|
8
|
+
// (or the terminal agent) and keeps the conversation and worktree. Reaches
|
|
9
|
+
// only the caller's workspace (or its project when it has none).
|
|
10
|
+
export async function POST(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const { from = null, session } = await request.json();
|
|
13
|
+
if (typeof from !== "string" || !from)
|
|
14
|
+
throw new Error("aos stop runs from inside an AgentOS session");
|
|
15
|
+
const caller = getDoneTarget(from);
|
|
16
|
+
const target = resolveSession(String(session ?? ""));
|
|
17
|
+
if (!inScope(scopeOf(caller), target))
|
|
18
|
+
throw new Error(
|
|
19
|
+
`${target.name} isn't in ${caller.name}'s workspace or project; aos stop reaches only those`
|
|
20
|
+
);
|
|
21
|
+
return NextResponse.json({ summary: await stopSession(target) });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -3,7 +3,8 @@ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
|
|
|
3
3
|
|
|
4
4
|
// When a chat's conversation starts over (lib/chat/refresh): context
|
|
5
5
|
// tokens for orchestrators and for tasks, and at which chats and tasks
|
|
6
|
-
// compact (lib/chat/stale)
|
|
6
|
+
// compact: between turns (lib/chat/stale) and mid-turn, by the agent itself;
|
|
7
|
+
// 0 for never.
|
|
7
8
|
export async function GET() {
|
|
8
9
|
return NextResponse.json(refreshSettings());
|
|
9
10
|
}
|
|
@@ -16,6 +17,7 @@ export async function PUT(request: NextRequest) {
|
|
|
16
17
|
taskTokens: body.taskTokens,
|
|
17
18
|
// Its name before it also set the agents' own auto-compact.
|
|
18
19
|
compactTokens: body.compactTokens ?? body.staleCompactTokens,
|
|
20
|
+
autoCompactTokens: body.autoCompactTokens,
|
|
19
21
|
});
|
|
20
22
|
return NextResponse.json(refreshSettings());
|
|
21
23
|
} catch (error) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { setStallMinutes, stallMinutes } from "@/lib/chat/stall";
|
|
3
|
+
|
|
4
|
+
// How long a chat turn may be silent before it's flagged stalled and its
|
|
5
|
+
// workspace's orchestrator is told (lib/chat/stall); 0 for never.
|
|
6
|
+
export async function GET() {
|
|
7
|
+
return NextResponse.json({ minutes: stallMinutes() });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function PUT(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const body = await request.json();
|
|
13
|
+
return NextResponse.json({ minutes: setStallMinutes(body.minutes) });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
16
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -41,6 +41,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
|
|
|
41
41
|
</span>
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
|
+
if (row.status?.stalled)
|
|
45
|
+
return (
|
|
46
|
+
<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">
|
|
47
|
+
Stalled
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
44
50
|
if (row.unread) return <UnreadDot />;
|
|
45
51
|
return null;
|
|
46
52
|
}
|
|
@@ -19,7 +19,7 @@ export function FreshStartForm() {
|
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
21
|
<Form
|
|
22
|
-
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}:${data.autoCompactTokens}`}
|
|
23
23
|
saved={data}
|
|
24
24
|
/>
|
|
25
25
|
);
|
|
@@ -30,12 +30,15 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
30
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
31
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
32
32
|
const compact = useThreshold(saved.compactTokens, 150);
|
|
33
|
-
const
|
|
33
|
+
const midTurn = useThreshold(saved.autoCompactTokens, 300);
|
|
34
|
+
const error =
|
|
35
|
+
orchestrator.error ?? task.error ?? compact.error ?? midTurn.error;
|
|
34
36
|
const dirty =
|
|
35
37
|
!error &&
|
|
36
38
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
37
39
|
task.tokens !== saved.taskTokens ||
|
|
38
|
-
compact.tokens !== saved.compactTokens
|
|
40
|
+
compact.tokens !== saved.compactTokens ||
|
|
41
|
+
midTurn.tokens !== saved.autoCompactTokens);
|
|
39
42
|
|
|
40
43
|
return (
|
|
41
44
|
<form
|
|
@@ -47,6 +50,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
47
50
|
orchestratorTokens: orchestrator.tokens,
|
|
48
51
|
taskTokens: task.tokens,
|
|
49
52
|
compactTokens: compact.tokens,
|
|
53
|
+
autoCompactTokens: midTurn.tokens,
|
|
50
54
|
},
|
|
51
55
|
{
|
|
52
56
|
onSuccess: () => toast.success("Context settings saved"),
|
|
@@ -67,15 +71,23 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
67
71
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
72
|
<h3 className="pt-2 text-sm font-medium">Compact</h3>
|
|
69
73
|
<p className="text-muted-foreground text-sm">
|
|
70
|
-
Chats and tasks compact
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
Chats and tasks compact twice over. Mid-turn, Claude Code summarizes its
|
|
75
|
+
own context when a job runs long: set this high, since it happens while
|
|
76
|
+
the agent is holding its files. Between turns, nothing is in flight, so
|
|
77
|
+
AgentOS compacts a chat over the smaller size, or idle past its prompt
|
|
78
|
+
cache, before the next message; the composer offers to first. Either way
|
|
79
|
+
AgentOS hands the agent its task, scope changes, files and todos back.
|
|
80
|
+
Orchestrators start fresh instead.
|
|
74
81
|
</p>
|
|
75
82
|
<ThresholdRow
|
|
76
|
-
label="
|
|
83
|
+
label="Mid-turn"
|
|
84
|
+
field={midTurn}
|
|
85
|
+
switchLabel="Let the agent compact itself mid-turn"
|
|
86
|
+
/>
|
|
87
|
+
<ThresholdRow
|
|
88
|
+
label="Between turns"
|
|
77
89
|
field={compact}
|
|
78
|
-
switchLabel="Compact chats and tasks
|
|
90
|
+
switchLabel="Compact chats and tasks between turns"
|
|
79
91
|
/>
|
|
80
92
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
81
93
|
<div className="flex justify-end">
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Switch } from "@/components/ui/switch";
|
|
8
|
+
import { useStallQuery, useUpdateStall } from "@/data/stall";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_STALL_MINUTES,
|
|
11
|
+
MAX_STALL_MINUTES,
|
|
12
|
+
MIN_STALL_MINUTES,
|
|
13
|
+
} from "@/lib/chat/stall-limits";
|
|
14
|
+
|
|
15
|
+
// How long a turn may produce nothing before it's flagged stalled and the
|
|
16
|
+
// workspace's orchestrator is told: app-wide.
|
|
17
|
+
export function StallForm() {
|
|
18
|
+
const { data, isPending, isError, error } = useStallQuery();
|
|
19
|
+
if (isPending)
|
|
20
|
+
return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
|
|
21
|
+
if (isError)
|
|
22
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
23
|
+
return <Form key={data.minutes} saved={data.minutes} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Form({ saved }: { saved: number }) {
|
|
27
|
+
const update = useUpdateStall();
|
|
28
|
+
const [on, setOn] = useState(saved > 0);
|
|
29
|
+
const [text, setText] = useState(
|
|
30
|
+
String(saved > 0 ? saved : DEFAULT_STALL_MINUTES)
|
|
31
|
+
);
|
|
32
|
+
const n = Number(text);
|
|
33
|
+
const valid =
|
|
34
|
+
Number.isInteger(n) && n >= MIN_STALL_MINUTES && n <= MAX_STALL_MINUTES;
|
|
35
|
+
const minutes = on ? n : 0;
|
|
36
|
+
const error =
|
|
37
|
+
on && !valid
|
|
38
|
+
? `Between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
|
|
39
|
+
: null;
|
|
40
|
+
const dirty = !error && minutes !== saved;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<form
|
|
44
|
+
onSubmit={(e) => {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
if (error) return;
|
|
47
|
+
update.mutate(minutes, {
|
|
48
|
+
onSuccess: () => toast.success("Stall alert saved"),
|
|
49
|
+
onError: (err) => toast.error(err.message),
|
|
50
|
+
});
|
|
51
|
+
}}
|
|
52
|
+
className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
|
|
53
|
+
>
|
|
54
|
+
<h3 className="text-sm font-medium">Stalled turns</h3>
|
|
55
|
+
<p className="text-muted-foreground text-sm">
|
|
56
|
+
A turn that produces nothing for this long (no text, tool call or
|
|
57
|
+
result) shows as stalled, and the workspace's orchestrator is told
|
|
58
|
+
once. A long command that is still printing, or a background command the
|
|
59
|
+
agent is waiting on, isn't a stall.
|
|
60
|
+
</p>
|
|
61
|
+
<label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
|
|
62
|
+
Flag stalled turns
|
|
63
|
+
<Switch
|
|
64
|
+
checked={on}
|
|
65
|
+
onCheckedChange={setOn}
|
|
66
|
+
aria-label="Flag stalled turns"
|
|
67
|
+
/>
|
|
68
|
+
</label>
|
|
69
|
+
{on && (
|
|
70
|
+
<label className="flex items-center justify-between gap-3 text-sm">
|
|
71
|
+
<span className="text-muted-foreground">After</span>
|
|
72
|
+
<span className="flex items-center gap-2">
|
|
73
|
+
<Input
|
|
74
|
+
type="number"
|
|
75
|
+
inputMode="numeric"
|
|
76
|
+
min={MIN_STALL_MINUTES}
|
|
77
|
+
max={MAX_STALL_MINUTES}
|
|
78
|
+
value={text}
|
|
79
|
+
onChange={(e) => setText(e.target.value)}
|
|
80
|
+
aria-label="Stall threshold, minutes"
|
|
81
|
+
aria-invalid={!!error}
|
|
82
|
+
className="h-11 w-24 text-base sm:h-9 md:text-sm"
|
|
83
|
+
/>
|
|
84
|
+
<span className="text-muted-foreground">minutes</span>
|
|
85
|
+
</span>
|
|
86
|
+
</label>
|
|
87
|
+
)}
|
|
88
|
+
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
89
|
+
<div className="flex justify-end">
|
|
90
|
+
<Button
|
|
91
|
+
type="submit"
|
|
92
|
+
size="sm"
|
|
93
|
+
className="h-11 sm:h-8"
|
|
94
|
+
disabled={!!error || !dirty || update.isPending}
|
|
95
|
+
>
|
|
96
|
+
Save
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
</form>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
|
5
5
|
import { TaskLimitForm } from "./TaskLimitForm";
|
|
6
6
|
import { RulesForm } from "./RulesForm";
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
|
+
import { StallForm } from "./StallForm";
|
|
8
9
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
9
10
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
10
11
|
|
|
@@ -53,6 +54,7 @@ export function WorkspaceSettings() {
|
|
|
53
54
|
</section>
|
|
54
55
|
))}
|
|
55
56
|
<FreshStartForm />
|
|
57
|
+
<StallForm />
|
|
56
58
|
<AwayWindowForm />
|
|
57
59
|
</div>
|
|
58
60
|
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
// How long a turn may be silent before it's stalled (lib/chat/stall).
|
|
4
|
+
export const stallKeys = { all: ["stall"] as const };
|
|
5
|
+
|
|
6
|
+
async function json<T>(res: Response): Promise<T> {
|
|
7
|
+
const data = await res.json().catch(() => ({}));
|
|
8
|
+
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
|
9
|
+
return data;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useStallQuery() {
|
|
13
|
+
return useQuery({
|
|
14
|
+
queryKey: stallKeys.all,
|
|
15
|
+
queryFn: async () => json<{ minutes: number }>(await fetch("/api/stall")),
|
|
16
|
+
staleTime: 60000,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useUpdateStall() {
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
return useMutation({
|
|
23
|
+
mutationFn: async (minutes: number) =>
|
|
24
|
+
json<{ minutes: number }>(
|
|
25
|
+
await fetch("/api/stall", {
|
|
26
|
+
method: "PUT",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ minutes }),
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
onSuccess: (data) => queryClient.setQueryData(stallKeys.all, data),
|
|
32
|
+
});
|
|
33
|
+
}
|
package/lib/chat/activity.ts
CHANGED
|
@@ -1,23 +1,48 @@
|
|
|
1
|
-
import type { ChatState } from "./events";
|
|
1
|
+
import type { ChatItem, ChatState } from "./events";
|
|
2
2
|
import { formatElapsed } from "./elapsed";
|
|
3
3
|
import { registry, type Live } from "./registry";
|
|
4
|
+
import { stallThresholdMs } from "./stall";
|
|
4
5
|
import { listItems } from "./store";
|
|
5
6
|
import type { WorkerEvent } from "./worker/protocol";
|
|
6
7
|
|
|
8
|
+
const lastWhat = (i: ChatItem): string =>
|
|
9
|
+
i.kind === "tool"
|
|
10
|
+
? i.status === "running"
|
|
11
|
+
? i.title
|
|
12
|
+
: `finished ${i.title}`
|
|
13
|
+
: i.kind === "assistant"
|
|
14
|
+
? "writing a reply"
|
|
15
|
+
: i.kind === "reasoning"
|
|
16
|
+
? "thinking"
|
|
17
|
+
: i.kind === "user"
|
|
18
|
+
? "your message"
|
|
19
|
+
: i.kind;
|
|
20
|
+
|
|
7
21
|
// Keeps a conversation's "what it's doing now" current from its events.
|
|
8
22
|
export function track(live: Live, e: WorkerEvent): void {
|
|
9
23
|
const a = live.activity;
|
|
10
24
|
if (e.type === "state") {
|
|
11
|
-
if (e.state === "running" && !a.turnStartedAt)
|
|
25
|
+
if (e.state === "running" && !a.turnStartedAt) {
|
|
26
|
+
a.turnStartedAt = Date.now();
|
|
27
|
+
a.lastAt = a.turnStartedAt;
|
|
28
|
+
a.lastWhat = "starting the turn";
|
|
29
|
+
}
|
|
12
30
|
if (e.state === "idle") {
|
|
13
31
|
a.turnStartedAt = undefined;
|
|
32
|
+
a.lastAt = a.lastWhat = undefined;
|
|
14
33
|
a.tools.clear();
|
|
15
34
|
}
|
|
16
35
|
} else if (e.type === "item" && e.item.kind === "turn_end") {
|
|
17
36
|
// A queued message starts the next turn with no idle in between.
|
|
18
37
|
a.turnStartedAt = undefined;
|
|
38
|
+
a.lastAt = a.lastWhat = undefined;
|
|
19
39
|
a.tools.clear();
|
|
20
|
-
} else if (e.type === "item"
|
|
40
|
+
} else if (e.type === "item" || e.type === "delta") {
|
|
41
|
+
a.lastAt = Date.now();
|
|
42
|
+
a.lastWhat = e.type === "delta" ? "writing" : lastWhat(e.item);
|
|
43
|
+
}
|
|
44
|
+
if (e.type === "item" && e.item.kind === "user") a.sends = (a.sends ?? 0) + 1;
|
|
45
|
+
if (e.type === "item" && e.item.kind === "tool") {
|
|
21
46
|
if (e.item.status === "running")
|
|
22
47
|
a.tools.set(e.item.id, { label: e.item.title, since: e.item.createdAt });
|
|
23
48
|
else a.tools.delete(e.item.id);
|
|
@@ -35,6 +60,11 @@ export function restoreActivity(sessionId: string, state: ChatState) {
|
|
|
35
60
|
activity.turnStartedAt = items.findLast(
|
|
36
61
|
(i) => i.kind === "user"
|
|
37
62
|
)?.createdAt;
|
|
63
|
+
if (state !== "idle") {
|
|
64
|
+
const last = items.at(-1);
|
|
65
|
+
activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
|
|
66
|
+
activity.lastWhat = last ? lastWhat(last) : undefined;
|
|
67
|
+
}
|
|
38
68
|
for (const i of items) {
|
|
39
69
|
if (i.kind === "tool" && i.status === "running" && state !== "idle")
|
|
40
70
|
activity.tools.set(i.id, { label: i.title, since: i.createdAt });
|
|
@@ -44,11 +74,50 @@ export function restoreActivity(sessionId: string, state: ChatState) {
|
|
|
44
74
|
return activity;
|
|
45
75
|
}
|
|
46
76
|
|
|
77
|
+
export interface Stall {
|
|
78
|
+
silentMs: number;
|
|
79
|
+
// What it last did.
|
|
80
|
+
last: string;
|
|
81
|
+
// When that was: one stall per value, so a condition keyed on it fires
|
|
82
|
+
// once and a new one starts only after activity resumes.
|
|
83
|
+
since: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A turn that has produced nothing (no token, item or result) for the
|
|
87
|
+
// threshold. Never one waiting on an answer, nor one with a background task
|
|
88
|
+
// running: a command it started can legitimately be quiet.
|
|
89
|
+
const TOOL_STALL_FACTOR = 4;
|
|
90
|
+
|
|
91
|
+
export function stallOf(
|
|
92
|
+
live: Live,
|
|
93
|
+
now: number,
|
|
94
|
+
thresholdMs: number
|
|
95
|
+
): Stall | null {
|
|
96
|
+
const a = live.activity;
|
|
97
|
+
if (live.state !== "running" || thresholdMs <= 0 || a.tasks.size > 0)
|
|
98
|
+
return null;
|
|
99
|
+
// A foreground tool call (a long test run) says nothing while it works:
|
|
100
|
+
// it gets a much longer allowance before it counts as stuck.
|
|
101
|
+
if (a.tools.size > 0) thresholdMs *= TOOL_STALL_FACTOR;
|
|
102
|
+
const since = a.lastAt ?? a.turnStartedAt;
|
|
103
|
+
if (!since || now - since < thresholdMs) return null;
|
|
104
|
+
return { silentMs: now - since, last: a.lastWhat ?? "unknown", since };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Whether a chat on this machine is stalled now, by the app-wide threshold.
|
|
108
|
+
export function chatStall(sessionId: string): Stall | null {
|
|
109
|
+
const live = registry.live.get(sessionId);
|
|
110
|
+
return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
47
113
|
// One line on what a chat session is doing, for the session list and
|
|
48
114
|
// notifications: the step running now and since when.
|
|
49
|
-
export function chatActivity(
|
|
50
|
-
|
|
51
|
-
|
|
115
|
+
export function chatActivity(sessionId: string): {
|
|
116
|
+
label: string;
|
|
117
|
+
since?: number;
|
|
118
|
+
background: number;
|
|
119
|
+
stalled?: Stall;
|
|
120
|
+
} | null {
|
|
52
121
|
const live = registry.live.get(sessionId);
|
|
53
122
|
if (!live) return null;
|
|
54
123
|
const a = live.activity;
|
|
@@ -57,6 +126,9 @@ export function chatActivity(
|
|
|
57
126
|
return { label: "Needs your answer", background };
|
|
58
127
|
if (live.state !== "running")
|
|
59
128
|
return background ? { label: "Idle", background } : null;
|
|
129
|
+
const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
|
|
130
|
+
if (stalled)
|
|
131
|
+
return { label: "Stalled", since: stalled.since, background, stalled };
|
|
60
132
|
const tool = [...a.tools.values()].at(-1);
|
|
61
133
|
return tool
|
|
62
134
|
? { label: tool.label, since: tool.since, background }
|
|
@@ -71,6 +143,8 @@ export function chatActivityLine(sessionId: string): string | null {
|
|
|
71
143
|
? `${a.background} running in background`
|
|
72
144
|
: "";
|
|
73
145
|
if (a.label === "Idle") return background || null;
|
|
146
|
+
if (a.stalled)
|
|
147
|
+
return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
|
|
74
148
|
const parts = [a.label];
|
|
75
149
|
if (a.since) parts.push(formatElapsed(Date.now() - a.since));
|
|
76
150
|
if (background) parts.push(background);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The note an agent gets back right after a compaction, gathered from
|
|
3
|
+
* AgentOS's records (lib/chat/working-state has the rules). From the
|
|
4
|
+
* database each time rather than tracked in memory, so it is right after a
|
|
5
|
+
* deploy restarted the worker too.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { statusPorcelain } from "../git-exec";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { amendmentsOf } from "../orchestrator/amendments";
|
|
12
|
+
import { lastItems } from "./store";
|
|
13
|
+
import { workingStateNote } from "./working-state";
|
|
14
|
+
|
|
15
|
+
async function gitStatus(cwd: string): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
const out = await statusPorcelain(cwd, {
|
|
18
|
+
timeout: 3_000,
|
|
19
|
+
maxBuffer: 64 * 1024,
|
|
20
|
+
});
|
|
21
|
+
return out.split("\n").slice(0, 40).join("\n");
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Empty when the session is gone or there is nothing to say.
|
|
28
|
+
export async function compactContext(sessionId: string): Promise<string> {
|
|
29
|
+
const session = db
|
|
30
|
+
.prepare(
|
|
31
|
+
`SELECT working_directory, task_prompt, role FROM sessions WHERE id = ?`
|
|
32
|
+
)
|
|
33
|
+
.get(sessionId) as
|
|
34
|
+
| Pick<Session, "working_directory" | "task_prompt" | "role">
|
|
35
|
+
| undefined;
|
|
36
|
+
// An orchestrator starts fresh instead of compacting.
|
|
37
|
+
if (!session || session.role === "orchestrator") return "";
|
|
38
|
+
const tools = lastItems(sessionId, 400, "tool").filter(
|
|
39
|
+
(i) => i.kind === "tool"
|
|
40
|
+
);
|
|
41
|
+
const todos = lastItems(sessionId, 1, "todos").find(
|
|
42
|
+
(i) => i.kind === "todos"
|
|
43
|
+
);
|
|
44
|
+
const user = lastItems(sessionId, 1, "user").find((i) => i.kind === "user");
|
|
45
|
+
return workingStateNote({
|
|
46
|
+
taskPrompt: session.task_prompt,
|
|
47
|
+
amendments: amendmentsOf(sessionId).map((a) => a.text),
|
|
48
|
+
tools,
|
|
49
|
+
todos: todos?.todos,
|
|
50
|
+
requests: user ? [user.text] : [],
|
|
51
|
+
gitStatus: await gitStatus(
|
|
52
|
+
session.working_directory.replace(/^~/, os.homedir())
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
}
|
package/lib/chat/driver.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface ChatStartOptions {
|
|
|
41
41
|
// Checked before every tool call, whatever the permissions say: why it's
|
|
42
42
|
// refused, or null to let it go on to them.
|
|
43
43
|
toolGuard?: ToolGuard;
|
|
44
|
+
// The note handed back to the agent right after its context is compacted
|
|
45
|
+
// (lib/chat/compact-context), empty for none. Only Claude Code has a hook
|
|
46
|
+
// for it; other drivers ignore it.
|
|
47
|
+
compactContext?: () => Promise<string>;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export type ToolGuard = (
|
|
@@ -275,3 +275,35 @@ export function preToolUseHooks(
|
|
|
275
275
|
...(guard ? [{ hooks: [guardHook(guard)] }] : []),
|
|
276
276
|
];
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
// After a compaction Claude Code starts the session again with source
|
|
280
|
+
// "compact", and what a SessionStart hook returns as additionalContext is
|
|
281
|
+
// added to the context: the one way to hand the agent something back that
|
|
282
|
+
// doesn't depend on what its summary kept. A failure here never blocks it.
|
|
283
|
+
export function compactedHooks(
|
|
284
|
+
context: () => Promise<string>
|
|
285
|
+
): HookCallbackMatcher[] {
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
matcher: "compact",
|
|
289
|
+
hooks: [
|
|
290
|
+
async () => {
|
|
291
|
+
try {
|
|
292
|
+
const additionalContext = await context();
|
|
293
|
+
return additionalContext
|
|
294
|
+
? {
|
|
295
|
+
hookSpecificOutput: {
|
|
296
|
+
hookEventName: "SessionStart",
|
|
297
|
+
additionalContext,
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
: {};
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error("building the compaction note:", e);
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
|
|
|
10
10
|
import { InputQueue } from "../queue";
|
|
11
11
|
import {
|
|
12
12
|
Approvals,
|
|
13
|
+
compactedHooks,
|
|
13
14
|
isPlanFile,
|
|
14
15
|
preToolUseHooks,
|
|
15
16
|
sdkMode,
|
|
@@ -193,6 +194,9 @@ export const claudeDriver: ChatDriver = {
|
|
|
193
194
|
disallowedTools: options.disallowedTools,
|
|
194
195
|
hooks: {
|
|
195
196
|
PreToolUse: preToolUseHooks(approvals, options.toolGuard),
|
|
197
|
+
...(options.compactContext && {
|
|
198
|
+
SessionStart: compactedHooks(options.compactContext),
|
|
199
|
+
}),
|
|
196
200
|
},
|
|
197
201
|
settings: { attribution: NO_ATTRIBUTION },
|
|
198
202
|
enableFileCheckpointing: true,
|
package/lib/chat/events.ts
CHANGED
|
@@ -354,7 +354,7 @@ export type ChatClientMessage =
|
|
|
354
354
|
// `during`: the user message whose turn was running when it was tapped.
|
|
355
355
|
| { type: "queue_send_now"; id: string; during?: string }
|
|
356
356
|
| { type: "files"; reqId: string; query: string }
|
|
357
|
-
| { type: "interrupt" }
|
|
357
|
+
| { type: "interrupt"; hard?: boolean }
|
|
358
358
|
| { type: "set_model"; model: string }
|
|
359
359
|
| { type: "set_access"; access: ChatAccess }
|
|
360
360
|
| { type: "set_plan"; plan: boolean }
|