@saadnvd1/agent-os 0.3.464 → 0.3.466
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 +20 -0
- package/app/api/fresh-start/route.ts +5 -3
- package/app/api/git/commit/route.ts +4 -3
- package/app/api/git/file-content/route.ts +4 -15
- package/app/api/git/history/[hash]/diff/route.ts +1 -1
- package/app/api/git/history/[hash]/route.ts +1 -1
- package/app/api/git/history/route.ts +1 -1
- package/app/api/git/push/route.ts +2 -2
- package/app/api/sessions/[id]/pr/route.ts +2 -2
- package/components/Chat/ChatPanel.tsx +23 -1
- package/components/Chat/Messages.tsx +2 -3
- package/components/Chat/StaleChip.tsx +55 -0
- package/components/Workspaces/FreshStartForm.tsx +29 -6
- package/data/chat/useChat.ts +10 -2
- package/lib/away/deploy.ts +3 -7
- package/lib/away/run.ts +6 -5
- package/lib/build.ts +2 -6
- package/lib/bus/index.ts +3 -0
- package/lib/chat/client-send.ts +11 -3
- package/lib/chat/drivers/claude-mapper.ts +11 -1
- package/lib/chat/drivers/claude.ts +9 -1
- package/lib/chat/events.ts +14 -1
- package/lib/chat/files.ts +31 -9
- package/lib/chat/refresh.ts +14 -2
- package/lib/chat/runner.ts +8 -1
- package/lib/chat/stale.ts +95 -0
- package/lib/chat/worker/host.ts +133 -4
- package/lib/chat/worker/protocol.ts +3 -0
- package/lib/done/remote.ts +5 -6
- package/lib/done/testing.ts +2 -7
- package/lib/done/worktree.ts +2 -2
- package/lib/git-exec.ts +334 -0
- package/lib/git-history.ts +29 -41
- package/lib/git-status.ts +45 -124
- package/lib/git.ts +44 -159
- package/lib/lumifyhub/publish.ts +2 -2
- package/lib/orchestrator/deliver.ts +9 -1
- package/lib/orchestrator/diff.ts +15 -13
- package/lib/orchestrator/rebase.ts +20 -24
- package/lib/orchestrator/repo-slug.ts +4 -7
- package/lib/orchestrator/repo.ts +24 -35
- package/lib/pr-generation.ts +2 -1
- package/lib/pr.ts +16 -57
- package/lib/schedules/start.ts +1 -0
- package/lib/sessions/worktree-setup.ts +17 -13
- package/lib/stacks/start.ts +7 -11
- package/lib/tasks/branch-worktree.ts +3 -2
- package/lib/tasks/finish.ts +5 -6
- package/lib/tasks/gh.ts +5 -0
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +3 -2
- package/lib/tasks/project-ref.ts +12 -19
- package/lib/tasks/session.ts +5 -5
- package/lib/worktree-hooks.ts +3 -7
- package/lib/worktree-placed.ts +3 -10
- package/lib/worktrees.ts +4 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -311,6 +311,19 @@ mode and starts it, **Keep planning** goes back to the composer.
|
|
|
311
311
|
The ring in the tab bar (in the composer on a phone) is how full the
|
|
312
312
|
conversation's context is, measured against the room before auto-compact:
|
|
313
313
|
amber past 60%, red past 85%. Tap it for the breakdown and **Compact now**.
|
|
314
|
+
|
|
315
|
+
A message to a chat idle past its prompt cache (5 minutes or an hour, read
|
|
316
|
+
from the agent's own usage) re-reads the whole conversation at the
|
|
317
|
+
cache-write price. When the context is also above a threshold (150k tokens by
|
|
318
|
+
default, Settings → Workspaces → Compact idle chats; off sets it to never), a
|
|
319
|
+
chip above the composer says "Idle 47m · 412k context · Compact first?": Send
|
|
320
|
+
compacts and then sends, **Send as-is** skips it for that one message.
|
|
321
|
+
Messages nobody is typing (the orchestrator's `send`, AgentOS events and
|
|
322
|
+
wakes, `aos send`, schedules) compact first on their own, only between turns,
|
|
323
|
+
and the chat shows one quiet line with the context before and after. An
|
|
324
|
+
orchestrator isn't compacted (a summary would lose the fence around what it
|
|
325
|
+
read from tasks and CI): it starts a fresh conversation from its brief and
|
|
326
|
+
the current state instead, as at its fresh-start threshold.
|
|
314
327
|
On a desktop browser the screen button attaches a screenshot of a screen or
|
|
315
328
|
window you pick; on a phone, attach a screenshot as a photo.
|
|
316
329
|
|
|
@@ -1245,6 +1258,13 @@ One Node process serves every terminal, chat stream and request, so a route
|
|
|
1245
1258
|
handler in `app/api/` may not call a `*Sync` child_process or fs function:
|
|
1246
1259
|
ESLint refuses it. Use the async form with a timeout.
|
|
1247
1260
|
|
|
1261
|
+
Every git process starts in `lib/git-exec.ts`: `git()`, `gitResult()`,
|
|
1262
|
+
`gitOk()` and named operations (`revParse`, `worktreeAdd`, `gitPush`, …). It
|
|
1263
|
+
sets one timeout, kills a hung git, never lets git prompt, keeps the server's
|
|
1264
|
+
tmux out of hooks and rejects with a `GitError` carrying the exit code and
|
|
1265
|
+
stderr. ESLint refuses git spawned anywhere else in `lib/`, `app/` or
|
|
1266
|
+
`server.ts`.
|
|
1267
|
+
|
|
1248
1268
|
`scripts/redeploy` (and `scripts/autodeploy`, which calls it) builds into
|
|
1249
1269
|
`.next-build` while the live server keeps serving `.next`. The build is swapped
|
|
1250
1270
|
in by rename only once it finished with a `BUILD_ID`, right before the restart.
|
|
@@ -2,15 +2,17 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
|
|
3
3
|
|
|
4
4
|
// When a chat's conversation starts over (lib/chat/refresh): context
|
|
5
|
-
// tokens for orchestrators and for tasks,
|
|
5
|
+
// tokens for orchestrators and for tasks, and above which a stale chat is
|
|
6
|
+
// compacted before a message (lib/chat/stale); 0 for never.
|
|
6
7
|
export async function GET() {
|
|
7
8
|
return NextResponse.json(refreshSettings());
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export async function PUT(request: NextRequest) {
|
|
11
12
|
try {
|
|
12
|
-
const { orchestratorTokens, taskTokens } =
|
|
13
|
-
|
|
13
|
+
const { orchestratorTokens, taskTokens, staleCompactTokens } =
|
|
14
|
+
await request.json();
|
|
15
|
+
setRefreshSettings({ orchestratorTokens, taskTokens, staleCompactTokens });
|
|
14
16
|
return NextResponse.json(refreshSettings());
|
|
15
17
|
} catch (error) {
|
|
16
18
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
|
|
45
45
|
|
|
46
46
|
if (
|
|
47
47
|
branchName &&
|
|
48
|
-
(typeof branchName !== "string" ||
|
|
48
|
+
(typeof branchName !== "string" ||
|
|
49
|
+
!(await isValidBranchName(path, branchName)))
|
|
49
50
|
) {
|
|
50
51
|
return NextResponse.json(
|
|
51
52
|
{ error: "Invalid branch name" },
|
|
@@ -64,8 +65,8 @@ export async function POST(request: NextRequest) {
|
|
|
64
65
|
|
|
65
66
|
// Create new branch if on main/master and branch name provided
|
|
66
67
|
let newBranch = false;
|
|
67
|
-
if (branchName && isMainBranch(path)) {
|
|
68
|
-
createBranch(path, branchName);
|
|
68
|
+
if (branchName && (await isMainBranch(path))) {
|
|
69
|
+
await createBranch(path, branchName);
|
|
69
70
|
newBranch = true;
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
import { promisify } from "util";
|
|
2
|
+
import { git } from "@/lib/git-exec";
|
|
4
3
|
import { expandPath } from "@/lib/git-status";
|
|
5
4
|
|
|
6
|
-
const execFileAsync = promisify(execFile);
|
|
7
|
-
|
|
8
5
|
/**
|
|
9
6
|
* GET /api/git/file-content?path=...&file=...
|
|
10
7
|
* Get file content from git HEAD (original version before changes)
|
|
@@ -33,17 +30,9 @@ export async function GET(request: NextRequest) {
|
|
|
33
30
|
|
|
34
31
|
try {
|
|
35
32
|
// Get file content from HEAD
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
cwd: expandedPath,
|
|
41
|
-
encoding: "utf-8",
|
|
42
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
43
|
-
timeout: 30_000,
|
|
44
|
-
killSignal: "SIGKILL",
|
|
45
|
-
}
|
|
46
|
-
);
|
|
33
|
+
const content = await git(expandedPath, ["show", `HEAD:${file}`], {
|
|
34
|
+
timeout: 30_000,
|
|
35
|
+
});
|
|
47
36
|
|
|
48
37
|
return NextResponse.json({ content });
|
|
49
38
|
} catch (gitError: unknown) {
|
|
@@ -31,7 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const diff = getCommitFileDiff(path, hash, file);
|
|
34
|
+
const diff = await getCommitFileDiff(path, hash, file);
|
|
35
35
|
return NextResponse.json({ diff });
|
|
36
36
|
} catch (error) {
|
|
37
37
|
console.error("Error getting commit file diff:", error);
|
|
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const commit = getCommitDetail(path, hash);
|
|
26
|
+
const commit = await getCommitDetail(path, hash);
|
|
27
27
|
if (!commit) {
|
|
28
28
|
return NextResponse.json({ error: "Commit not found" }, { status: 404 });
|
|
29
29
|
}
|
|
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
|
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const commits = getCommitHistory(path, limit);
|
|
18
|
+
const commits = await getCommitHistory(path, limit);
|
|
19
19
|
return NextResponse.json({ commits });
|
|
20
20
|
} catch (error) {
|
|
21
21
|
console.error("Error getting commit history:", error);
|
|
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Check if remote exists
|
|
31
|
-
const remoteUrl = getRemoteUrl(path);
|
|
31
|
+
const remoteUrl = await getRemoteUrl(path);
|
|
32
32
|
if (!remoteUrl) {
|
|
33
33
|
return NextResponse.json(
|
|
34
34
|
{ error: "No remote origin configured" },
|
|
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Push (set upstream if needed)
|
|
51
|
-
const needsUpstream = !hasUpstream(path);
|
|
51
|
+
const needsUpstream = !(await hasUpstream(path));
|
|
52
52
|
const output = await push(path, needsUpstream);
|
|
53
53
|
|
|
54
54
|
forgetGitStatus(path);
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { execFile } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
4
|
import { getDb, queries, type Session } from "@/lib/db";
|
|
5
|
+
import { gitPush } from "@/lib/git-exec";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
@@ -69,8 +70,7 @@ async function createPR(
|
|
|
69
70
|
): Promise<PRInfo> {
|
|
70
71
|
// First push the branch if not already pushed
|
|
71
72
|
try {
|
|
72
|
-
await
|
|
73
|
-
cwd: projectPath,
|
|
73
|
+
await gitPush(projectPath, ["-u", "origin", "--", branchName], {
|
|
74
74
|
timeout: 30000,
|
|
75
75
|
});
|
|
76
76
|
} catch {
|
|
@@ -26,6 +26,9 @@ import { SubagentCard } from "./Subagent";
|
|
|
26
26
|
import { UndoDialog, UndoneBlock } from "./Undo";
|
|
27
27
|
import { Composer } from "./Composer";
|
|
28
28
|
import { Queue } from "./Queue";
|
|
29
|
+
import { StaleChip } from "./StaleChip";
|
|
30
|
+
import { composerCompacts } from "@/lib/chat/stale";
|
|
31
|
+
import { useFreshStartQuery } from "@/data/fresh-start";
|
|
29
32
|
import { QuoteButton } from "./QuoteButton";
|
|
30
33
|
import { ToolBodies, ToolGroup } from "./Tools";
|
|
31
34
|
import { RowState, RowStore } from "./rowState";
|
|
@@ -282,6 +285,12 @@ export function ChatPanel({
|
|
|
282
285
|
toEnd();
|
|
283
286
|
send(text);
|
|
284
287
|
};
|
|
288
|
+
// A stale chat compacts before the next message unless the reader picks
|
|
289
|
+
// "Send as-is", for that send only.
|
|
290
|
+
const staleCompactTokens = useFreshStartQuery().data?.staleCompactTokens ?? 0;
|
|
291
|
+
const [asIsFor, setAsIsFor] = useState<string | null>(null);
|
|
292
|
+
const asIs = asIsFor === sessionId;
|
|
293
|
+
const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
|
|
285
294
|
|
|
286
295
|
// Shift+Tab switches plan mode from the composer, as in the terminal.
|
|
287
296
|
const shiftTab = useRef(togglePlan);
|
|
@@ -517,6 +526,13 @@ export function ChatPanel({
|
|
|
517
526
|
)
|
|
518
527
|
}
|
|
519
528
|
/>
|
|
529
|
+
<StaleChip
|
|
530
|
+
context={context}
|
|
531
|
+
state={state}
|
|
532
|
+
thresholdTokens={staleCompactTokens}
|
|
533
|
+
asIs={asIs}
|
|
534
|
+
onAsIs={setAsIs}
|
|
535
|
+
/>
|
|
520
536
|
<Composer
|
|
521
537
|
draftKey={sessionId}
|
|
522
538
|
suggestion={shownSuggestion}
|
|
@@ -529,7 +545,13 @@ export function ChatPanel({
|
|
|
529
545
|
placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
|
|
530
546
|
onSend={(text, images) => {
|
|
531
547
|
toEnd();
|
|
532
|
-
|
|
548
|
+
const compact = composerCompacts(text, context, {
|
|
549
|
+
state,
|
|
550
|
+
thresholdTokens: staleCompactTokens,
|
|
551
|
+
asIs,
|
|
552
|
+
});
|
|
553
|
+
setAsIs(false);
|
|
554
|
+
send(text, images.length ? images : undefined, compact);
|
|
533
555
|
}}
|
|
534
556
|
onStop={interrupt}
|
|
535
557
|
commands={commands}
|
|
@@ -4,6 +4,7 @@ import { Check, ChevronRight, Circle, CircleDot, Sparkles } from "lucide-react";
|
|
|
4
4
|
import type { ChatItem } from "@/lib/chat/events";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { leadingCommand } from "@/lib/chat/commands";
|
|
7
|
+
import { compactedLine } from "@/lib/chat/stale";
|
|
7
8
|
import { CopyButton } from "./CopyButton";
|
|
8
9
|
import { ImageThumb } from "./ImageThumb";
|
|
9
10
|
import { Markdown } from "./Markdown";
|
|
@@ -184,9 +185,7 @@ export function Compacted({ item }: { item: Of<"compacted"> }) {
|
|
|
184
185
|
return (
|
|
185
186
|
<div className="text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
186
187
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
187
|
-
{item
|
|
188
|
-
? "Context compacted automatically"
|
|
189
|
-
: "Context compacted"}
|
|
188
|
+
{compactedLine(item)}
|
|
190
189
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
191
190
|
</div>
|
|
192
191
|
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Minimize2 } from "lucide-react";
|
|
4
|
+
import type { ChatContext, ChatState } from "@/lib/chat/events";
|
|
5
|
+
import { staleLabel, staleness } from "@/lib/chat/stale";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { useNow } from "./useNow";
|
|
8
|
+
|
|
9
|
+
// Above the composer while the chat is stale (lib/chat/stale): its prompt
|
|
10
|
+
// cache has expired and its context is long, so the next message would
|
|
11
|
+
// re-read all of it at full price. Send compacts first unless the reader
|
|
12
|
+
// picks "Send as-is", for that one send only.
|
|
13
|
+
export function StaleChip({
|
|
14
|
+
context,
|
|
15
|
+
state,
|
|
16
|
+
thresholdTokens,
|
|
17
|
+
asIs,
|
|
18
|
+
onAsIs,
|
|
19
|
+
}: {
|
|
20
|
+
context: ChatContext | null;
|
|
21
|
+
state: ChatState;
|
|
22
|
+
thresholdTokens: number;
|
|
23
|
+
asIs: boolean;
|
|
24
|
+
onAsIs: (asIs: boolean) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const watching = !!context?.cacheTtlMs && thresholdTokens > 0;
|
|
27
|
+
const now = useNow(watching);
|
|
28
|
+
const stale = staleness(context, { state, thresholdTokens, now });
|
|
29
|
+
if (!stale) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role="status"
|
|
33
|
+
className="bg-card popover-surface mb-2 flex items-center gap-2 rounded-2xl py-1 pr-1 pl-3 text-xs"
|
|
34
|
+
>
|
|
35
|
+
<Minimize2 className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
|
36
|
+
<span className="text-muted-foreground min-w-0 flex-1 truncate">
|
|
37
|
+
{staleLabel(stale)} ·{" "}
|
|
38
|
+
<span className="text-foreground">
|
|
39
|
+
{asIs ? "Sending as-is" : "Compact first?"}
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-pressed={asIs}
|
|
45
|
+
onClick={() => onAsIs(!asIs)}
|
|
46
|
+
className={cn(
|
|
47
|
+
"hover:bg-foreground/[0.06] min-h-11 shrink-0 rounded-xl px-3 font-medium md:min-h-8",
|
|
48
|
+
asIs && "text-primary"
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{asIs ? "Compact first" : "Send as-is"}
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -18,7 +18,10 @@ export function FreshStartForm() {
|
|
|
18
18
|
if (isError)
|
|
19
19
|
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
20
20
|
return (
|
|
21
|
-
<Form
|
|
21
|
+
<Form
|
|
22
|
+
key={`${data.orchestratorTokens}:${data.taskTokens}:${data.staleCompactTokens}`}
|
|
23
|
+
saved={data}
|
|
24
|
+
/>
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -26,11 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
26
29
|
const update = useUpdateFreshStart();
|
|
27
30
|
const orchestrator = useThreshold(saved.orchestratorTokens, 200);
|
|
28
31
|
const task = useThreshold(saved.taskTokens, 200);
|
|
29
|
-
const
|
|
32
|
+
const stale = useThreshold(saved.staleCompactTokens, 150);
|
|
33
|
+
const error = orchestrator.error ?? task.error ?? stale.error;
|
|
30
34
|
const dirty =
|
|
31
35
|
!error &&
|
|
32
36
|
(orchestrator.tokens !== saved.orchestratorTokens ||
|
|
33
|
-
task.tokens !== saved.taskTokens
|
|
37
|
+
task.tokens !== saved.taskTokens ||
|
|
38
|
+
stale.tokens !== saved.staleCompactTokens);
|
|
34
39
|
|
|
35
40
|
return (
|
|
36
41
|
<form
|
|
@@ -38,9 +43,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
38
43
|
e.preventDefault();
|
|
39
44
|
if (error) return;
|
|
40
45
|
update.mutate(
|
|
41
|
-
{ orchestratorTokens: orchestrator.tokens, taskTokens: task.tokens },
|
|
42
46
|
{
|
|
43
|
-
|
|
47
|
+
orchestratorTokens: orchestrator.tokens,
|
|
48
|
+
taskTokens: task.tokens,
|
|
49
|
+
staleCompactTokens: stale.tokens,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
onSuccess: () => toast.success("Context settings saved"),
|
|
44
53
|
onError: (err) => toast.error(err.message),
|
|
45
54
|
}
|
|
46
55
|
);
|
|
@@ -56,6 +65,18 @@ function Form({ saved }: { saved: RefreshSettings }) {
|
|
|
56
65
|
</p>
|
|
57
66
|
<ThresholdRow label="Orchestrators" field={orchestrator} />
|
|
58
67
|
<ThresholdRow label="Tasks" field={task} />
|
|
68
|
+
<h3 className="pt-2 text-sm font-medium">Compact idle chats</h3>
|
|
69
|
+
<p className="text-muted-foreground text-sm">
|
|
70
|
+
A message to a chat idle past its prompt cache re-reads the whole
|
|
71
|
+
conversation at full price. Above this size, messages from the
|
|
72
|
+
orchestrator, events and schedules compact it first; the composer offers
|
|
73
|
+
to.
|
|
74
|
+
</p>
|
|
75
|
+
<ThresholdRow
|
|
76
|
+
label="Idle chats"
|
|
77
|
+
field={stale}
|
|
78
|
+
switchLabel="Compact idle chats before a message"
|
|
79
|
+
/>
|
|
59
80
|
{error && <p className="text-destructive text-xs">{error}</p>}
|
|
60
81
|
<div className="flex justify-end">
|
|
61
82
|
<Button
|
|
@@ -90,9 +111,11 @@ function useThreshold(tokens: number, fallbackK: number) {
|
|
|
90
111
|
function ThresholdRow({
|
|
91
112
|
label,
|
|
92
113
|
field,
|
|
114
|
+
switchLabel,
|
|
93
115
|
}: {
|
|
94
116
|
label: string;
|
|
95
117
|
field: ReturnType<typeof useThreshold>;
|
|
118
|
+
switchLabel?: string;
|
|
96
119
|
}) {
|
|
97
120
|
return (
|
|
98
121
|
<div className="space-y-2">
|
|
@@ -101,7 +124,7 @@ function ThresholdRow({
|
|
|
101
124
|
<Switch
|
|
102
125
|
checked={field.on}
|
|
103
126
|
onCheckedChange={field.setOn}
|
|
104
|
-
aria-label={`Fresh starts for ${label.toLowerCase()}`}
|
|
127
|
+
aria-label={switchLabel ?? `Fresh starts for ${label.toLowerCase()}`}
|
|
105
128
|
/>
|
|
106
129
|
</label>
|
|
107
130
|
{field.on && (
|
package/data/chat/useChat.ts
CHANGED
|
@@ -298,10 +298,18 @@ export function useChat(
|
|
|
298
298
|
|
|
299
299
|
const queryClient = useQueryClient();
|
|
300
300
|
const send = useCallback(
|
|
301
|
-
|
|
301
|
+
// `compact`: the reader let a stale chat compact first (lib/chat/stale).
|
|
302
|
+
(text: string, images?: ChatImage[], compact?: boolean) => {
|
|
302
303
|
const ws = wsRef.current;
|
|
303
304
|
if (!ws) return;
|
|
304
|
-
ws.send(
|
|
305
|
+
ws.send(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
type: "send",
|
|
308
|
+
text,
|
|
309
|
+
images,
|
|
310
|
+
...(compact && { compact: true }),
|
|
311
|
+
})
|
|
312
|
+
);
|
|
305
313
|
if (ws.readyState === WebSocket.OPEN) markRunning(queryClient, sessionId);
|
|
306
314
|
},
|
|
307
315
|
[queryClient, sessionId]
|
package/lib/away/deploy.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "fs";
|
|
11
11
|
import type { ProjectConfig } from "../project-config";
|
|
12
|
-
import {
|
|
12
|
+
import { gitFetch, isAncestor } from "../git-exec";
|
|
13
13
|
import { expandHome } from "../tasks/session";
|
|
14
14
|
import { selfUrl } from "../agents/self-env";
|
|
15
15
|
import { commitTime } from "../orchestrator/repo";
|
|
@@ -61,15 +61,11 @@ async function contains(
|
|
|
61
61
|
sha: string
|
|
62
62
|
): Promise<boolean> {
|
|
63
63
|
if (tip === sha) return true;
|
|
64
|
-
const ancestor = () =>
|
|
65
|
-
run("git", ["merge-base", "--is-ancestor", sha, tip], repo).then(
|
|
66
|
-
() => true,
|
|
67
|
-
() => false
|
|
68
|
-
);
|
|
64
|
+
const ancestor = () => isAncestor(repo, sha, tip);
|
|
69
65
|
if (await ancestor()) return true;
|
|
70
66
|
// The deployed commit may be newer than this checkout knows. A fetch that
|
|
71
67
|
// fails throws, so stale refs never read as "not deployed".
|
|
72
|
-
await
|
|
68
|
+
await gitFetch(repo, ["--quiet", "origin"]);
|
|
73
69
|
return ancestor();
|
|
74
70
|
}
|
|
75
71
|
|
package/lib/away/run.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { notifyPhone } from "../notify";
|
|
|
23
23
|
import { getProject } from "../projects";
|
|
24
24
|
import { loadProjectConfig } from "../project-config";
|
|
25
25
|
import { run } from "../tasks/gh";
|
|
26
|
+
import { git } from "../git-exec";
|
|
26
27
|
import { expandHome } from "../tasks/session";
|
|
27
28
|
import { auditId, auditsRoot, writeAtomic } from "../session-audit/store";
|
|
28
29
|
import {
|
|
@@ -199,11 +200,11 @@ async function dirtySessions(ids: string[]): Promise<Set<string>> {
|
|
|
199
200
|
// A linked machine's worktree isn't on this disk.
|
|
200
201
|
if (!s || (s.host_id && s.host_id !== "local")) return;
|
|
201
202
|
const dir = expandHome(s.worktree_path || s.working_directory);
|
|
202
|
-
const out = await
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
).catch(() => "");
|
|
203
|
+
const out = await git(dir, [
|
|
204
|
+
"status",
|
|
205
|
+
"--porcelain",
|
|
206
|
+
"--untracked-files=normal",
|
|
207
|
+
]).catch(() => "");
|
|
207
208
|
if (out.trim()) dirty.add(id);
|
|
208
209
|
})
|
|
209
210
|
);
|
package/lib/build.ts
CHANGED
|
@@ -3,20 +3,16 @@
|
|
|
3
3
|
// A worker whose build isn't the server's is running code from before a
|
|
4
4
|
// redeploy.
|
|
5
5
|
|
|
6
|
-
import { execFileSync } from "child_process";
|
|
7
6
|
import fs from "fs";
|
|
8
7
|
import path from "path";
|
|
9
8
|
import type { ChatState } from "./chat/events";
|
|
9
|
+
import { gitSync } from "./git-exec";
|
|
10
10
|
|
|
11
11
|
export function buildId(): string {
|
|
12
12
|
if (process.env.AGENTOS_BUILD) return process.env.AGENTOS_BUILD;
|
|
13
13
|
let id = "";
|
|
14
14
|
try {
|
|
15
|
-
id =
|
|
16
|
-
cwd: process.cwd(),
|
|
17
|
-
encoding: "utf8",
|
|
18
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
-
}).trim();
|
|
15
|
+
id = gitSync(process.cwd(), ["rev-parse", "HEAD"]).trim();
|
|
20
16
|
} catch {}
|
|
21
17
|
if (!id) {
|
|
22
18
|
try {
|
package/lib/bus/index.ts
CHANGED
|
@@ -163,6 +163,9 @@ async function deliver(
|
|
|
163
163
|
from: from.name,
|
|
164
164
|
peer: from.peer,
|
|
165
165
|
origin: from.origin,
|
|
166
|
+
// Nobody is at the composer to choose: a stale chat is compacted
|
|
167
|
+
// first (lib/chat/stale).
|
|
168
|
+
compact: "auto",
|
|
166
169
|
});
|
|
167
170
|
return { state };
|
|
168
171
|
} catch (error) {
|
package/lib/chat/client-send.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import type { ChatImage } from "./events";
|
|
2
|
+
import type { CompactFirst } from "./stale";
|
|
2
3
|
|
|
3
|
-
// What a browser's send may carry: its text and images
|
|
4
|
-
//
|
|
4
|
+
// What a browser's send may carry: its text and images, and whether the
|
|
5
|
+
// reader chose to compact a stale chat first. Who a message is from (`from`,
|
|
6
|
+
// `peer`, `origin`) is set by the server alone, never by a client.
|
|
5
7
|
export function clientSend(msg: unknown): {
|
|
6
8
|
text: string;
|
|
7
9
|
images?: ChatImage[];
|
|
10
|
+
compact?: CompactFirst;
|
|
8
11
|
} {
|
|
9
|
-
const m = (msg ?? {}) as {
|
|
12
|
+
const m = (msg ?? {}) as {
|
|
13
|
+
text?: unknown;
|
|
14
|
+
images?: unknown;
|
|
15
|
+
compact?: unknown;
|
|
16
|
+
};
|
|
10
17
|
return {
|
|
11
18
|
text: typeof m.text === "string" ? m.text : "",
|
|
12
19
|
images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
|
|
20
|
+
...(m.compact === true && { compact: "yes" as const }),
|
|
13
21
|
};
|
|
14
22
|
}
|
|
@@ -31,6 +31,10 @@ export type ClaudeMessage = {
|
|
|
31
31
|
input_tokens?: number;
|
|
32
32
|
output_tokens?: number;
|
|
33
33
|
cache_creation_input_tokens?: number;
|
|
34
|
+
cache_creation?: {
|
|
35
|
+
ephemeral_5m_input_tokens?: number;
|
|
36
|
+
ephemeral_1h_input_tokens?: number;
|
|
37
|
+
} | null;
|
|
34
38
|
};
|
|
35
39
|
};
|
|
36
40
|
event?: {
|
|
@@ -49,7 +53,11 @@ export type ClaudeMessage = {
|
|
|
49
53
|
content?: string;
|
|
50
54
|
terminal_slash_commands?: string[];
|
|
51
55
|
commands?: SdkCommand[];
|
|
52
|
-
compact_metadata?: {
|
|
56
|
+
compact_metadata?: {
|
|
57
|
+
trigger?: "manual" | "auto";
|
|
58
|
+
pre_tokens?: number;
|
|
59
|
+
post_tokens?: number;
|
|
60
|
+
};
|
|
53
61
|
// Background tasks (task_started, task_progress, task_notification).
|
|
54
62
|
task_id?: string;
|
|
55
63
|
tool_use_id?: string;
|
|
@@ -223,6 +231,8 @@ export class ClaudeMapper {
|
|
|
223
231
|
id: randomUUID(),
|
|
224
232
|
kind: "compacted",
|
|
225
233
|
trigger: m.compact_metadata?.trigger,
|
|
234
|
+
before: m.compact_metadata?.pre_tokens,
|
|
235
|
+
after: m.compact_metadata?.post_tokens,
|
|
226
236
|
createdAt: now(),
|
|
227
237
|
},
|
|
228
238
|
},
|
|
@@ -10,6 +10,7 @@ import type { DriverEvent, FileSuggestion, McpServerView } from "../events";
|
|
|
10
10
|
import { InputQueue } from "../queue";
|
|
11
11
|
import { Approvals, isPlanFile, sdkMode } from "./claude-approvals";
|
|
12
12
|
import { toChatContext, usageTotals } from "../context";
|
|
13
|
+
import { cacheTtlMs } from "../stale";
|
|
13
14
|
import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
14
15
|
import { SuggestionTrace } from "./suggestion-trace";
|
|
15
16
|
import { redact } from "../../orchestrator/untrusted";
|
|
@@ -224,6 +225,8 @@ export const claudeDriver: ChatDriver = {
|
|
|
224
225
|
() => plan
|
|
225
226
|
);
|
|
226
227
|
let sessionId = options.resumeId ?? null;
|
|
228
|
+
// The prompt-cache lifetime its turns write with (lib/chat/stale).
|
|
229
|
+
let cacheTtl: number | undefined;
|
|
227
230
|
|
|
228
231
|
// What the agent restored on starting: a resumed conversation's totals
|
|
229
232
|
// when its last process saved them, nothing otherwise. Each turn's cost
|
|
@@ -248,13 +251,18 @@ export const claudeDriver: ChatDriver = {
|
|
|
248
251
|
planFile = writtenPlanFile(m) ?? planFile;
|
|
249
252
|
trace.message(m);
|
|
250
253
|
for (const e of mapper.map(m)) out.push(e);
|
|
254
|
+
if (m.type === "assistant" && !m.parent_tool_use_id)
|
|
255
|
+
cacheTtl = cacheTtlMs(m.message?.usage?.cache_creation) ?? cacheTtl;
|
|
251
256
|
// How full the window is now, for the meter, without holding up
|
|
252
257
|
// the conversation.
|
|
253
258
|
if (m.type === "result")
|
|
254
259
|
void q
|
|
255
260
|
.getContextUsage({ detail: "summary" })
|
|
256
261
|
.then((u) =>
|
|
257
|
-
out.push({
|
|
262
|
+
out.push({
|
|
263
|
+
type: "context",
|
|
264
|
+
context: { ...toChatContext(u), cacheTtlMs: cacheTtl },
|
|
265
|
+
})
|
|
258
266
|
)
|
|
259
267
|
.catch(() => {});
|
|
260
268
|
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -106,7 +106,11 @@ export interface ChatContext {
|
|
|
106
106
|
percentage: number;
|
|
107
107
|
categories: { name: string; tokens: number }[];
|
|
108
108
|
model?: string;
|
|
109
|
+
// When it was measured: the end of the last turn.
|
|
109
110
|
at: number;
|
|
111
|
+
// The prompt-cache lifetime the conversation writes with (lib/chat/stale),
|
|
112
|
+
// once a turn has said.
|
|
113
|
+
cacheTtlMs?: number;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
// Running totals the agent reports at the end of each turn.
|
|
@@ -174,7 +178,16 @@ export type ChatItem =
|
|
|
174
178
|
| (Base & { kind: "command_output"; text: string })
|
|
175
179
|
// The conversation's MCP servers, as /mcp reports them.
|
|
176
180
|
| (Base & { kind: "mcp"; servers: McpServerView[] })
|
|
177
|
-
| (Base & {
|
|
181
|
+
| (Base & {
|
|
182
|
+
kind: "compacted";
|
|
183
|
+
trigger?: "manual" | "auto";
|
|
184
|
+
// Context tokens before and after, when the agent reports them.
|
|
185
|
+
before?: number;
|
|
186
|
+
after?: number;
|
|
187
|
+
// Compacted ahead of a message to a stale chat (lib/chat/stale): how
|
|
188
|
+
// long it had been idle.
|
|
189
|
+
idleMs?: number;
|
|
190
|
+
})
|
|
178
191
|
// A plan the agent proposed from plan mode, and whether it was carried out.
|
|
179
192
|
| (Base & { kind: "plan"; plan: string; carried?: boolean })
|
|
180
193
|
| (Base & { kind: "error"; message: string })
|