@saadnvd1/agent-os 0.3.470 → 0.3.471

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 CHANGED
@@ -17,8 +17,8 @@ and check on all of it from your phone.
17
17
  Every workspace has one orchestrator, a chat pinned at the top of its list.
18
18
  It starts tasks, answers agents that are blocked, and reviews and merges
19
19
  pull requests through gates: CI green and settled, an independent review of
20
- the exact commit (big diffs are reviewed in parts), the PR's Code review
21
- section, nothing blocked, and the diff in scope. Anything that is yours to
20
+ the exact commit (big diffs are reviewed in parts), nothing blocked, and the
21
+ diff in scope. Anything that is yours to
22
22
  decide (publishing, money, something irreversible, credentials) waits as an
23
23
  ask with Approve, Decline and Reply. Turn on **Require my approval** and
24
24
  sensitive or large PRs wait for you too. [More](#orchestrator)
@@ -312,25 +312,34 @@ 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
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.
315
+ Chats and tasks compact at 150k tokens of context by default (Settings →
316
+ Workspaces → Compact; off sets it to never), not near the model's window:
317
+ AgentOS launches Claude Code with `CLAUDE_CODE_AUTO_COMPACT_WINDOW` set to
318
+ that size (its floor is 100k), so the agent compacts itself mid-turn. Between
319
+ turns AgentOS catches what it didn't: a chat over the threshold is compacted
320
+ before the next message, and so is one idle past its prompt cache (5 minutes
321
+ or an hour, read from the agent's own usage), whose next message would
322
+ re-read the whole conversation at the cache-write price. A chip above the
323
+ composer says "Idle 47m · 412k context · Compact first?": Send compacts and
324
+ then sends, **Send as-is** skips it for that one message. Messages nobody is
325
+ typing (the orchestrator's `send`, AgentOS events and wakes, `aos send`,
326
+ schedules) compact first on their own, only between turns, and the chat
327
+ shows one quiet line with the context before and after. An orchestrator
328
+ isn't compacted (a summary would lose the fence around what it read from
329
+ tasks and CI): it starts a fresh conversation from its brief and the current
330
+ state at its fresh-start threshold, or earlier once idle past its cache.
327
331
  On a desktop browser the screen button attaches a screenshot of a screen or
328
332
  window you pick; on a phone, attach a screenshot as a photo.
329
333
 
330
334
  **Usage** (the sidebar's ⋯ menu, or the palette) shows the account's 5-hour
331
335
  and weekly windows, and what chats cost today, over 7 or 30 days, by day,
332
336
  workspace and session. Costs are the agent's own estimates, recorded as each
333
- turn ends.
337
+ turn ends. **Gate reviews** below it shows what the orchestrator's
338
+ independent reviewer and scope check cost, by PR: they run as `claude -p`
339
+ and leave no transcript, so each run's tokens, model and duration are read
340
+ from its JSON result into `check_runs`, and the review's verdict says them
341
+ too. Every compaction is kept in `chat_compactions` (who ran it, and the
342
+ context before and after), so cost per task can be compared by date.
334
343
 
335
344
  **Esc** stops a running turn, as it does in Claude Code's terminal, unless a
336
345
  menu, dialog or the command list is open (those close first). On a phone the
@@ -465,17 +474,15 @@ Starting a task returns at once and shows **Setting up** until the agent
465
474
  launches; a restart in the middle resumes it. A failed setup shows on the task
466
475
  and is in the agent's first prompt (redacted, as data).
467
476
 
468
- Before opening its PR, every task runs `/do-code-review` (the project's
469
- `.claude/skills/do-code-review`, or Claude Code's `/code-review` where a
470
- project has none), fixes the Blocking and High findings, and ends the PR body
471
- with a **Code review** section: the commit it reviewed, the agents that ran,
472
- what it fixed and what it deferred and why. The orchestrator's `sign_off` and
473
- a stack's **Land** refuse a PR without that section for its head commit, and
474
- this repository's CI fails one too. A head AgentOS rebased itself is
475
- covered by the review of the head it rebased: its gates go by what they
476
- recorded, and CI by the section's `Restacked:` line. The review agents are in
477
- [.claude/agents](.claude/agents) and the skill in
478
- [.claude/skills](.claude/skills/README.md).
477
+ Tasks don't review their own PRs. The orchestrator's independent review of
478
+ the PR's exact head commit is the one review: `sign_off` and a stack's
479
+ **Land** refuse a PR without a passing one, and a PR body with AI
480
+ attribution. A head AgentOS rebased itself is covered by the review of the
481
+ head it rebased while the PR's own patch is unchanged. A task may still run
482
+ `/do-code-review` (the project's `.claude/skills/do-code-review`, or Claude
483
+ Code's `/code-review`) on a risky change before its PR; it's no longer
484
+ required. The review agents are in [.claude/agents](.claude/agents) and the
485
+ skill in [.claude/skills](.claude/skills/README.md).
479
486
 
480
487
  Requires the GitHub CLI (`gh`) signed in, and a project with a GitHub remote.
481
488
 
@@ -934,7 +941,7 @@ it may do on its own, and the lines that always come back to you as asks.
934
941
  it (2 minutes with no new check), its review passed, nothing is
935
942
  `BLOCKED:` or waiting, the diff stays in scope (no secrets, not only
936
943
  lockfiles, nothing outside the repo, within the card) and its stack parent
937
- has merged, and the PR body's Code review section names that commit. `land` judges each item again at its own head right before
944
+ has merged, and the PR body carries no AI attribution. `land` judges each item again at its own head right before
938
945
  merging it. A gate failing again on a different commit, or a repo with no
939
946
  CI, goes to you as an ask, and the orchestrator stops merging that task.
940
947
  A failure counts once per task, gate and head commit, however many times
@@ -953,17 +960,15 @@ it may do on its own, and the lines that always come back to you as asks.
953
960
  conflicts (read in the PR poll AgentOS already makes), `git merge-tree`
954
961
  checks it against the base. Clean, and with the task's agent idle and
955
962
  nothing uncommitted, AgentOS rebases it in the task's worktree and pushes
956
- only its branch with `--force-with-lease`; the Code review section gets a
957
- `Restacked: <reviewed> -> <new>` line so the task needn't review again,
958
- and the orchestrator's review carries over when the PR's own patch is
963
+ only its branch with `--force-with-lease`, and the orchestrator's review
964
+ carries over when the PR's own patch is
959
965
  unchanged (same `git patch-id`; a fresh review starts otherwise). Not
960
966
  clean: the task is asked once to rebase. Either way, one event. Main and
961
967
  other tasks' branches are never written.
962
968
  - **Tasks woken on CI failures and reviews:** a check failing on a task's
963
969
  PR head messages the task's own agent once per head, with the check, the
964
970
  failing step and about 60 lines of `gh run view --log-failed` around the
965
- error, telling it to fix the root cause, push and update its Reviewed:
966
- line. A review or comment from a collaborator (not the account gh runs
971
+ error, telling it to fix the root cause and push. A review or comment from a collaborator (not the account gh runs
967
972
  as, which is the task's own) wakes it once per review or comment; one
968
973
  GraphQL query per repository a minute fingerprints the open task PRs, and
969
974
  only a PR whose fingerprint moved has its reviews read. Comment-only wakes
@@ -976,8 +981,7 @@ it may do on its own, and the lines that always come back to you as asks.
976
981
  `owner/repo#N` or URL, as long as its repository is one of the workspace's
977
982
  projects; anything else is refused. The same gates apply, except that a
978
983
  draft waits and a "blocked", "do not merge" or "wip" label holds it (there's
979
- no session to read), and a body with no Code review section is covered by
980
- the orchestrator's own review of that head. `sessions` also lists tmux
984
+ no session to read). `sessions` also lists tmux
981
985
  sessions AgentOS didn't start that work in a workspace repository or a
982
986
  worktree of it, here or on another machine, with their branch's PR; `read`
983
987
  reads them, and nothing else acts on them.
@@ -1333,8 +1337,7 @@ until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1333
1337
  In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1334
1338
  which also holds the app's first-load JavaScript to a budget
1335
1339
  (`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
1336
- it), and fails a pull request whose body has no Code review section for its head
1337
- commit (run `/do-code-review` first; see [Tasks](#tasks)).
1340
+ it).
1338
1341
 
1339
1342
  ### Session audits
1340
1343
 
@@ -2,17 +2,21 @@ 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, and above which a stale chat is
6
- // compacted before a message (lib/chat/stale); 0 for never.
5
+ // tokens for orchestrators and for tasks, and at which chats and tasks
6
+ // compact (lib/chat/stale); 0 for never.
7
7
  export async function GET() {
8
8
  return NextResponse.json(refreshSettings());
9
9
  }
10
10
 
11
11
  export async function PUT(request: NextRequest) {
12
12
  try {
13
- const { orchestratorTokens, taskTokens, staleCompactTokens } =
14
- await request.json();
15
- setRefreshSettings({ orchestratorTokens, taskTokens, staleCompactTokens });
13
+ const body = await request.json();
14
+ setRefreshSettings({
15
+ orchestratorTokens: body.orchestratorTokens,
16
+ taskTokens: body.taskTokens,
17
+ // Its name before it also set the agents' own auto-compact.
18
+ compactTokens: body.compactTokens ?? body.staleCompactTokens,
19
+ });
16
20
  return NextResponse.json(refreshSettings());
17
21
  } catch (error) {
18
22
  const message = error instanceof Error ? error.message : String(error);
@@ -1,15 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { USAGE_RANGES, type UsageRange } from "@/lib/usage/aggregate";
3
- import { usageReport } from "@/lib/usage/report";
3
+ import { checkRunReport, usageReport } from "@/lib/usage/report";
4
4
  import { usageWindows } from "@/lib/usage/windows";
5
5
 
6
6
  // GET /api/usage?range=today|7d|30d - chat spend by day, session and
7
- // workspace, and the account's rolling windows
7
+ // workspace, what the orchestrator's reviews cost by PR, and the account's
8
+ // rolling windows
8
9
  export async function GET(request: NextRequest) {
9
10
  const asked = request.nextUrl.searchParams.get("range") as UsageRange | null;
10
11
  const range = asked && USAGE_RANGES.includes(asked) ? asked : "7d";
11
12
  return NextResponse.json({
12
13
  report: usageReport(range),
14
+ reviews: checkRunReport(range),
13
15
  windows: await usageWindows(),
14
16
  });
15
17
  }
@@ -285,9 +285,9 @@ export function ChatPanel({
285
285
  toEnd();
286
286
  send(text);
287
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;
288
+ // A chat past the compact threshold compacts before the next message
289
+ // unless the reader picks "Send as-is", for that send only.
290
+ const compactTokens = useFreshStartQuery().data?.compactTokens ?? 0;
291
291
  const [asIsFor, setAsIsFor] = useState<string | null>(null);
292
292
  const asIs = asIsFor === sessionId;
293
293
  const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
@@ -529,7 +529,7 @@ export function ChatPanel({
529
529
  <StaleChip
530
530
  context={context}
531
531
  state={state}
532
- thresholdTokens={staleCompactTokens}
532
+ thresholdTokens={compactTokens}
533
533
  asIs={asIs}
534
534
  onAsIs={setAsIs}
535
535
  />
@@ -547,7 +547,7 @@ export function ChatPanel({
547
547
  toEnd();
548
548
  const compact = composerCompacts(text, context, {
549
549
  state,
550
- thresholdTokens: staleCompactTokens,
550
+ thresholdTokens: compactTokens,
551
551
  asIs,
552
552
  });
553
553
  setAsIs(false);
@@ -2,14 +2,15 @@
2
2
 
3
3
  import { Minimize2 } from "lucide-react";
4
4
  import type { ChatContext, ChatState } from "@/lib/chat/events";
5
- import { staleLabel, staleness } from "@/lib/chat/stale";
5
+ import { compactionDue, staleLabel } from "@/lib/chat/stale";
6
6
  import { cn } from "@/lib/utils";
7
7
  import { useNow } from "./useNow";
8
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.
9
+ // Above the composer while the chat is due a compaction (lib/chat/stale):
10
+ // its context is past the compact threshold, and maybe its prompt cache has
11
+ // expired too, so the next message would re-read all of it at full price.
12
+ // Send compacts first unless the reader picks "Send as-is", for that one
13
+ // send only.
13
14
  export function StaleChip({
14
15
  context,
15
16
  state,
@@ -25,7 +26,7 @@ export function StaleChip({
25
26
  }) {
26
27
  const watching = !!context?.cacheTtlMs && thresholdTokens > 0;
27
28
  const now = useNow(watching);
28
- const stale = staleness(context, { state, thresholdTokens, now });
29
+ const stale = compactionDue(context, { state, thresholdTokens, now });
29
30
  if (!stale) return null;
30
31
  return (
31
32
  <div
@@ -41,8 +41,8 @@ export function UsageDialog() {
41
41
  <DialogHeader>
42
42
  <DialogTitle>Usage</DialogTitle>
43
43
  <DialogDescription>
44
- The account&apos;s limits, and what chat sessions on this machine
45
- cost.
44
+ The account&apos;s limits, and what chat sessions and gate reviews
45
+ on this machine cost.
46
46
  </DialogDescription>
47
47
  </DialogHeader>
48
48
 
@@ -124,6 +124,35 @@ export function UsageDialog() {
124
124
  </>
125
125
  )}
126
126
  </section>
127
+
128
+ {data?.reviews && data.reviews.total.turns > 0 && (
129
+ <section className="space-y-3">
130
+ <h3 className="text-sm font-medium">Gate reviews</h3>
131
+ <div className="flex items-baseline gap-2">
132
+ <span className="text-2xl font-semibold tabular-nums">
133
+ {formatUsd(data.reviews.total.costUsd)}
134
+ </span>
135
+ <span className="text-muted-foreground text-xs">
136
+ estimate · {data.reviews.total.turns} runs ·{" "}
137
+ {data.reviews.total.tokens.toLocaleString()} tokens
138
+ </span>
139
+ </div>
140
+ <SpendList
141
+ title="By PR"
142
+ rows={data.reviews.sessions.map((s) => ({
143
+ key: s.id,
144
+ label: s.name,
145
+ ...s,
146
+ }))}
147
+ total={data.reviews.total}
148
+ limit={10}
149
+ />
150
+ <p className="text-muted-foreground text-[11px]">
151
+ Each run of the orchestrator&apos;s independent reviewer and scope
152
+ check, from its own result. Not part of chat spend.
153
+ </p>
154
+ </section>
155
+ )}
127
156
  </DialogContent>
128
157
  </Dialog>
129
158
  );
@@ -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.staleCompactTokens}`}
22
+ key={`${data.orchestratorTokens}:${data.taskTokens}:${data.compactTokens}`}
23
23
  saved={data}
24
24
  />
25
25
  );
@@ -29,13 +29,13 @@ function Form({ saved }: { saved: RefreshSettings }) {
29
29
  const update = useUpdateFreshStart();
30
30
  const orchestrator = useThreshold(saved.orchestratorTokens, 200);
31
31
  const task = useThreshold(saved.taskTokens, 200);
32
- const stale = useThreshold(saved.staleCompactTokens, 150);
33
- const error = orchestrator.error ?? task.error ?? stale.error;
32
+ const compact = useThreshold(saved.compactTokens, 150);
33
+ const error = orchestrator.error ?? task.error ?? compact.error;
34
34
  const dirty =
35
35
  !error &&
36
36
  (orchestrator.tokens !== saved.orchestratorTokens ||
37
37
  task.tokens !== saved.taskTokens ||
38
- stale.tokens !== saved.staleCompactTokens);
38
+ compact.tokens !== saved.compactTokens);
39
39
 
40
40
  return (
41
41
  <form
@@ -46,7 +46,7 @@ function Form({ saved }: { saved: RefreshSettings }) {
46
46
  {
47
47
  orchestratorTokens: orchestrator.tokens,
48
48
  taskTokens: task.tokens,
49
- staleCompactTokens: stale.tokens,
49
+ compactTokens: compact.tokens,
50
50
  },
51
51
  {
52
52
  onSuccess: () => toast.success("Context settings saved"),
@@ -65,17 +65,17 @@ function Form({ saved }: { saved: RefreshSettings }) {
65
65
  </p>
66
66
  <ThresholdRow label="Orchestrators" field={orchestrator} />
67
67
  <ThresholdRow label="Tasks" field={task} />
68
- <h3 className="pt-2 text-sm font-medium">Compact idle chats</h3>
68
+ <h3 className="pt-2 text-sm font-medium">Compact</h3>
69
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.
70
+ Chats and tasks compact when their context passes this size: Claude Code
71
+ does it itself, and between turns AgentOS compacts a chat over it, or
72
+ idle past its prompt cache, before the next message. The composer offers
73
+ to first. Orchestrators start fresh instead.
74
74
  </p>
75
75
  <ThresholdRow
76
- label="Idle chats"
77
- field={stale}
78
- switchLabel="Compact idle chats before a message"
76
+ label="Chats and tasks"
77
+ field={compact}
78
+ switchLabel="Compact chats and tasks at this size"
79
79
  />
80
80
  {error && <p className="text-destructive text-xs">{error}</p>}
81
81
  <div className="flex justify-end">
@@ -9,6 +9,8 @@ export const usageKeys = {
9
9
 
10
10
  export interface UsageResponse {
11
11
  report: UsageReport;
12
+ // The orchestrator's reviewer and scope check runs, by PR.
13
+ reviews: UsageReport;
12
14
  windows: UsageWindows;
13
15
  }
14
16
 
@@ -9,6 +9,7 @@ import { runInBackground } from "../async-operations";
9
9
  import { trustPromptKeys } from "../tasks/state";
10
10
  import { BUS_BRIEF } from "./brief";
11
11
  import { selfUrl } from "./self-env";
12
+ import { autoCompactEnv } from "../chat/refresh";
12
13
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "../program-status/claude-flag";
13
14
  import {
14
15
  sessionProjectEnv,
@@ -35,6 +36,7 @@ export function ensureBusBrief(): void {
35
36
  export function agentEnv(sessionId: string): Record<string, string> {
36
37
  return {
37
38
  ...sessionProjectEnv(sessionId),
39
+ ...autoCompactEnv(sessionId),
38
40
  AGENTOS_SESSION_ID: sessionId,
39
41
  AGENTOS_URL: selfUrl(),
40
42
  PATH: `${AOS_BIN_DIR}:${process.env.PATH ?? ""}`,
@@ -6,8 +6,8 @@
6
6
  * default: its state is in the database and its brief carries it
7
7
  * (lib/orchestrator/digest). A task keeps its progress only in its
8
8
  * conversation and its branch, so for tasks it's off unless switched on.
9
- * App-wide, in Settings → Workspaces, with the size past which an idle
10
- * chat is compacted before a message goes in (lib/chat/stale).
9
+ * App-wide, in Settings → Workspaces, with the size at which chats and
10
+ * tasks compact (lib/chat/stale).
11
11
  */
12
12
 
13
13
  import { db } from "../db";
@@ -15,18 +15,20 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
15
15
 
16
16
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
17
17
  export const TASK_KEY = "chat.task_refresh_tokens";
18
- export const STALE_COMPACT_KEY = "chat.stale_compact_tokens";
18
+ // Named for what it first did: compact chats idle past their cache.
19
+ export const COMPACT_KEY = "chat.stale_compact_tokens";
19
20
  export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
20
- export const DEFAULT_STALE_COMPACT_TOKENS = 150_000;
21
+ export const DEFAULT_COMPACT_TOKENS = 150_000;
21
22
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
22
23
 
23
24
  export interface RefreshSettings {
24
25
  // Context tokens at which a conversation starts over; 0 is off.
25
26
  orchestratorTokens: number;
26
27
  taskTokens: number;
27
- // Context tokens above which a chat whose prompt cache has expired is
28
- // compacted before the next message; 0 is off.
29
- staleCompactTokens: number;
28
+ // Context tokens at which a chat's or task's agent compacts: Claude
29
+ // Code's own auto-compact window, and between turns a chat over it (or
30
+ // idle past its cache) is compacted before the next message. 0 is off.
31
+ compactTokens: number;
30
32
  }
31
33
 
32
34
  function read(key: string, fallback: number): number {
@@ -41,7 +43,7 @@ export function refreshSettings(): RefreshSettings {
41
43
  return {
42
44
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
43
45
  taskTokens: read(TASK_KEY, 0),
44
- staleCompactTokens: read(STALE_COMPACT_KEY, DEFAULT_STALE_COMPACT_TOKENS),
46
+ compactTokens: read(COMPACT_KEY, DEFAULT_COMPACT_TOKENS),
45
47
  };
46
48
  }
47
49
 
@@ -74,16 +76,32 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
74
76
  ]);
75
77
  if (input.taskTokens !== undefined)
76
78
  checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
77
- if (input.staleCompactTokens !== undefined)
78
- checked.push([
79
- STALE_COMPACT_KEY,
80
- checkRefreshTokens(input.staleCompactTokens),
81
- ]);
79
+ if (input.compactTokens !== undefined)
80
+ checked.push([COMPACT_KEY, checkRefreshTokens(input.compactTokens)]);
82
81
  db.transaction(() => {
83
82
  for (const [key, n] of checked) put.run(key, String(n));
84
83
  })();
85
84
  }
86
85
 
86
+ // Claude Code's own auto-compact window, in plain tokens (it reads "150k"
87
+ // as 150 and clamps anything under 100k up to it).
88
+ export const AUTO_COMPACT_ENV = "CLAUDE_CODE_AUTO_COMPACT_WINDOW";
89
+ const AUTO_COMPACT_MIN = 100_000;
90
+
91
+ // What an agent is launched with so it compacts itself at the threshold.
92
+ // Not an orchestrator: it starts over from its brief at its own threshold
93
+ // instead, since a summary would carry what it read from tasks and CI
94
+ // without its untrusted fence. Nothing when it's off, so a window set in
95
+ // the user's own environment still applies.
96
+ export function autoCompactEnv(sessionId: string): Record<string, string> {
97
+ const row = db
98
+ .prepare(`SELECT role FROM sessions WHERE id = ?`)
99
+ .get(sessionId) as { role: string | null } | undefined;
100
+ const tokens = refreshSettings().compactTokens;
101
+ if (row?.role === "orchestrator" || tokens <= 0) return {};
102
+ return { [AUTO_COMPACT_ENV]: String(Math.max(AUTO_COMPACT_MIN, tokens)) };
103
+ }
104
+
87
105
  const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
88
106
 
89
107
  // What the agent reads ahead of its first message in the new conversation.
package/lib/chat/stale.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Stale chats: a message sent to a conversation whose prompt cache has
3
- * expired re-reads the whole history at the cache-write price. When it is
4
- * also long, compacting first is far cheaper than carrying it (one summary
5
- * turn, then a small cache write instead of a huge one). The one check every
6
- * send path uses: the composer to offer it (components/Chat/StaleChip), the
7
- * chat worker to do it for automated sends (lib/chat/worker/host).
2
+ * Compacting between turns. A chat's agent compacts itself at the compact
3
+ * threshold (Claude Code's auto-compact window, set at launch by
4
+ * lib/agents/launch). Between turns AgentOS catches what it didn't: a chat
5
+ * over the threshold is compacted before the next message, and so is one
6
+ * whose prompt cache has expired, since that message would re-read the
7
+ * whole history at the cache-write price (one summary turn, then a small
8
+ * cache write instead of a huge one). The one check every send path uses:
9
+ * the composer to offer it (components/Chat/StaleChip), the chat worker to
10
+ * do it for automated sends (lib/chat/worker/host).
8
11
  *
9
12
  * Pure, so the browser and the worker read the same rule.
10
13
  */
@@ -38,21 +41,26 @@ export function cacheTtlMs(
38
41
  export interface Staleness {
39
42
  idleMs: number;
40
43
  tokens: number;
44
+ // Idle past the cache its last turn wrote, not only over the threshold.
45
+ stale: boolean;
41
46
  }
42
47
 
43
- // Stale: no turn running, the last one ended longer ago than the cache it
44
- // wrote lives, and the context is above the threshold (0 is off). Null when
45
- // it isn't, or when the chat never said which cache it uses.
46
- export function staleness(
48
+ // Due a compaction before the next message: no turn running and the
49
+ // context above the threshold (0 is off). Null when it isn't, or when the
50
+ // chat never said which cache it uses (only Claude's chats do).
51
+ export function compactionDue(
47
52
  context: ChatContext | null | undefined,
48
53
  opts: { state: ChatState; thresholdTokens: number; now?: number }
49
54
  ): Staleness | null {
50
55
  if (opts.state === "running" || opts.state === "waiting") return null;
51
56
  if (!context?.cacheTtlMs || opts.thresholdTokens <= 0) return null;
52
- const idleMs = (opts.now ?? Date.now()) - context.at;
53
- if (idleMs <= context.cacheTtlMs) return null;
54
57
  if (context.usedTokens <= opts.thresholdTokens) return null;
55
- return { idleMs, tokens: context.usedTokens };
58
+ const idleMs = (opts.now ?? Date.now()) - context.at;
59
+ return {
60
+ idleMs,
61
+ tokens: context.usedTokens,
62
+ stale: idleMs > context.cacheTtlMs,
63
+ };
56
64
  }
57
65
 
58
66
  export function formatIdle(ms: number): string {
@@ -62,9 +70,10 @@ export function formatIdle(ms: number): string {
62
70
  return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
63
71
  }
64
72
 
65
- // "Idle 47m · 412k context"
73
+ // "Idle 47m · 412k context", or "412k context" while its cache is warm.
66
74
  export function staleLabel(s: Staleness): string {
67
- return `Idle ${formatIdle(s.idleMs)} · ${formatTokens(s.tokens)} context`;
75
+ const size = `${formatTokens(s.tokens)} context`;
76
+ return s.stale ? `Idle ${formatIdle(s.idleMs)} · ${size}` : size;
68
77
  }
69
78
 
70
79
  // Whether the composer's send compacts first: the chip was showing and the
@@ -75,7 +84,7 @@ export function composerCompacts(
75
84
  opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
76
85
  ): boolean {
77
86
  if (opts.asIs || text.trimStart().startsWith("/")) return false;
78
- return !!staleness(context, opts);
87
+ return !!compactionDue(context, opts);
79
88
  }
80
89
 
81
90
  // The chat's line for a compaction: "Compacted before sending · idle 47m ·