@saadnvd1/agent-os 0.3.488 → 0.3.493

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
@@ -920,7 +920,13 @@ other sessions. Sessions sit on shelves: **Pinned** (pin from a row's ⋯
920
920
  menu), **Needs you** (an approval, a question, a terminal waiting for input, a
921
921
  message typed and never sent, a failed task, or the orchestrator's open asks,
922
922
  each with a badge), **Working**,
923
- and **Done**, newest first, ten at a time. A purple dot marks a session that
923
+ and **Done**. Done is folded to one row, "3 done today · 12 earlier", until you
924
+ tap it (open or folded is remembered per device); open, it lists newest first,
925
+ ten at a time. The orchestrator's row carries small chips for the work it
926
+ started: running, in review, blocked and queued, each left out at zero. Tap
927
+ them to show only that orchestrator's work on the same shelves; the "Showing
928
+ … work ×" chip at the top clears it (it resets on reload). A session an
929
+ orchestrator started has a small orchestrator icon on its row. A purple dot marks a session that
924
930
  finished or changed since you last opened it. Search matches
925
931
  titles and project names, and the project filter narrows the list to one
926
932
  project and holds that project's own actions: new session, terminal, dev
@@ -1059,7 +1065,31 @@ it may do on its own, and the lines that always come back to you as asks.
1059
1065
  arrives marked as from that orchestrator and fenced as untrusted, so it can't
1060
1066
  approve, answer an ask or pass a gate. The route
1061
1067
  they call answers only the orchestrator's worker, by a per-orchestrator
1062
- secret. Its shell runs only `aos` commands that read.
1068
+ secret.
1069
+ - **Shell:** it runs anything without asking, so an orchestrator can read and
1070
+ investigate on its own: `gh pr list`, `gh run view --log-failed`, `gh api`
1071
+ GETs, `curl`, `sqlite3 -readonly`, git in any repository, a skill's scripts,
1072
+ pipes and chains. Its chat worker refuses a deny list before a command runs,
1073
+ naming the tool to use instead: merging, closing or retargeting a PR
1074
+ (`gh pr merge`, `gh pr close`, `gh pr edit --base`, and `gh api` or `curl`
1075
+ calls that merge a pull request or PATCH or PUT one; use `sign_off`, `arm`,
1076
+ `land` or `drop`), `git push` (`quick_pr` pushes), writing in a project
1077
+ repository or task worktree other than its own open quick changes (git
1078
+ commit, reset, checkout, rebase, merge, stash, clean or rm there, and `rm`,
1079
+ `mv`, `cp`, `tee` or a redirect into one), writing AgentOS's state (`sqlite3`
1080
+ on its database without `-readonly` or with INSERT, UPDATE, DELETE, DROP,
1081
+ ALTER or ATTACH; files under `~/.agent-os` outside its folder), stopping
1082
+ AgentOS or its tmux (`pkill`/`kill` aimed at it, `tmux kill-*`, a
1083
+ `launchctl` or `serviceman` restart), and reading secret files (the same
1084
+ list as the write guard) or tokenvault (`tv get`, `tv dump`). It reads each
1085
+ command line the way a shell would, so these are caught inside pipes,
1086
+ chains and `$( )`, behind `bash -c`, `sh -c`, `env`, `sudo`, `xargs`,
1087
+ `find -exec` or an absolute path, and after a `cd` or `git -C`. **The deny
1088
+ list catches honest mistakes, not a determined bypass:** a script or
1089
+ `python -c` can do any of these where no parser sees it, and an
1090
+ orchestrator reads other sessions' text, which can carry planted
1091
+ instructions. The trade-off was chosen on 2026-10-11, for an orchestrator
1092
+ that does its own investigating instead of handing it to him.
1063
1093
  - **Triage:** its brief has it decide each job: small and clear is a task with
1064
1094
  `plan: false` and the steps written into the brief; big, unclear or
1065
1095
  cross-cutting is `plan: true`; work outside a repository is a
@@ -226,6 +226,7 @@ export function ChatPanel({
226
226
  earlierTasks,
227
227
  toolBodies,
228
228
  requestToolBody,
229
+ pinToolBody,
229
230
  state,
230
231
  connected,
231
232
  commands,
@@ -364,8 +365,13 @@ export function ChatPanel({
364
365
  // eslint-disable-next-line react-hooks/exhaustive-deps
365
366
  const rowState = useMemo(() => new RowStore(), [sessionId]);
366
367
  const bodies = useMemo(
367
- () => ({ bodies: toolBodies, request: requestToolBody, connected }),
368
- [toolBodies, requestToolBody, connected]
368
+ () => ({
369
+ bodies: toolBodies,
370
+ request: requestToolBody,
371
+ pin: pinToolBody,
372
+ connected,
373
+ }),
374
+ [toolBodies, requestToolBody, pinToolBody, connected]
369
375
  );
370
376
  const itemsById = useMemo(
371
377
  () => new Map(items.map((i) => [i.id, i])),
@@ -5,6 +5,7 @@ import { Check, ChevronRight, Loader2, Square, X } from "lucide-react";
5
5
  import type { FileDiff, ToolBody } from "@/lib/chat/events";
6
6
  import type { ToolItem } from "@/lib/chat/group";
7
7
  import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
8
+ import { needsBody } from "@/lib/chat/bodyCache";
8
9
  import { cn } from "@/lib/utils";
9
10
  import { formatElapsed } from "@/lib/chat/elapsed";
10
11
  import { Highlighted } from "./Code";
@@ -16,16 +17,21 @@ import { useRowState } from "./rowState";
16
17
  export const ToolBodies = createContext<{
17
18
  bodies: Record<string, ToolBody | null>;
18
19
  request: (id: string) => void;
20
+ // Marks a body as on screen until the returned cleanup runs.
21
+ pin: (id: string) => () => void;
19
22
  connected: boolean;
20
- }>({ bodies: {}, request: () => {}, connected: false });
23
+ }>({ bodies: {}, request: () => {}, pin: () => () => {}, connected: false });
21
24
 
22
25
  // A tool call's output and diff: its own, or once loaded. `undefined` while
23
26
  // it loads.
24
27
  export function useToolBody(tool: ToolItem): ToolBody | null | undefined {
25
- const { bodies, request, connected } = useContext(ToolBodies);
28
+ const { bodies, request, pin, connected } = useContext(ToolBodies);
26
29
  const body = tool.deferred ? bodies[tool.id] : tool;
27
30
  useEffect(() => {
28
- if (tool.deferred && body === undefined && connected) request(tool.id);
31
+ if (tool.deferred) return pin(tool.id);
32
+ }, [tool.deferred, tool.id, pin]);
33
+ useEffect(() => {
34
+ if (needsBody(tool.deferred, body, connected)) request(tool.id);
29
35
  }, [tool.deferred, tool.id, body, request, connected]);
30
36
  return body;
31
37
  }
@@ -77,6 +77,8 @@ describe("OrchestratorPinRow", () => {
77
77
  orchestrators: [],
78
78
  runningByWorkspace: new Map(),
79
79
  queuedByWorkspace: new Map(),
80
+ rollups: new Map(),
81
+ orchestratorNames: new Map(),
80
82
  orderedIds: () => [],
81
83
  cardUrl: () => null,
82
84
  onSelect,
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
6
6
  import * as DM from "@/components/ui/dropdown-menu";
7
7
  import * as CM from "@/components/ui/context-menu";
8
8
  import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
9
+ import { visibleChips } from "@/lib/sidebar/rollup";
10
+ import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
11
+ import { useSnapshot } from "valtio";
9
12
  import { orchestratorRowText } from "@/lib/sidebar/orchestrator-row";
10
13
  import { cn } from "@/lib/utils";
11
14
  import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
@@ -36,6 +39,9 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
36
39
  inReview: overview?.inReview ?? 0,
37
40
  queued: ctx.queuedByWorkspace.get(ws) ?? 0,
38
41
  });
42
+ const chips = visibleChips(ctx.rollups.get(session.id));
43
+ const { orchestratorId } = useSnapshot(sidebarUi);
44
+ const filtering = orchestratorId === session.id;
39
45
  const label = ctx.workspaceNames.get(ws) ?? "Orchestrator";
40
46
  const summary = asks ? `${status} · ${asks}` : status;
41
47
  const menu = (kind: "dropdown" | "context") => (
@@ -100,6 +106,36 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
100
106
  </>
101
107
  )}
102
108
  </span>
109
+ {chips.length > 0 && (
110
+ <button
111
+ type="button"
112
+ aria-pressed={filtering}
113
+ title={`Show only ${label}'s work`}
114
+ onClick={(e) => {
115
+ e.stopPropagation();
116
+ sidebarUiActions.setOrchestrator(
117
+ filtering ? null : session.id
118
+ );
119
+ }}
120
+ className={cn(
121
+ "text-muted-foreground -mx-1 mt-0.5 flex min-h-6 flex-wrap items-center gap-x-2.5 rounded px-1 text-xs",
122
+ "hover:bg-muted hover:text-foreground md:min-h-5",
123
+ filtering && "bg-muted text-foreground"
124
+ )}
125
+ >
126
+ {chips.map((c) => (
127
+ <span key={c.key} className="flex items-center gap-1">
128
+ <span
129
+ aria-hidden
130
+ className={cn("h-1.5 w-1.5 rounded-full", c.dot)}
131
+ />
132
+ <span className="tabular-nums">
133
+ {ctx.rollups.get(session.id)![c.key]} {c.label}
134
+ </span>
135
+ </span>
136
+ ))}
137
+ </button>
138
+ )}
103
139
  </>
104
140
  )}
105
141
  </span>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { createContext, useContext } from "react";
4
4
  import type { ProjectWithRepositories } from "@/lib/projects";
5
+ import type { Rollup } from "@/lib/sidebar/rollup";
5
6
  import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
6
7
 
7
8
  // What every row needs from the list, so shelves don't pass it along.
@@ -14,6 +15,9 @@ export interface RowContextValue {
14
15
  orchestrators: OrchestratorOverview[];
15
16
  runningByWorkspace: Map<string, number>;
16
17
  queuedByWorkspace: Map<string, number>;
18
+ // What each orchestrator session started, and its name (its workspace's).
19
+ rollups: Map<string, Rollup>;
20
+ orchestratorNames: Map<string, string>;
17
21
  // Render order, for shift-click range selection.
18
22
  orderedIds: () => string[];
19
23
  cardUrl: (sessionId: string) => string | null | undefined;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
- import { CheckSquare, GitFork, Square } from "lucide-react";
5
+ import { CheckSquare, GitFork, Square, Workflow } from "lucide-react";
6
6
  import { compactTimeAgo, fromSqliteTime } from "@/lib/session-meta";
7
7
  import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
8
8
  import { useMinuteTick } from "@/hooks/useMinuteTick";
@@ -33,6 +33,9 @@ export const SessionRow = memo(function SessionRow({
33
33
  const [renaming, setRenaming] = useState(false);
34
34
  const { session } = row;
35
35
  const active = session.id === ctx.activeSessionId;
36
+ const viaName = session.started_by
37
+ ? ctx.orchestratorNames.get(session.started_by)
38
+ : undefined;
36
39
  const selecting = selectedIds.size > 0;
37
40
  const selected = selectedIds.has(session.id);
38
41
  const onClick = (e: React.MouseEvent) => {
@@ -76,9 +79,19 @@ export const SessionRow = memo(function SessionRow({
76
79
  )
77
80
  }
78
81
  aside={
79
- session.parent_session_id && (
80
- <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
81
- )
82
+ <>
83
+ {viaName && (
84
+ <span title={`Started by ${viaName}`} className="shrink-0">
85
+ <Workflow
86
+ aria-label={`Via orchestrator: ${viaName}`}
87
+ className="text-muted-foreground/60 h-3 w-3"
88
+ />
89
+ </span>
90
+ )}
91
+ {session.parent_session_id && (
92
+ <GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
93
+ )}
94
+ </>
82
95
  }
83
96
  trailing={
84
97
  <>
@@ -2,7 +2,12 @@
2
2
 
3
3
  import type { ReactNode } from "react";
4
4
  import { ChevronDown } from "lucide-react";
5
- import { DONE_PAGE, doneLimit, type Shelves } from "@/lib/sidebar/shelves";
5
+ import {
6
+ DONE_PAGE,
7
+ doneLimit,
8
+ doneSummary,
9
+ type Shelves,
10
+ } from "@/lib/sidebar/shelves";
6
11
  import type { MachineGroup } from "@/lib/sidebar/machines";
7
12
  import { sidebarUiActions } from "@/stores/sidebarUi";
8
13
  import { cn } from "@/lib/utils";
@@ -97,6 +102,7 @@ export function SessionShelves({
97
102
  }) {
98
103
  const shown = shelves.done.slice(0, doneLimit(donePages));
99
104
  const hidden = shelves.done.length - shown.length;
105
+ const summary = doneSummary(shelves.done).text;
100
106
  return (
101
107
  <>
102
108
  {shelves.orchestrators.length + toStart.length > 0 && (
@@ -140,7 +146,7 @@ export function SessionShelves({
140
146
  {shelves.done.length > 0 && (
141
147
  <section aria-label="Done">
142
148
  <ShelfLabel
143
- label="Done"
149
+ label={doneCollapsed ? summary : "Done"}
144
150
  collapsed={doneCollapsed}
145
151
  onToggle={sidebarUiActions.toggleDone}
146
152
  />
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { AlertCircle, FolderPlus, Plus } from "lucide-react";
3
+ import { AlertCircle, FolderPlus, Plus, X } from "lucide-react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { ShimmeringLoader } from "@/components/ui/skeleton";
6
+ import { sidebarUiActions } from "@/stores/sidebarUi";
6
7
  import type { SidebarData } from "./useSidebarData";
7
8
  import { MachineShelves, SessionShelves } from "./SessionShelves";
8
9
  import { SidebarExtras } from "./SidebarExtras";
@@ -71,6 +72,21 @@ export function SidebarBody({
71
72
 
72
73
  return (
73
74
  <>
75
+ {ui.orchestratorId && (
76
+ <button
77
+ type="button"
78
+ onClick={() => sidebarUiActions.setOrchestrator(null)}
79
+ aria-label="Clear the orchestrator filter"
80
+ className="bg-muted hover:bg-muted/70 text-foreground mx-2.5 mt-2 flex min-h-8 max-w-[calc(100%-1.25rem)] items-center gap-1.5 rounded-full px-3 text-xs md:min-h-7"
81
+ >
82
+ <span className="truncate">
83
+ Showing{" "}
84
+ {data.orchestratorNames.get(ui.orchestratorId) ?? "orchestrator"}
85
+ &rsquo;s work
86
+ </span>
87
+ <X className="h-3 w-3 shrink-0" />
88
+ </button>
89
+ )}
74
90
  <DraftShelf projects={data.projects} />
75
91
  {count === 0 &&
76
92
  (ui.query ? (
@@ -110,6 +110,8 @@ export function SessionList({
110
110
  orchestrators,
111
111
  runningByWorkspace: data.runningByWorkspace,
112
112
  queuedByWorkspace: data.queuedByWorkspace,
113
+ rollups: data.rollupByOrchestrator,
114
+ orchestratorNames: data.orchestratorNames,
113
115
  cardUrl: data.taskCardUrl,
114
116
  }),
115
117
  [
@@ -122,6 +124,8 @@ export function SessionList({
122
124
  orchestrators,
123
125
  data.runningByWorkspace,
124
126
  data.queuedByWorkspace,
127
+ data.rollupByOrchestrator,
128
+ data.orchestratorNames,
125
129
  data.taskCardUrl,
126
130
  ]
127
131
  );
@@ -15,6 +15,7 @@ import { useSessionsQuery } from "@/data/sessions";
15
15
  import { useLinkedHostNames } from "@/data/hosts";
16
16
  import { useProjectsQuery } from "@/data/projects";
17
17
  import { useQueuedTasksQuery, useTasksQuery } from "@/data/tasks";
18
+ import { rollups, type Rollup } from "@/lib/sidebar/rollup";
18
19
  import { queuedRows } from "@/lib/sidebar/queued";
19
20
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
20
21
  import { useOrchestratorsQuery } from "@/data/orchestrators";
@@ -69,7 +70,7 @@ export function useSidebarData(
69
70
  );
70
71
  const project = workspaceProjects.find((p) => p.id === ui.projectId) ?? null;
71
72
 
72
- const { shelves, machines } = useMemo(() => {
73
+ const { all, shelves, machines } = useMemo(() => {
73
74
  const byId = new Map(projects.map((p) => [p.id, p]));
74
75
  const workspaceName = new Map(workspaces.map((w) => [w.id, w.name]));
75
76
  const taskStates: Record<string, TaskState> = {};
@@ -84,23 +85,32 @@ export function useSidebarData(
84
85
  projectName,
85
86
  query: ui.query,
86
87
  };
88
+ const input = {
89
+ ...common,
90
+ sessions: sessions.filter(
91
+ (s) =>
92
+ !onMachineOnly(s, linked) &&
93
+ inWorkspace(
94
+ s,
95
+ (id) => byId.get(id)?.workspace_id,
96
+ workspace?.id ?? null
97
+ )
98
+ ),
99
+ projectId: project?.id ?? null,
100
+ currentOrchestrators,
101
+ };
102
+ // The rollups count the whole list; the filter only narrows what shows.
103
+ const all = buildShelves(input);
87
104
  return {
88
- shelves: buildShelves({
89
- ...common,
90
- sessions: sessions.filter(
91
- (s) =>
92
- !onMachineOnly(s, linked) &&
93
- inWorkspace(
94
- s,
95
- (id) => byId.get(id)?.workspace_id,
96
- workspace?.id ?? null
97
- )
98
- ),
99
- projectId: project?.id ?? null,
100
- currentOrchestrators,
101
- }),
102
- // Not in any project, so not under a chosen one.
103
- machines: project ? [] : machineGroups({ ...common, sessions }, linked),
105
+ all,
106
+ shelves: ui.orchestratorId
107
+ ? buildShelves({ ...input, startedBy: ui.orchestratorId })
108
+ : all,
109
+ // Not in any project, nor started by an orchestrator.
110
+ machines:
111
+ project || ui.orchestratorId
112
+ ? []
113
+ : machineGroups({ ...common, sessions }, linked),
104
114
  };
105
115
  }, [
106
116
  linked,
@@ -113,6 +123,7 @@ export function useSidebarData(
113
123
  workspace,
114
124
  project,
115
125
  ui.query,
126
+ ui.orchestratorId,
116
127
  ]);
117
128
 
118
129
  // Per workspace, how many of its project sessions are working: the
@@ -150,7 +161,7 @@ export function useSidebarData(
150
161
  );
151
162
 
152
163
  // Tasks waiting to start, in their line's order.
153
- const queued = useMemo(
164
+ const queuedAll = useMemo(
154
165
  () =>
155
166
  queuedRows({
156
167
  queue,
@@ -161,6 +172,39 @@ export function useSidebarData(
161
172
  }),
162
173
  [queue, sessions, workspace, project, ui.query]
163
174
  );
175
+ const queued = useMemo(
176
+ () =>
177
+ ui.orchestratorId
178
+ ? queuedAll.filter((r) => r.item.startedBy === ui.orchestratorId)
179
+ : queuedAll,
180
+ [queuedAll, ui.orchestratorId]
181
+ );
182
+
183
+ // Each orchestrator's started work, keyed by its counts like the ones
184
+ // above so a push that changed none of them redraws no row.
185
+ const rollupKey = useMemo(
186
+ () => JSON.stringify([...rollups(all, queuedAll)].sort()),
187
+ [all, queuedAll]
188
+ );
189
+ const rollupByOrchestrator = useMemo(
190
+ () => new Map(JSON.parse(rollupKey) as [string, Rollup][]),
191
+ [rollupKey]
192
+ );
193
+
194
+ // Orchestrator session id -> the workspace it serves, for tags and chips.
195
+ const orchestratorKey = JSON.stringify(
196
+ sessions
197
+ .filter((s) => s.role === "orchestrator")
198
+ .map((s) => [
199
+ s.id,
200
+ workspaces.find((w) => w.id === s.workspace_id)?.name ?? s.name,
201
+ ])
202
+ .sort()
203
+ );
204
+ const orchestratorNames = useMemo(
205
+ () => new Map(JSON.parse(orchestratorKey) as [string, string][]),
206
+ [orchestratorKey]
207
+ );
164
208
 
165
209
  const taskCardUrl = useCallback(
166
210
  (id: string) => tasks.find((t) => t.id === id)?.cardUrl,
@@ -194,6 +238,8 @@ export function useSidebarData(
194
238
  projectNames,
195
239
  runningByWorkspace,
196
240
  queuedByWorkspace,
241
+ rollupByOrchestrator,
242
+ orchestratorNames,
197
243
  workspaces,
198
244
  workspace,
199
245
  workspaceProjects,
@@ -19,6 +19,7 @@ import type {
19
19
  UndoPreview,
20
20
  } from "@/lib/chat/events";
21
21
  import type { SearchResult } from "@/lib/chat/find";
22
+ import { addBody, touchBody } from "@/lib/chat/bodyCache";
22
23
 
23
24
  // The index of the item with `id`, searched from the end: what changes is
24
25
  // almost always the latest.
@@ -129,6 +130,13 @@ export function useChat(
129
130
  const [toolBodies, setToolBodies] = useState<Record<string, ToolBody | null>>(
130
131
  {}
131
132
  );
133
+ // Bodies open on screen, never evicted from toolBodies.
134
+ const visibleBodies = useRef(new Set<string>());
135
+ const pinToolBody = useCallback((id: string) => {
136
+ visibleBodies.current.add(id);
137
+ setToolBodies((prev) => touchBody(prev, id));
138
+ return () => void visibleBodies.current.delete(id);
139
+ }, []);
132
140
  const [state, setState] = useState<ChatState>("idle");
133
141
  const [connected, setConnected] = useState(false);
134
142
  const [commands, setCommands] = useState<ChatCommand[]>([]);
@@ -249,7 +257,9 @@ export function useChat(
249
257
  const have = new Set(prev.map((p) => p.id));
250
258
  setItems(m.items.filter((i) => !have.has(i.id)).concat(prev));
251
259
  } else if (m.type === "tool_body") {
252
- setToolBodies((prev) => ({ ...prev, [m.id]: m.body }));
260
+ setToolBodies((prev) =>
261
+ addBody(prev, m.id, m.body, visibleBodies.current)
262
+ );
253
263
  } else if (m.type === "item") {
254
264
  withDeltas(() => {
255
265
  const routed = routeItem(
@@ -457,6 +467,7 @@ export function useChat(
457
467
  earlierTasks,
458
468
  toolBodies,
459
469
  requestToolBody,
470
+ pinToolBody,
460
471
  state,
461
472
  connected,
462
473
  commands,
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { WATCHED_TABLES } from "@/lib/db/changes";
4
4
  import { gitKeys } from "../git/keys";
5
+ import { taskKeys } from "../tasks/keys";
5
6
  import { sessionKeys } from "../sessions/keys";
6
7
  import { ALL_TOPICS, invalidateTopics, topicKeys } from "./topics";
7
8
 
@@ -16,6 +17,14 @@ describe("pushed topics", () => {
16
17
  expect(topicKeys(topic), topic).not.toEqual([]);
17
18
  });
18
19
 
20
+ it("a snapshot refetches the queue and every other watched table", () => {
21
+ for (const table of WATCHED_TABLES) expect(ALL_TOPICS).toContain(table);
22
+ const client = new QueryClient();
23
+ client.setQueryData(taskKeys.queue(), []);
24
+ invalidateTopics(client, ALL_TOPICS);
25
+ expect(client.getQueryState(taskKeys.queue())?.isInvalidated).toBe(true);
26
+ });
27
+
19
28
  it("maps a git folder and a session's setup to their queries", () => {
20
29
  expect(topicKeys("git:~/app")).toContainEqual(gitKeys.status("~/app"));
21
30
  expect(topicKeys("git:~/app")).toContainEqual([
@@ -1,4 +1,5 @@
1
1
  import type { QueryClient, QueryKey } from "@tanstack/react-query";
2
+ import { WATCHED_TABLES } from "@/lib/db/changes";
2
3
  import { sessionKeys } from "../sessions/keys";
3
4
  import { taskKeys } from "../tasks/keys";
4
5
  import { projectKeys } from "../projects/keys";
@@ -88,19 +89,7 @@ export function invalidateTopics(
88
89
  }
89
90
 
90
91
  // Every pushed topic but git folders: after a gap the stream can't replay.
91
- export const ALL_TOPICS = [
92
- "sessions",
93
- "projects",
94
- "workspaces",
95
- "hosts",
96
- "discovered",
97
- "stacks",
98
- "bus_messages",
99
- "schedules",
100
- "orchestrator_asks",
101
- "orchestrator_rules",
102
- "away_periods",
103
- "dev_servers",
104
- ];
92
+ // Every table the triggers watch is in it, so a new one can't be left out.
93
+ export const ALL_TOPICS = [...new Set([...WATCHED_TABLES, "discovered"])];
105
94
 
106
95
  export { keysFor as topicKeys };
@@ -0,0 +1,69 @@
1
+ import type { ToolBody } from "./events";
2
+
3
+ // Opened tool output kept in memory, least recently used first out.
4
+ export const MAX_LOADED_BODIES = 40;
5
+ export const MAX_LOADED_BODY_CHARS = 5_000_000;
6
+
7
+ export type Bodies = Record<string, ToolBody | null>;
8
+
9
+ export function bodySize(body: ToolBody | null): number {
10
+ if (!body) return 0;
11
+ const d = body.diff;
12
+ return (
13
+ (body.output?.length ?? 0) +
14
+ (d ? d.path.length + d.before.length + d.after.length : 0)
15
+ );
16
+ }
17
+
18
+ // Object keys keep insertion order, so the first key is the least recently
19
+ // used. Puts `id` last, loaded or being opened again.
20
+ export function touchBody(bodies: Bodies, id: string): Bodies {
21
+ if (!(id in bodies) || Object.keys(bodies).pop() === id) return bodies;
22
+ const { [id]: body, ...rest } = bodies;
23
+ return { ...rest, [id]: body };
24
+ }
25
+
26
+ // Drops the least recently used bodies until both limits hold. A body on
27
+ // screen stays, even when that leaves the cache over a limit.
28
+ export function capBodies(
29
+ bodies: Bodies,
30
+ visible: ReadonlySet<string>
31
+ ): Bodies {
32
+ const ids = Object.keys(bodies);
33
+ let count = ids.length;
34
+ let chars = ids.reduce((n, id) => n + bodySize(bodies[id]), 0);
35
+ if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS)
36
+ return bodies;
37
+ const drop = new Set<string>();
38
+ for (const id of ids) {
39
+ if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS) break;
40
+ if (visible.has(id)) continue;
41
+ drop.add(id);
42
+ count--;
43
+ chars -= bodySize(bodies[id]);
44
+ }
45
+ return Object.fromEntries(
46
+ ids.filter((id) => !drop.has(id)).map((id) => [id, bodies[id]])
47
+ );
48
+ }
49
+
50
+ // A body arriving: it is the most recently used.
51
+ export function addBody(
52
+ bodies: Bodies,
53
+ id: string,
54
+ body: ToolBody | null,
55
+ visible: ReadonlySet<string>
56
+ ): Bodies {
57
+ const { [id]: _old, ...rest } = bodies;
58
+ return capBodies({ ...rest, [id]: body }, visible);
59
+ }
60
+
61
+ // Whether an opened tool must ask for its body: deferred, not loaded (never
62
+ // was, or evicted), and the socket is up.
63
+ export function needsBody(
64
+ deferred: boolean | undefined,
65
+ body: ToolBody | null | undefined,
66
+ connected: boolean
67
+ ): boolean {
68
+ return !!deferred && body === undefined && connected;
69
+ }