@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/lib/chat/files.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
+ import { git } from "../git-exec";
3
4
  import type { FileSuggestion } from "./events";
4
5
  import {
5
6
  indexPaths,
@@ -33,11 +34,36 @@ export function sweepFileIndexes(now = Date.now()): void {
33
34
 
34
35
  export const fileIndexCount = () => cache.size;
35
36
 
37
+ const LIST_TIMEOUT_MS = 5000;
38
+
39
+ export interface Lister {
40
+ // The program it needs.
41
+ cmd: string;
42
+ list: (cwd: string) => Promise<string>;
43
+ }
44
+
36
45
  // Ways to list a folder's files with ignored ones left out: ripgrep, or
37
46
  // git where ripgrep isn't installed.
38
- export const LISTERS: [string, string[]][] = [
39
- ["rg", ["--files", "--hidden", "-g", "!.git"]],
40
- ["git", ["ls-files", "--cached", "--others", "--exclude-standard"]],
47
+ export const LISTERS: Lister[] = [
48
+ {
49
+ cmd: "rg",
50
+ list: async (cwd) =>
51
+ (
52
+ await execFileAsync("rg", ["--files", "--hidden", "-g", "!.git"], {
53
+ cwd,
54
+ maxBuffer: 64 * 1024 * 1024,
55
+ timeout: LIST_TIMEOUT_MS,
56
+ })
57
+ ).stdout,
58
+ },
59
+ {
60
+ cmd: "git",
61
+ list: (cwd) =>
62
+ git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"], {
63
+ timeout: LIST_TIMEOUT_MS,
64
+ maxBuffer: 64 * 1024 * 1024,
65
+ }),
66
+ },
41
67
  ];
42
68
 
43
69
  // The folder's files, for @mentions when no agent is running to match them
@@ -46,13 +72,9 @@ export async function listFiles(
46
72
  cwd: string,
47
73
  listers = LISTERS
48
74
  ): Promise<IndexedPath[]> {
49
- for (const [cmd, args] of listers) {
75
+ for (const { list } of listers) {
50
76
  try {
51
- const { stdout } = await execFileAsync(cmd, args, {
52
- cwd,
53
- maxBuffer: 64 * 1024 * 1024,
54
- timeout: 5000,
55
- });
77
+ const stdout = await list(cwd);
56
78
  return indexPaths(
57
79
  withFolders(stdout.split("\n").filter(Boolean).slice(0, MAX_FILES))
58
80
  );
@@ -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
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { Session } from "../db";
6
6
  import type { TaskPR } from "../tasks";
7
- import { run } from "../tasks/gh";
7
+ import { git, gitPush } from "../git-exec";
8
8
  import { projectPathFor } from "../tasks/session";
9
9
  import { PROTECTED_BRANCHES } from "../tasks/finish";
10
10
  import { liveChildren } from "../stacks/guard";
@@ -23,15 +23,14 @@ export async function deleteMergedRemote(
23
23
  liveChildren(s.id).length
24
24
  )
25
25
  return false;
26
- const line = await run(
27
- "git",
28
- ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
26
+ const line = await git(
29
27
  repo,
30
- 30000
28
+ ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
29
+ { timeout: 30000 }
31
30
  ).catch(() => "");
32
31
  const tip = line.trim().split(/\s+/)[0];
33
32
  if (!tip || tip !== pr.head) return false;
34
- return run("git", ["push", "origin", "--delete", branch], repo, 60000)
33
+ return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
35
34
  .then(() => true)
36
35
  .catch(() => false);
37
36
  }
@@ -4,18 +4,13 @@
4
4
  import fs from "fs";
5
5
  import os from "os";
6
6
  import path from "path";
7
- import { execFileSync } from "child_process";
7
+ import { gitSync } from "../git-exec";
8
8
 
9
9
  // Old enough that CI counts as settled on every commit.
10
10
  const env = { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" };
11
11
 
12
12
  export const git = (cwd: string, ...args: string[]) =>
13
- execFileSync("git", args, {
14
- cwd,
15
- encoding: "utf8",
16
- stdio: "pipe",
17
- env,
18
- }).trim();
13
+ gitSync(cwd, args, { env }).trim();
19
14
 
20
15
  export function commitFile(cwd: string, file: string, text: string): string {
21
16
  fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
@@ -7,7 +7,7 @@
7
7
  import fs from "fs";
8
8
  import type { Session } from "../db";
9
9
  import { getDefaultBranch } from "../git";
10
- import { run } from "../tasks/gh";
10
+ import { git as runGit } from "../git-exec";
11
11
  import { mergePolicy } from "../tasks/merge-policy";
12
12
  import { getProject } from "../projects";
13
13
  import {
@@ -24,7 +24,7 @@ export type WorktreeFate =
24
24
  | { action: "kept"; why: string; path: string };
25
25
 
26
26
  const git = (cwd: string, ...args: string[]) =>
27
- run("git", args, cwd, 30000).then((out) => out.trim());
27
+ runGit(cwd, args, { timeout: 30000 }).then((out) => out.trim());
28
28
 
29
29
  // The main checkout a worktree belongs to.
30
30
  async function repoOf(worktree: string): Promise<string> {