@saadnvd1/agent-os 0.3.463 → 0.3.465

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
@@ -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
 
@@ -947,7 +960,7 @@ it may do on its own, and the lines that always come back to you as asks.
947
960
  - **Decision log:** `note` and the brakes and escalations write to
948
961
  `orchestrator_notes`, and each line shows in its chat.
949
962
  - **Standing rules:** what it must keep knowing ("never merge #111", "briefs
950
- must not name competitors") it keeps with `rule_add` and drops with
963
+ must not name third-party products") it keeps with `rule_add` and drops with
951
964
  `rule_remove`, in `orchestrator_rules`. You see, add, edit and remove them in
952
965
  Settings → Workspaces, each change with your passkey (any process on the
953
966
  machine reaches the route, and a rule of yours is your word to it). A rule restricts or informs; it never approves an
@@ -1273,7 +1286,12 @@ added lines for token-shaped secrets with the merge gate's own rule
1273
1286
  (`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
1274
1287
  `lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
1275
1288
  token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
1276
- whole. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1289
+ whole. It also refuses words that must not land in this public repo
1290
+ (`scripts/hooks/banned-terms.ts`): a short built-in list, plus, when it
1291
+ exists, a private list of regexes kept outside the repo in
1292
+ `~/.config/agent-os/banned-names` (`AGENTOS_BANNED_NAMES` overrides the
1293
+ path), checked against added lines and file names; `scripts/check` runs the
1294
+ same check over every tracked file. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1277
1295
  until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1278
1296
  In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1279
1297
  which also holds the app's first-load JavaScript to a budget
@@ -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);
@@ -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]
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 })
@@ -6,7 +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.
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).
10
11
  */
11
12
 
12
13
  import { db } from "../db";
@@ -14,13 +15,18 @@ import { MAX_REFRESH_TOKENS, MIN_REFRESH_TOKENS } from "./refresh-limits";
14
15
 
15
16
  export const ORCHESTRATOR_KEY = "orchestrator.refresh_tokens";
16
17
  export const TASK_KEY = "chat.task_refresh_tokens";
18
+ export const STALE_COMPACT_KEY = "chat.stale_compact_tokens";
17
19
  export const DEFAULT_ORCHESTRATOR_TOKENS = 200_000;
20
+ export const DEFAULT_STALE_COMPACT_TOKENS = 150_000;
18
21
  export { MIN_REFRESH_TOKENS, MAX_REFRESH_TOKENS } from "./refresh-limits";
19
22
 
20
23
  export interface RefreshSettings {
21
24
  // Context tokens at which a conversation starts over; 0 is off.
22
25
  orchestratorTokens: number;
23
26
  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;
24
30
  }
25
31
 
26
32
  function read(key: string, fallback: number): number {
@@ -35,6 +41,7 @@ export function refreshSettings(): RefreshSettings {
35
41
  return {
36
42
  orchestratorTokens: read(ORCHESTRATOR_KEY, DEFAULT_ORCHESTRATOR_TOKENS),
37
43
  taskTokens: read(TASK_KEY, 0),
44
+ staleCompactTokens: read(STALE_COMPACT_KEY, DEFAULT_STALE_COMPACT_TOKENS),
38
45
  };
39
46
  }
40
47
 
@@ -49,7 +56,7 @@ export function checkRefreshTokens(value: unknown): number {
49
56
  value > MAX_REFRESH_TOKENS
50
57
  )
51
58
  throw new Error(
52
- `A fresh-start threshold is off or between ${MIN_REFRESH_TOKENS / 1000}k and ${MAX_REFRESH_TOKENS / 1000}k tokens`
59
+ `A context threshold is off or between ${MIN_REFRESH_TOKENS / 1000}k and ${MAX_REFRESH_TOKENS / 1000}k tokens`
53
60
  );
54
61
  return value;
55
62
  }
@@ -67,6 +74,11 @@ export function setRefreshSettings(input: Partial<RefreshSettings>): void {
67
74
  ]);
68
75
  if (input.taskTokens !== undefined)
69
76
  checked.push([TASK_KEY, checkRefreshTokens(input.taskTokens)]);
77
+ if (input.staleCompactTokens !== undefined)
78
+ checked.push([
79
+ STALE_COMPACT_KEY,
80
+ checkRefreshTokens(input.staleCompactTokens),
81
+ ]);
70
82
  db.transaction(() => {
71
83
  for (const [key, n] of checked) put.run(key, String(n));
72
84
  })();
@@ -8,6 +8,7 @@
8
8
  import { randomUUID } from "crypto";
9
9
  import os from "os";
10
10
  import { db, type Session } from "../db";
11
+ import type { CompactFirst } from "./stale";
11
12
  import type {
12
13
  ApprovalDecision,
13
14
  ChatContext,
@@ -329,6 +330,8 @@ export async function sendChat(
329
330
  images?: ChatImage[];
330
331
  // Typed in the composer: waits in the queue while a turn runs.
331
332
  queue?: boolean;
333
+ // Compact a stale chat first (lib/chat/stale).
334
+ compact?: CompactFirst;
332
335
  } & SentBy
333
336
  ): Promise<void> {
334
337
  const text = input.text.trim();
@@ -356,6 +359,7 @@ export async function sendChat(
356
359
  images: input.images,
357
360
  ...sentBy(input),
358
361
  queue: input.queue,
362
+ compact: input.compact,
359
363
  });
360
364
  }
361
365
 
@@ -486,6 +490,8 @@ async function resumeCutOff(sessionId: string): Promise<void> {
486
490
  text: RESUME_NOTE,
487
491
  from: "agentos",
488
492
  origin: { kind: "event", label: "AgentOS" },
493
+ // Down past its cache, a long chat is compacted before it carries on.
494
+ compact: "auto",
489
495
  });
490
496
  if (task)
491
497
  queueEvent(
@@ -552,7 +558,7 @@ export async function chatFileSuggestions(
552
558
  // only then has it accepted it. "queued" when a turn was already running.
553
559
  export async function sendChatConfirmed(
554
560
  sessionId: string,
555
- input: { text: string; id?: string } & SentBy,
561
+ input: { text: string; id?: string; compact?: CompactFirst } & SentBy,
556
562
  timeoutMs = 10_000
557
563
  ): Promise<"delivered" | "queued"> {
558
564
  const text = input.text.trim();
@@ -607,6 +613,7 @@ export async function sendChatConfirmed(
607
613
  id,
608
614
  text,
609
615
  ...sentBy(input),
616
+ compact: input.compact,
610
617
  });
611
618
  await accepted;
612
619
  } finally {
@@ -0,0 +1,95 @@
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).
8
+ *
9
+ * Pure, so the browser and the worker read the same rule.
10
+ */
11
+
12
+ import type { ChatContext, ChatItem, ChatState } from "./events";
13
+ import { formatTokens } from "./context";
14
+
15
+ // How a send treats a stale chat: "auto" compacts first only when it is
16
+ // stale (automated sends); "yes" compacts first because the reader chose
17
+ // to (the composer's chip). Absent, it never does.
18
+ export type CompactFirst = "auto" | "yes";
19
+
20
+ // The prompt-cache lifetime a turn wrote with, read from its usage: the
21
+ // session runs with one or the other, and only a write says which. A turn
22
+ // that only read the cache says nothing.
23
+ export function cacheTtlMs(
24
+ cacheCreation:
25
+ | {
26
+ ephemeral_5m_input_tokens?: number;
27
+ ephemeral_1h_input_tokens?: number;
28
+ }
29
+ | null
30
+ | undefined
31
+ ): number | undefined {
32
+ if (!cacheCreation) return undefined;
33
+ if ((cacheCreation.ephemeral_1h_input_tokens ?? 0) > 0) return 60 * 60_000;
34
+ if ((cacheCreation.ephemeral_5m_input_tokens ?? 0) > 0) return 5 * 60_000;
35
+ return undefined;
36
+ }
37
+
38
+ export interface Staleness {
39
+ idleMs: number;
40
+ tokens: number;
41
+ }
42
+
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(
47
+ context: ChatContext | null | undefined,
48
+ opts: { state: ChatState; thresholdTokens: number; now?: number }
49
+ ): Staleness | null {
50
+ if (opts.state === "running" || opts.state === "waiting") return null;
51
+ 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
+ if (context.usedTokens <= opts.thresholdTokens) return null;
55
+ return { idleMs, tokens: context.usedTokens };
56
+ }
57
+
58
+ export function formatIdle(ms: number): string {
59
+ const m = Math.floor(ms / 60_000);
60
+ if (m < 120) return `${m}m`;
61
+ const h = Math.floor(m / 60);
62
+ return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
63
+ }
64
+
65
+ // "Idle 47m · 412k context"
66
+ export function staleLabel(s: Staleness): string {
67
+ return `Idle ${formatIdle(s.idleMs)} · ${formatTokens(s.tokens)} context`;
68
+ }
69
+
70
+ // Whether the composer's send compacts first: the chip was showing and the
71
+ // reader didn't pick "Send as-is". A slash command goes as typed.
72
+ export function composerCompacts(
73
+ text: string,
74
+ context: ChatContext | null | undefined,
75
+ opts: { state: ChatState; thresholdTokens: number; asIs: boolean }
76
+ ): boolean {
77
+ if (opts.asIs || text.trimStart().startsWith("/")) return false;
78
+ return !!staleness(context, opts);
79
+ }
80
+
81
+ // The chat's line for a compaction: "Compacted before sending · idle 47m ·
82
+ // 412k → 38k tokens" when it ran ahead of a message to a stale chat.
83
+ export function compactedLine(
84
+ item: Extract<ChatItem, { kind: "compacted" }>
85
+ ): string {
86
+ const head =
87
+ item.idleMs !== undefined
88
+ ? `Compacted before sending · idle ${formatIdle(item.idleMs)}`
89
+ : item.trigger === "auto"
90
+ ? "Context compacted automatically"
91
+ : "Context compacted";
92
+ if (item.before === undefined) return head;
93
+ const after = item.after === undefined ? "…" : formatTokens(item.after);
94
+ return `${head} · ${formatTokens(item.before)} → ${after} tokens`;
95
+ }
@@ -13,6 +13,7 @@ import { resolveModelForAgent } from "../../model-catalog";
13
13
  import { chatDriverFor } from "../drivers";
14
14
  import type { ChatConversation } from "../driver";
15
15
  import type {
16
+ ChatContext,
16
17
  ChatImage,
17
18
  ChatItem,
18
19
  ChatState,
@@ -35,7 +36,8 @@ import {
35
36
  visualsTools,
36
37
  } from "../../artifacts/tools";
37
38
  import type { RoleExtras } from "./extras";
38
- import { refreshedNote } from "../refresh";
39
+ import { refreshSettings, refreshedNote } from "../refresh";
40
+ import { staleLabel, staleness, type Staleness } from "../stale";
39
41
  import type { WorkerCommand, WorkerEvent } from "./protocol";
40
42
 
41
43
  // How soon after a stopped turn ends a turn the agent starts on its own
@@ -123,6 +125,19 @@ export class ChatHost {
123
125
  // size can't end every conversation after one turn.
124
126
  private freshConversation: boolean;
125
127
  private contextBase: number | null = null;
128
+ // How full the conversation was at its last turn's end (lib/chat/stale).
129
+ private context: ChatContext | null = null;
130
+ // A compaction run ahead of a message to a stale chat: the message waits
131
+ // at the front of the queue until it ends (compactFirst).
132
+ private compacting: {
133
+ idleMs: number;
134
+ before: number;
135
+ done?: boolean;
136
+ } | null = null;
137
+ // Its line, until the agent's next measure gives the size after.
138
+ private compactedLine: Extract<ChatItem, { kind: "compacted" }> | null = null;
139
+ // An orchestrator gone stale starts over rather than compacting.
140
+ private staleRefresh: Staleness | null = null;
126
141
  readonly done: Promise<void>;
127
142
 
128
143
  constructor(
@@ -182,11 +197,19 @@ export class ChatHost {
182
197
  permissionMode: extras.permissionMode,
183
198
  });
184
199
  this.suggestion = session.chat_suggestion ?? null;
200
+ try {
201
+ this.context = session.chat_context
202
+ ? (JSON.parse(session.chat_context) as ChatContext)
203
+ : null;
204
+ } catch {
205
+ this.context = null;
206
+ }
185
207
  this.refresh = extras.refresh;
186
208
  this.freshConversation = !session.claude_session_id;
187
209
  if (session.chat_refreshed)
188
210
  this.refreshedNote = refreshedNote(
189
- extras.refresh?.role ?? "task",
211
+ extras.refresh?.role ??
212
+ (session.role === "orchestrator" ? "orchestrator" : "task"),
190
213
  session.chat_refreshed
191
214
  );
192
215
  // Told only an orchestrator whose tools changed since its last worker:
@@ -222,6 +245,12 @@ export class ChatHost {
222
245
  try {
223
246
  for await (const e of this.conversation.events) {
224
247
  if (e.type === "item") {
248
+ // A compaction run for a stale chat shows as its one line; what
249
+ // a failed one printed still shows.
250
+ if (this.compacting?.done && e.item.kind === "command_output")
251
+ continue;
252
+ if (this.compacting && e.item.kind === "compacted")
253
+ e.item = this.compactedItem(e.item);
225
254
  if ("streaming" in e.item && e.item.streaming)
226
255
  this.streaming.set(e.item.id, e.item);
227
256
  else this.streaming.delete(e.item.id);
@@ -259,7 +288,9 @@ export class ChatHost {
259
288
  JSON.stringify(e.context),
260
289
  this.session.id
261
290
  );
291
+ this.context = e.context;
262
292
  this.emit(e);
293
+ this.settleCompactedLine(e.context.usedTokens);
263
294
  this.refreshIfFull(e.context.usedTokens);
264
295
  } else if (e.type === "turn_start") {
265
296
  this.turnsStarted++;
@@ -303,6 +334,8 @@ export class ChatHost {
303
334
  // A send waiting on the same change may have started a turn
304
335
  // meanwhile: that turn is running, not idle.
305
336
  if (this.busy()) continue;
337
+ // The compaction ran (or failed): its message is first in line.
338
+ this.compacting = null;
306
339
  if (this.sendQueued()) continue;
307
340
  }
308
341
  // A stopped turn can still settle an approval after it ended.
@@ -503,7 +536,13 @@ export class ChatHost {
503
536
  this.sendUser(cmd, true);
504
537
  return;
505
538
  }
506
- if (cmd.queue && (this.busy() || listQueue(this.session.id).length)) {
539
+ if (this.compactFirst(cmd)) return;
540
+ // A compaction run for an earlier message takes nothing in: what's
541
+ // sent meanwhile goes behind that message.
542
+ if (
543
+ (cmd.queue || this.compacting) &&
544
+ (this.busy() || listQueue(this.session.id).length)
545
+ ) {
507
546
  // Behind the running turn, and behind anything queued before it.
508
547
  enqueue(this.session.id, cmd);
509
548
  this.emit({ type: "queue" });
@@ -620,6 +659,88 @@ export class ChatHost {
620
659
  this.close();
621
660
  }
622
661
 
662
+ // A stale chat (lib/chat/stale) is compacted before the message goes in,
663
+ // only between turns: the message waits at the front of the queue, and
664
+ // the compaction's end sends it. "yes" is the reader's choice, made on
665
+ // what the composer showed; "auto" checks for itself.
666
+ private compactFirst(cmd: Extract<WorkerCommand, { type: "send" }>): boolean {
667
+ if (!cmd.compact || this.busy() || this.closed || this.handingOver())
668
+ return false;
669
+ const stale = this.staleNow(cmd.compact);
670
+ if (!stale) return false;
671
+ try {
672
+ enqueue(this.session.id, cmd);
673
+ } catch {
674
+ // A full queue: it goes in as it is.
675
+ return false;
676
+ }
677
+ moveToFront(this.session.id, cmd.id);
678
+ this.emit({ type: "queue" });
679
+ // An orchestrator isn't compacted: a summary would carry what it read
680
+ // from tasks and CI without its untrusted fence. It starts a fresh
681
+ // conversation from its brief and the database instead, as at its
682
+ // threshold, and the next worker sends the message.
683
+ if (this.session.role === "orchestrator") {
684
+ this.staleRefresh = stale;
685
+ this.refreshAt = stale.tokens;
686
+ this.retiring = true;
687
+ this.retireWaited = true;
688
+ this.retireIfDone();
689
+ return true;
690
+ }
691
+ this.compacting = { idleMs: stale.idleMs, before: stale.tokens };
692
+ this.currentTurn = null;
693
+ this.stopping = null;
694
+ this.conversation.send("/compact");
695
+ this.setState("running");
696
+ console.error(
697
+ `${new Date().toISOString()} compacting ${this.session.id} before a message: idle ${Math.round(stale.idleMs / 60_000)}m, ${stale.tokens} tokens`
698
+ );
699
+ return true;
700
+ }
701
+
702
+ private staleNow(compact: "auto" | "yes"): Staleness | null {
703
+ const context = this.context;
704
+ // Only an agent that says which cache it uses has one to lose.
705
+ if (!context?.cacheTtlMs) return null;
706
+ if (compact === "yes")
707
+ return { idleMs: Date.now() - context.at, tokens: context.usedTokens };
708
+ return staleness(context, {
709
+ state: this.state,
710
+ thresholdTokens: refreshSettings().staleCompactTokens,
711
+ });
712
+ }
713
+
714
+ private compactedItem(
715
+ item: Extract<ChatItem, { kind: "compacted" }>
716
+ ): ChatItem {
717
+ if (this.compacting) this.compacting.done = true;
718
+ const line = {
719
+ ...item,
720
+ idleMs: this.compacting?.idleMs,
721
+ before: item.before ?? this.compacting?.before,
722
+ };
723
+ this.compactedLine = line.after === undefined ? line : null;
724
+ if (line.after !== undefined) this.logCompacted(line);
725
+ return line;
726
+ }
727
+
728
+ // The size after, from the measure that follows the compaction's result.
729
+ private settleCompactedLine(usedTokens: number): void {
730
+ const line = this.compactedLine;
731
+ if (!line) return;
732
+ this.compactedLine = null;
733
+ const settled = { ...line, after: usedTokens };
734
+ this.record(settled);
735
+ this.logCompacted(settled);
736
+ }
737
+
738
+ private logCompacted(line: { before?: number; after?: number }): void {
739
+ console.error(
740
+ `${new Date().toISOString()} compacted ${this.session.id}: ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
741
+ );
742
+ }
743
+
623
744
  // Past its threshold, once: it retires at the next turn boundary, as for
624
745
  // a deploy, without waiting for the agent's guess at the next message
625
746
  // (the new conversation never saw what it guessed from).
@@ -643,13 +764,21 @@ export class ChatHost {
643
764
  chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
644
765
  chat_refreshed = ? WHERE id = ?`
645
766
  ).run(this.refreshAt, this.session.id);
767
+ const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
768
+ const stale = this.staleRefresh;
646
769
  this.record({
647
770
  id: `note-refresh-${Date.now()}`,
648
771
  kind: "note",
649
772
  tone: "note",
650
- text: `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with its brief${this.refresh?.role === "orchestrator" ? " and the current state from AgentOS" : ""} at its next message.`,
773
+ text: stale
774
+ ? `${staleLabel(stale)}, past its prompt cache: the agent starts a fresh conversation with ${brief} before the next message.`
775
+ : `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with ${brief} at its next message.`,
651
776
  createdAt: Date.now(),
652
777
  });
778
+ if (stale)
779
+ console.error(
780
+ `${new Date().toISOString()} fresh start for stale ${this.session.id}: ${stale.tokens} tokens, idle ${Math.round(stale.idleMs / 60_000)}m`
781
+ );
653
782
  }
654
783
 
655
784
  // Retiring, and nothing in the background keeps it: messages wait in the
@@ -18,6 +18,7 @@ import type {
18
18
  PeerMessage,
19
19
  } from "../events";
20
20
  import type { UndoResult } from "../driver";
21
+ import type { CompactFirst } from "../stale";
21
22
 
22
23
  export const PROTOCOL_VERSION = 1;
23
24
 
@@ -35,6 +36,8 @@ export type WorkerCommand =
35
36
  // From the composer: waits its turn behind a running one, in the
36
37
  // queue, rather than joining it.
37
38
  queue?: boolean;
39
+ // Compact a stale conversation before this goes in (lib/chat/stale).
40
+ compact?: CompactFirst;
38
41
  }
39
42
  // A queued message goes next: the running turn stops for it.
40
43
  // `during`: the user message whose turn the reader saw running; only that
@@ -1,4 +1,5 @@
1
1
  import type { ChatOrigin, ChatState } from "../chat/events";
2
+ import type { CompactFirst } from "../chat/stale";
2
3
  import {
3
4
  isAnswer,
4
5
  deliveriesSince,
@@ -22,7 +23,12 @@ export const AGENTOS_SENDER = "agentos";
22
23
 
23
24
  export type ChatSender = (
24
25
  sessionId: string,
25
- input: { text: string; from: string; origin: ChatOrigin }
26
+ input: {
27
+ text: string;
28
+ from: string;
29
+ origin: ChatOrigin;
30
+ compact: CompactFirst;
31
+ }
26
32
  ) => Promise<void>;
27
33
 
28
34
  // After a failed send, wait 5s, then 10s, 20s ... up to 5 minutes.
@@ -97,6 +103,8 @@ export async function deliverEvents(opts: {
97
103
  text,
98
104
  from: AGENTOS_SENDER,
99
105
  origin,
106
+ // An orchestrator idle past its cache is compacted first.
107
+ compact: "auto",
100
108
  });
101
109
  failing.delete(workspaceId);
102
110
  return text;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A workspace's standing rules: what its orchestrator must keep knowing
3
3
  * across fresh starts ("never merge #111", "briefs must not name
4
- * competitors"). The orchestrator writes them with rule_add and rule_remove,
4
+ * third-party products"). The orchestrator writes them with rule_add and rule_remove,
5
5
  * Saad in Settings → Workspaces, and every start reads them into its brief.
6
6
  * They restrict and inform; none can approve anything. A removed rule stays
7
7
  * in the table, marked.
@@ -73,7 +73,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
73
73
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
74
74
  note: "Add a line to this workspace's decision log; it shows in your chat.",
75
75
  rule_add:
76
- 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name competitors"). It restricts or informs; it can never approve anything.',
76
+ 'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
77
77
  rule_remove:
78
78
  "Remove a standing rule you kept, by its id, only when Saad says so or it no longer applies (the PR it names merged or closed). Saad's rules are his to remove.",
79
79
  review:
@@ -139,6 +139,7 @@ async function postToOrchestrator(
139
139
  ? `${fromLabel(schedule)}, set up by "${creator.name}"`
140
140
  : fromLabel(schedule),
141
141
  origin: scheduleOrigin(schedule, creator),
142
+ compact: "auto",
142
143
  });
143
144
  onSession(orchestrator.id);
144
145
  return orchestrator.id;
@@ -1,6 +1,6 @@
1
1
  // A linked machine's sessions are sessions here. One whose folder maps to
2
2
  // a project here sits with that project's sessions; the rest are listed
3
- // under their machine ("homelab · 3"), with the same rows. Client-safe.
3
+ // under their machine ("devbox · 3"), with the same rows. Client-safe.
4
4
  import type { Session } from "@/lib/db/types";
5
5
  import { buildShelves, type ShelfInput, type SidebarRow } from "./shelves";
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.463",
3
+ "version": "0.3.465",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -18,7 +18,6 @@
18
18
  "mcp/",
19
19
  "public/",
20
20
  "scripts/",
21
- "!scripts/competitor-audit/**",
22
21
  "stores/",
23
22
  "styles/",
24
23
  "next.config.ts",
package/scripts/check CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # The full guard, the same run locally and in CI:
3
- # scripts/check typecheck, lint, format, tests
3
+ # scripts/check banned terms, typecheck, lint, format, tests
4
4
  # scripts/check --build ...and a production build, held to its JS budget
5
5
  set -euo pipefail
6
6
  cd "$(dirname "$0")/.."
7
7
 
8
8
  step() { printf '\n▸ %s\n' "$1"; }
9
9
 
10
+ step "terms"; npx tsx scripts/hooks/banned-terms.ts --tree
10
11
  step "typecheck"; npx tsc --noEmit
11
12
  step "lint"; npx eslint . --max-warnings=40
12
13
  step "format"; npx prettier --check . --log-level warn
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { execFileSync, spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { GENERIC_TERMS, loadTerms, matcher, scanTree } from "./banned-terms";
7
+
8
+ const generic = GENERIC_TERMS[0];
9
+
10
+ function tempRepo() {
11
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "banned-terms-"));
12
+ const git = (...args: string[]) =>
13
+ execFileSync("git", args, { cwd: repo, stdio: "pipe" });
14
+ git("init", "-q");
15
+ return { repo, git };
16
+ }
17
+
18
+ describe("loadTerms", () => {
19
+ it("is the generic list alone when the private file is absent", () => {
20
+ const terms = loadTerms(path.join(os.tmpdir(), "no-such-banned-names"));
21
+ expect(terms.map((t) => t.source)).toEqual(GENERIC_TERMS);
22
+ });
23
+
24
+ it("adds the private regexes, skipping blanks and comments", () => {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "banned-names-"));
26
+ const file = path.join(dir, "banned-names");
27
+ fs.writeFileSync(file, "# names\nacme[ -]?widgets\n\n zorp \n");
28
+ try {
29
+ const check = matcher(loadTerms(file));
30
+ expect(check("We copied Acme-Widgets here")).toBe('"Acme-Widgets"');
31
+ expect(check("ZORP")).toBe('"ZORP"');
32
+ expect(check(`the ${generic.toUpperCase()} list`)).not.toBeNull();
33
+ expect(check("nothing to see")).toBeNull();
34
+ } finally {
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("scanTree", () => {
41
+ it("names the file and line of each hit, and a hit in a path", () => {
42
+ const { repo, git } = tempRepo();
43
+ try {
44
+ fs.mkdirSync(path.join(repo, `${generic}-x`));
45
+ fs.writeFileSync(path.join(repo, `${generic}-x`, "a.md"), "fine\n");
46
+ fs.writeFileSync(path.join(repo, "b.ts"), `ok\n// a ${generic}\n`);
47
+ fs.writeFileSync(path.join(repo, "c.bin"), `\0${generic}`);
48
+ git("add", ".");
49
+ expect(scanTree(repo, loadTerms("/nonexistent"))).toEqual([
50
+ { file: "b.ts", line: 2, what: `"${generic}"` },
51
+ { file: `${generic}-x/a.md`, line: 0, what: `"${generic}"` },
52
+ ]);
53
+ } finally {
54
+ fs.rmSync(repo, { recursive: true, force: true });
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("the pre-commit check", () => {
60
+ it("fails on a staged private name, and passes once it's gone", () => {
61
+ const { repo, git } = tempRepo();
62
+ const list = path.join(repo, "..", `${path.basename(repo)}-names`);
63
+ fs.writeFileSync(list, "acme\n");
64
+ const scan = () =>
65
+ spawnSync(
66
+ path.resolve("node_modules/.bin/tsx"),
67
+ [path.resolve("scripts/hooks/banned-terms.ts")],
68
+ {
69
+ cwd: repo,
70
+ encoding: "utf-8",
71
+ env: { ...process.env, AGENTOS_BANNED_NAMES: list },
72
+ }
73
+ );
74
+ try {
75
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike Acme does\n");
76
+ git("add", "x.md");
77
+ const bad = scan();
78
+ expect(bad.status).toBe(1);
79
+ expect(bad.stderr).toContain("x.md:2");
80
+ fs.writeFileSync(path.join(repo, "x.md"), "ok\nlike others do\n");
81
+ git("add", "x.md");
82
+ expect(scan().status).toBe(0);
83
+ fs.writeFileSync(path.join(repo, "acme-notes.md"), "clean\n");
84
+ git("add", "acme-notes.md");
85
+ const named = scan();
86
+ expect(named.status).toBe(1);
87
+ expect(named.stderr).toContain("acme-notes.md:0");
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ fs.rmSync(list, { force: true });
91
+ }
92
+ });
93
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Words that must never land in this public repo: one generic term, always,
3
+ * plus a private denylist kept outside the repo (the list itself would be a
4
+ * mention), one case-insensitive regex per line, skipped when it's absent.
5
+ *
6
+ * tsx scripts/hooks/banned-terms.ts the staged diff's added lines (pre-commit)
7
+ * tsx scripts/hooks/banned-terms.ts --tree every tracked file (scripts/check, CI)
8
+ */
9
+
10
+ import { execFileSync } from "child_process";
11
+ import fs from "fs";
12
+ import os from "os";
13
+ import path from "path";
14
+ import { scanDiff, stagedDiff, type SecretHit } from "./staged-secrets";
15
+
16
+ // Assembled so this file isn't a hit itself.
17
+ export const GENERIC_TERMS = [["compet", "itor"].join("")];
18
+
19
+ export const bannedFile = () =>
20
+ process.env.AGENTOS_BANNED_NAMES ||
21
+ path.join(os.homedir(), ".config", "agent-os", "banned-names");
22
+
23
+ export function loadTerms(file = bannedFile()): RegExp[] {
24
+ const generic = GENERIC_TERMS.map((t) => new RegExp(t, "i"));
25
+ let raw = "";
26
+ try {
27
+ raw = fs.readFileSync(file, "utf8");
28
+ } catch {
29
+ return generic;
30
+ }
31
+ const own = raw
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l && !l.startsWith("#"))
35
+ .map((l) => new RegExp(l, "i"));
36
+ return [...generic, ...own];
37
+ }
38
+
39
+ // What a line matched, without echoing the private pattern that caught it.
40
+ export const matcher = (terms: RegExp[]) => (line: string) => {
41
+ for (const t of terms) {
42
+ const m = line.match(t);
43
+ if (m) return `"${m[0]}"`;
44
+ }
45
+ return null;
46
+ };
47
+
48
+ export function scanTree(cwd: string, terms: RegExp[]): SecretHit[] {
49
+ const check = matcher(terms);
50
+ const files = execFileSync("git", ["ls-files", "-z"], {
51
+ cwd,
52
+ encoding: "utf8",
53
+ maxBuffer: 64 * 1024 * 1024,
54
+ })
55
+ .split("\0")
56
+ .filter(Boolean);
57
+ const hits: SecretHit[] = [];
58
+ for (const file of files) {
59
+ let text: string;
60
+ try {
61
+ text = fs.readFileSync(path.join(cwd, file), "utf8");
62
+ } catch {
63
+ continue; // deleted in the working tree, or a submodule
64
+ }
65
+ if (text.includes("\0")) continue;
66
+ text.split("\n").forEach((l, i) => {
67
+ const what = check(l);
68
+ if (what) hits.push({ file, line: i + 1, what });
69
+ });
70
+ }
71
+ // A path can name something too.
72
+ for (const file of files) {
73
+ const what = check(file);
74
+ if (what) hits.push({ file, line: 0, what });
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ function stagedHits(terms: RegExp[]): SecretHit[] {
80
+ const check = matcher(terms);
81
+ const names = execFileSync(
82
+ "git",
83
+ ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
84
+ { encoding: "utf8" }
85
+ )
86
+ .split("\0")
87
+ .filter(Boolean)
88
+ .flatMap((file) => {
89
+ const what = check(file);
90
+ return what ? [{ file, line: 0, what }] : [];
91
+ });
92
+ return [...scanDiff(stagedDiff(), check), ...names];
93
+ }
94
+
95
+ if (process.argv[1]?.endsWith("banned-terms.ts")) {
96
+ const terms = loadTerms();
97
+ const hits = process.argv.includes("--tree")
98
+ ? scanTree(process.cwd(), terms)
99
+ : stagedHits(terms);
100
+ for (const h of hits)
101
+ console.error(`✗ ${h.file}:${h.line}: names ${h.what}; reword it`);
102
+ if (hits.length) process.exit(1);
103
+ }
@@ -13,10 +13,13 @@ export interface SecretHit {
13
13
  what: string;
14
14
  }
15
15
 
16
- // Each added line of a `git diff -U0` with the file and new line number it
17
- // lands on. Headers are only read before a file's first hunk, so an added
18
- // line that itself starts with "++" isn't taken for one.
19
- export function scanDiff(diff: string): SecretHit[] {
16
+ // Each added line of a `git diff -U0` that `check` flags, with the file and
17
+ // new line number it lands on. Headers are only read before a file's first
18
+ // hunk, so an added line that itself starts with "++" isn't taken for one.
19
+ export function scanDiff(
20
+ diff: string,
21
+ check: (line: string) => string | null = secretsIn
22
+ ): SecretHit[] {
20
23
  const hits: SecretHit[] = [];
21
24
  let file = "";
22
25
  let line = 0;
@@ -30,7 +33,7 @@ export function scanDiff(diff: string): SecretHit[] {
30
33
  header = false;
31
34
  line = Number(l.match(/\+(\d+)/)?.[1] ?? 0);
32
35
  } else if (!header && l.startsWith("+")) {
33
- const what = secretsIn(l.slice(1));
36
+ const what = check(l.slice(1));
34
37
  if (what) hits.push({ file, line, what });
35
38
  line++;
36
39
  }
@@ -38,8 +41,8 @@ export function scanDiff(diff: string): SecretHit[] {
38
41
  return hits;
39
42
  }
40
43
 
41
- if (process.argv[1]?.endsWith("staged-secrets.ts")) {
42
- const diff = execFileSync(
44
+ export const stagedDiff = () =>
45
+ execFileSync(
43
46
  "git",
44
47
  [
45
48
  "diff",
@@ -51,7 +54,9 @@ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
51
54
  ],
52
55
  { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }
53
56
  );
54
- const hits = scanDiff(diff);
57
+
58
+ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
59
+ const hits = scanDiff(stagedDiff());
55
60
  for (const h of hits)
56
61
  console.error(`✗ ${h.file}:${h.line}: looks like a secret (${h.what})`);
57
62
  if (hits.length) {