@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.
Files changed (57) hide show
  1. package/README.md +20 -0
  2. package/app/api/fresh-start/route.ts +5 -3
  3. package/app/api/git/commit/route.ts +4 -3
  4. package/app/api/git/file-content/route.ts +4 -15
  5. package/app/api/git/history/[hash]/diff/route.ts +1 -1
  6. package/app/api/git/history/[hash]/route.ts +1 -1
  7. package/app/api/git/history/route.ts +1 -1
  8. package/app/api/git/push/route.ts +2 -2
  9. package/app/api/sessions/[id]/pr/route.ts +2 -2
  10. package/components/Chat/ChatPanel.tsx +23 -1
  11. package/components/Chat/Messages.tsx +2 -3
  12. package/components/Chat/StaleChip.tsx +55 -0
  13. package/components/Workspaces/FreshStartForm.tsx +29 -6
  14. package/data/chat/useChat.ts +10 -2
  15. package/lib/away/deploy.ts +3 -7
  16. package/lib/away/run.ts +6 -5
  17. package/lib/build.ts +2 -6
  18. package/lib/bus/index.ts +3 -0
  19. package/lib/chat/client-send.ts +11 -3
  20. package/lib/chat/drivers/claude-mapper.ts +11 -1
  21. package/lib/chat/drivers/claude.ts +9 -1
  22. package/lib/chat/events.ts +14 -1
  23. package/lib/chat/files.ts +31 -9
  24. package/lib/chat/refresh.ts +14 -2
  25. package/lib/chat/runner.ts +8 -1
  26. package/lib/chat/stale.ts +95 -0
  27. package/lib/chat/worker/host.ts +133 -4
  28. package/lib/chat/worker/protocol.ts +3 -0
  29. package/lib/done/remote.ts +5 -6
  30. package/lib/done/testing.ts +2 -7
  31. package/lib/done/worktree.ts +2 -2
  32. package/lib/git-exec.ts +334 -0
  33. package/lib/git-history.ts +29 -41
  34. package/lib/git-status.ts +45 -124
  35. package/lib/git.ts +44 -159
  36. package/lib/lumifyhub/publish.ts +2 -2
  37. package/lib/orchestrator/deliver.ts +9 -1
  38. package/lib/orchestrator/diff.ts +15 -13
  39. package/lib/orchestrator/rebase.ts +20 -24
  40. package/lib/orchestrator/repo-slug.ts +4 -7
  41. package/lib/orchestrator/repo.ts +24 -35
  42. package/lib/pr-generation.ts +2 -1
  43. package/lib/pr.ts +16 -57
  44. package/lib/schedules/start.ts +1 -0
  45. package/lib/sessions/worktree-setup.ts +17 -13
  46. package/lib/stacks/start.ts +7 -11
  47. package/lib/tasks/branch-worktree.ts +3 -2
  48. package/lib/tasks/finish.ts +5 -6
  49. package/lib/tasks/gh.ts +5 -0
  50. package/lib/tasks/move-testing.ts +2 -2
  51. package/lib/tasks/move.ts +3 -2
  52. package/lib/tasks/project-ref.ts +12 -19
  53. package/lib/tasks/session.ts +5 -5
  54. package/lib/worktree-hooks.ts +3 -7
  55. package/lib/worktree-placed.ts +3 -10
  56. package/lib/worktrees.ts +4 -42
  57. 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, 0 for never.
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 } = await request.json();
13
- setRefreshSettings({ orchestratorTokens, taskTokens });
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" || !isValidBranchName(path, branchName))
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 { execFile } from "child_process";
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 { stdout: content } = await execFileAsync(
37
- "git",
38
- ["show", `HEAD:${file}`],
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 execFileAsync("git", ["push", "-u", "origin", "--", branchName], {
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
- send(text, images.length ? images : undefined);
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.trigger === "auto"
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 key={`${data.orchestratorTokens}:${data.taskTokens}`} saved={data} />
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 error = orchestrator.error ?? task.error;
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
- onSuccess: () => toast.success("Fresh starts saved"),
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 && (
@@ -298,10 +298,18 @@ export function useChat(
298
298
 
299
299
  const queryClient = useQueryClient();
300
300
  const send = useCallback(
301
- (text: string, images?: ChatImage[]) => {
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(JSON.stringify({ type: "send", text, images }));
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]
@@ -9,7 +9,7 @@
9
9
 
10
10
  import fs from "fs";
11
11
  import type { ProjectConfig } from "../project-config";
12
- import { run } from "../tasks/gh";
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 run("git", ["fetch", "--quiet", "origin"], repo);
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 run(
203
- "git",
204
- ["status", "--porcelain", "--untracked-files=normal"],
205
- dir
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 = execFileSync("git", ["rev-parse", "HEAD"], {
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) {
@@ -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. Who a message is
4
- // from (`from`, `peer`, `origin`) is set by the server alone, never by a client.
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 { text?: unknown; images?: unknown };
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?: { trigger?: "manual" | "auto" };
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({ type: "context", context: toChatContext(u) })
262
+ out.push({
263
+ type: "context",
264
+ context: { ...toChatContext(u), cacheTtlMs: cacheTtl },
265
+ })
258
266
  )
259
267
  .catch(() => {});
260
268
  }
@@ -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 & { kind: "compacted"; trigger?: "manual" | "auto" })
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 })