paseo-follow-up 1.3.0-beta.3 → 1.3.0-beta.4

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
@@ -116,7 +116,7 @@ The Follow-up sidebar row (and ⌘K → **Follow-up: Home**) opens **Home**: eve
116
116
  Install the beta:
117
117
 
118
118
  ```sh
119
- paseo plugin update follow-through --version 1.3.0-beta.3
119
+ paseo plugin update follow-through --version 1.3.0-beta.4
120
120
  # or, for a fresh install:
121
121
  paseo plugin install npm:paseo-follow-up@beta
122
122
  ```
@@ -6,6 +6,7 @@ import { FIELD_LABEL, excerpt, highlightRuns, homeSearchDocs, indexDocs, omnibox
6
6
  import { SORT_HINT, SORT_LABEL, SORT_MODES, type SortMode } from "../shared/sort";
7
7
  import { priorityNote, PRIORITY_SOURCE_LABEL } from "../shared/priorities";
8
8
  import { meters, meterText, type Meter } from "../shared/usage";
9
+ import { resourceMeter, type Resources } from "../shared/resources";
9
10
  import type { HomeSources } from "./home-data";
10
11
  import type { Workstream } from "../shared/home";
11
12
  import { Age, PrChipView } from "./home-list";
@@ -171,10 +172,12 @@ export function UsageMeters({ theme, sources, dense }: { theme: Theme; sources:
171
172
  const now = useMinute();
172
173
  const list = useMemo(() => meters(usage, Math.max(now, Date.now())), [usage, now]);
173
174
  const [open, setOpen] = useState<string | null>(null);
174
- if (!list.length) return null;
175
+ const vm = usage.resources && resourceMeter(usage.resources, now) ? usage.resources : null;
176
+ if (!list.length && !vm) return null;
175
177
  const detail = list.find(m => m.family === open);
176
178
  return <View style={{ paddingHorizontal: dense ? 8 : 16, gap: 4 }}>
177
179
  <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 12, rowGap: 2 }}>
180
+ {vm && <VmMeter r={vm} c={c} now={Math.max(now, Date.now())} />}
178
181
  {list.map(m => <Pressable key={m.family} accessibilityRole="button" accessibilityLabel={`${meterText(m, now)}. ${m.accounts.length > 1 ? `${m.accounts.length} accounts; ` : ""}show detail`}
179
182
  aria-expanded={open === m.family} onPress={() => setOpen(o => o === m.family ? null : m.family)}
180
183
  style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 22 }}>
@@ -191,6 +194,21 @@ export function UsageMeters({ theme, sources, dense }: { theme: Theme; sources:
191
194
  </View>}
192
195
  </View>;
193
196
  }
197
+ /**
198
+ * "Paseo memory 72% · CPU 40%", the resource governor's view. Text stays muted like the provider meters; the bar
199
+ * and the "· paused" suffix take amber or red when Follow-up has held back its own work. Old readings dim.
200
+ */
201
+ function VmMeter({ r, c, now }: { r: Resources; c: Colors; now: number }) {
202
+ const m = resourceMeter(r, now);
203
+ if (!m) return null;
204
+ const color = toneColor(m, c);
205
+ return <View accessibilityRole="text" accessibilityLabel={m.label} style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 22, opacity: m.stale ? 0.6 : 1 }}>
206
+ <Bar pct={m.pct} color={color} c={c} />
207
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 11, fontVariant: ["tabular-nums"] }}>
208
+ {m.text}{m.suffix ? <Text style={{ color: m.tone === "ok" ? c.foregroundMuted : color, fontWeight: "500" }}>{` ${m.suffix}`}</Text> : null}
209
+ </Text>
210
+ </View>;
211
+ }
194
212
  function Bar({ pct, color, c }: { pct: number; color: string; c: Colors }) {
195
213
  return <View style={{ width: 28, height: 4, borderRadius: 2, backgroundColor: c.border, overflow: "hidden" }}>
196
214
  <View style={{ width: `${Math.min(100, pct)}%`, height: 4, backgroundColor: color === c.foregroundMuted ? withAlpha(c.foreground, 0.45) : color }} />
package/client/home.tsx CHANGED
@@ -128,36 +128,45 @@ function useAgentLive(agentId: string | null) {
128
128
  return { handle, permissions: (handle?.pendingPermissions ?? []) as unknown as Permission[], status: handle?.status ?? null };
129
129
  }
130
130
 
131
- /** The last steps Home read per agent this session, shown while the agent is unloaded. */
132
- const seenSteps = new Map<string, TailStep[]>();
131
+ /** The last steps Home read per agent this session, and when, shown while the agent isn't loaded. */
132
+ const seenSteps = new Map<string, { steps: TailStep[]; at: number }>();
133
133
  /**
134
134
  * "Recent · last N steps", read only for the selected agent, refreshed when its status or flag changes.
135
- * Reading a timeline loads an unloaded agent for an hour (see server/agent-load.ts), so an unloaded agent
136
- * shows the steps Home saw last and waits for an explicit "Load recent steps".
135
+ * Reading a timeline loads an unloaded agent for an hour (see shared/agent-load.ts), so an agent that isn't loaded
136
+ * shows the steps Home saw last (marked with when) and waits for an explicit "Load recent steps".
137
137
  */
138
138
  function useTail(handle: ReturnType<typeof useAgentLive>["handle"], key: string) {
139
139
  const [steps, setSteps] = useState<TailStep[] | null>(null);
140
140
  const [error, setError] = useState<string | null>(null);
141
+ const [seenAt, setSeenAt] = useState<number | null>(null);
141
142
  const [wake, setWake] = useState(0);
142
143
  const [asleep, setAsleep] = useState(false);
143
- useEffect(() => { setWake(0); }, [handle]);
144
+ const [loading, setLoading] = useState(false);
145
+ const [unknown, setUnknown] = useState(false);
146
+ useEffect(() => { setWake(0); setUnknown(false); }, [handle]);
147
+ // A snapshot that never arrives counts as not loaded instead of spinning forever.
148
+ useEffect(() => { if (!handle) return; const id = setTimeout(() => setUnknown(true), 5_000); return () => clearTimeout(id); }, [handle]);
144
149
  useEffect(() => {
145
150
  if (!handle) { setSteps([]); return; }
146
151
  const current = handle.current();
147
- if (!current) return;
148
- const loaded = isLoaded(current as unknown as LoadSignals);
149
- setAsleep(!loaded);
150
- if (!loaded && !wake) { setSteps(seenSteps.get(handle.id) ?? []); setError(null); return; }
151
- let live = true;
152
+ if (!current && !unknown) return;
153
+ const loaded = !!current && isLoaded(current as unknown as LoadSignals);
154
+ if (!loaded && !wake) {
155
+ const seen = seenSteps.get(handle.id);
156
+ setAsleep(true); setSteps(seen?.steps ?? []); setSeenAt(seen?.at ?? null); setError(null); return;
157
+ }
158
+ setAsleep(false);
159
+ let live = true; setLoading(true);
152
160
  handle.timeline.refetch({ direction: "tail", limit: 40 }).then(page => {
153
161
  if (!live) return;
154
162
  const entries = [...(page as unknown as { entries: { seqStart: number; item: Parameters<typeof tailSteps>[0][number] }[] }).entries].sort((a, b) => a.seqStart - b.seqStart);
155
163
  const next = tailSteps(entries.map(e => e.item), 6);
156
- seenSteps.set(handle.id, next); setSteps(next); setError(null);
157
- }).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } });
164
+ seenSteps.set(handle.id, { steps: next, at: Date.now() }); setSteps(next); setSeenAt(null); setError(null);
165
+ }).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } })
166
+ .finally(() => { if (live) setLoading(false); });
158
167
  return () => { live = false; };
159
- }, [handle, key, wake]);
160
- return { steps, error, asleep: asleep && !wake, load: () => setWake(n => n + 1) };
168
+ }, [handle, key, wake, unknown]);
169
+ return { steps, error, seenAt, asleep, loading, woken: wake > 0, load: () => setWake(n => n + 1) };
161
170
  }
162
171
 
163
172
  function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onBack }: {
@@ -230,15 +239,16 @@ function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onB
230
239
  actions={{ openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion, openAgent: () => openFull(s) }} />}
231
240
  {actionBar}
232
241
  {s.agentId && <View style={{ gap: 8 }}>
233
- <Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>Recent · last {tail.steps?.length || 6} steps</Text>
242
+ <Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>{tail.asleep && tail.seenAt ? `Last seen · ${age(new Date(tail.seenAt).toISOString(), Date.now()) === "now" ? "just now" : `${age(new Date(tail.seenAt).toISOString(), Date.now())} ago`}` : tail.steps?.length ? `Recent · last ${tail.steps.length} steps` : "Recent steps"}</Text>
243
+ {!!tail.error && <Text style={t.meta}>{`Couldn't load the agent's steps: ${tail.error}`}</Text>}
234
244
  {tail.steps === null ? <Spinner color={c.foregroundMuted} size={12} label="Loading the agent's steps" />
235
- : tail.steps.length === 0 ? <Text style={t.meta}>{tail.error ? `Couldn't load the agent's steps: ${tail.error}` : tail.asleep ? "This agent is asleep, so its steps aren't loaded." : "No steps yet."}</Text>
245
+ : tail.steps.length === 0 ? (tail.error ? null : <Text style={t.meta}>{tail.asleep ? "This agent isn't loaded, so its steps aren't shown." : "No steps yet."}</Text>)
236
246
  : tail.steps.map((step, i) => <Step key={i} step={step} c={c} last={i === tail.steps!.length - 1} />)}
237
- {tail.asleep && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
238
- <Button theme={theme} compact={compact} kind="secondary" icon="RefreshCw" title="Load recent steps" accessibilityHint="Wakes the agent to read its steps. It stays loaded for about an hour." onPress={tail.load} />
239
- <Text style={[t.small, { flex: 1, minWidth: 160 }]}>Wakes the agent, which keeps it in memory for about an hour.</Text>
247
+ {(tail.asleep || (tail.woken && tail.loading)) && <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
248
+ <Button theme={theme} compact={compact} kind="secondary" title={tail.loading ? "Loading…" : "Load recent steps"} loading={tail.loading} disabled={tail.loading} onPress={tail.load} />
249
+ <Text style={[t.small, { flex: 1, minWidth: 160 }]}>Loads the agent into memory for about an hour.</Text>
240
250
  </View>}
241
- <Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>
251
+ {!!tail.steps?.length && <Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>}
242
252
  </View>}
243
253
  </ScrollView>
244
254
  {s.agentId && <ReplyBox theme={theme} compact={compact} agentName={s.title} onSend={async text => {
@@ -3,10 +3,13 @@ import { inboxActRpc, inboxListRpc } from "../shared/contracts";
3
3
  import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
4
4
  import type { InboxRow } from "../shared/inbox";
5
5
  import { subscribeDisplay } from "./display";
6
+ import { onVisible, pollDelay } from "./visibility";
6
7
 
7
8
  type List = RpcOutput<typeof inboxListRpc>;
8
9
  export type Recent = { agentIds: string[]; label: string; at: number; batch: boolean };
9
10
  const POLL_MS = 4_000;
11
+ /** While the app is hidden (1.3.0-beta.4). */
12
+ const HIDDEN_POLL_MS = 30_000;
10
13
 
11
14
  /**
12
15
  * One shared poll of the inbox for every surface on this client. Sign-offs apply optimistically;
@@ -47,13 +50,14 @@ export function createInboxStore(client: PluginClientContext) {
47
50
  } catch {
48
51
  failures++;
49
52
  if (failures >= 2 && offlineSince === null) { offlineSince = Date.now(); emit(); }
50
- } finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
53
+ } finally { if (!stopped) timer = setTimeout(() => void poll(), pollDelay(POLL_MS, HIDDEN_POLL_MS)); }
51
54
  };
52
55
  const refresh = () => { if (timer) clearTimeout(timer); void poll(); };
53
56
  const unsubscribe = subscribeDisplay(values => {
54
57
  const on = values.enable_inbox !== false;
55
58
  if (on !== enabled) { enabled = on; emit(); if (on) refresh(); }
56
59
  });
60
+ const offVisible = onVisible(refresh);
57
61
  void poll();
58
62
 
59
63
  const patch = (agentIds: string[], change: (row: InboxRow) => InboxRow) => {
@@ -91,7 +95,7 @@ export function createInboxStore(client: PluginClientContext) {
91
95
  recent: () => recent,
92
96
  clearRecent() { recent = null; emit(); },
93
97
  refresh,
94
- stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
98
+ stop() { stopped = true; offVisible(); if (timer) clearTimeout(timer); listeners.clear(); unsubscribe(); },
95
99
 
96
100
  /** Sign off now; queue for replay when the host can't be reached. */
97
101
  async signOff(requested: string[], options: { muteTurn?: boolean } = {}) {
package/client/live.tsx CHANGED
@@ -8,8 +8,11 @@ import { needsYou } from "../shared/attention";
8
8
  import { useSidebarViewActive } from "./inbox";
9
9
  import { homeCounts } from "../shared/home";
10
10
  import { useHomeModel, type HomeSources } from "./home-data";
11
+ import { onVisible, pollDelay } from "./visibility";
11
12
 
12
13
  const POLL_MS = 8_000;
14
+ /** While the app is hidden. */
15
+ const HIDDEN_POLL_MS = 60_000;
13
16
 
14
17
  /** One shared poll of tracked workspaces for the pills, header-button dots and the sidebar count. */
15
18
  export function createLiveStore(client: PluginClientContext) {
@@ -20,17 +23,27 @@ export function createLiveStore(client: PluginClientContext) {
20
23
  const listeners = new Set<() => void>();
21
24
  let timer: ReturnType<typeof setTimeout> | null = null;
22
25
  let stopped = false;
26
+ /** The worker's list revision this store holds; each poll asks only for what changed since (1.3.0-beta.4). */
27
+ let revision: number | undefined;
28
+ let all = new Map<string, Workspace>();
23
29
  const emit = () => { version++; for (const l of listeners) l(); };
24
30
  const poll = async () => {
25
31
  try {
26
- const result = await client.rpc(listRpc, {});
32
+ const result = await client.rpc(listRpc, revision === undefined ? {} : { since: revision });
27
33
  if (stopped) return;
28
- workspaces = new Map(result.workspaces.filter(w => !w.archived).map(w => [w.id, w]));
29
- archived = result.workspaces.filter(w => w.archived);
34
+ const diff = result.full === false;
35
+ if (diff && !result.workspaces.length && !result.removed?.length) return;
36
+ const next = diff ? new Map(all) : new Map<string, Workspace>();
37
+ for (const id of result.removed ?? []) next.delete(id);
38
+ for (const w of result.workspaces) next.set(w.id, w);
39
+ all = next; revision = result.revision;
40
+ workspaces = new Map([...all.values()].filter(w => !w.archived).map(w => [w.id, w]));
41
+ archived = [...all.values()].filter(w => w.archived);
30
42
  emit();
31
43
  } catch { /* Keep the last answer through restarts and disconnects. */ }
32
- finally { if (!stopped) timer = setTimeout(() => void poll(), POLL_MS); }
44
+ finally { if (!stopped) timer = setTimeout(() => void poll(), pollDelay(POLL_MS, HIDDEN_POLL_MS)); }
33
45
  };
46
+ const offVisible = onVisible(() => { if (timer) clearTimeout(timer); void poll(); });
34
47
  void poll();
35
48
  return {
36
49
  subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
@@ -39,7 +52,7 @@ export function createLiveStore(client: PluginClientContext) {
39
52
  all: () => [...workspaces.values()],
40
53
  archived: () => archived,
41
54
  refresh: () => { if (timer) clearTimeout(timer); void poll(); },
42
- stop() { stopped = true; if (timer) clearTimeout(timer); listeners.clear(); },
55
+ stop() { stopped = true; offVisible(); if (timer) clearTimeout(timer); listeners.clear(); },
43
56
  };
44
57
  }
45
58
  export type LiveStore = ReturnType<typeof createLiveStore>;
@@ -61,7 +61,7 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
61
61
  onValueChange={value => change.mutate({ enable_follow_every: value })} />
62
62
  <SettingsSwitch label="Send repairs automatically" hint="Review threads and red CI go to the workspace's agent on their own. Applies to workspaces Follow-up starts following from now on." value={data.enable_auto_sends_default} disabled={change.isPending}
63
63
  onValueChange={value => change.mutate({ enable_auto_sends_default: value })} />
64
- <SettingsSelect label="Watch closely for" hint="A workspace with no message from you in this long, no loaded agent and no open PR rests. It stays listed but isn't checked, and nothing is sent to it, until a turn starts there."
64
+ <SettingsSelect label="Rest workspaces after" hint="A workspace rests when you haven't messaged it in this long, its agent isn't loaded and it has no open PR. It stays listed, but isn't checked and gets nothing sent until you start a turn there."
65
65
  value={String(windowDays)} disabled={change.isPending}
66
66
  options={[...new Set([1, 3, 7, 14, windowDays])].sort((a, b) => a - b).map(d => ({ value: String(d), label: d === 1 ? "1 day" : `${d} days` }))}
67
67
  onValueChange={value => change.mutate({ activeWindowDays: Number(value) })} />
@@ -69,14 +69,14 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
69
69
  </SettingsSection>
70
70
  {!!data?.workspaces.length && <SettingsSection title="Workspace exceptions" info="Stop following one workspace, or change its auto-send. Following it again picks up its PRs on the next refresh.">
71
71
  <SettingsCard>
72
- <SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: w.title }))]} onValueChange={setPicked} />
72
+ <SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: w.dormant ? `${w.title} · resting` : w.title }))]} onValueChange={setPicked} />
73
73
  {chosen && <SettingsSwitch label="Follow this workspace" value={!chosen.optedOut && chosen.enable_tracking} disabled={change.isPending}
74
74
  onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, follow: value } }, { onSuccess: () => toast.show(value ? `Following ${chosen.title}` : `Stopped following ${chosen.title}`, { variant: "success" }) })} />}
75
- {chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" hint={chosen.parked ? "Resting. Turns back on by itself when the workspace is active again." : chosen.dormant ? "Resting. Nothing is sent until a turn starts there." : undefined} value={chosen.enable_auto_sends} disabled={change.isPending}
75
+ {chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" hint={chosen.parked ? "Resting. Auto-send was paused and comes back when the workspace is active again. Changing it here makes your choice stick." : chosen.dormant ? "Resting. Nothing is sent until you start a turn there." : undefined} value={chosen.enable_auto_sends} disabled={change.isPending}
76
76
  onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, enable_auto_sends: value } })} />}
77
77
  </SettingsCard>
78
78
  {exceptions.length > 0 && <SettingsCard>
79
- {exceptions.map(w => <SettingsAction key={w.id} label={w.title} hint={w.optedOut || !w.enable_tracking ? "Not followed" : w.enable_auto_sends ? "Sends repairs automatically" : "Repairs wait for you"}
79
+ {exceptions.map(w => <SettingsAction key={w.id} label={w.title} hint={(w.optedOut || !w.enable_tracking ? "Not followed" : w.enable_auto_sends ? "Sends repairs automatically" : "Repairs wait for you") + (w.dormant && !w.optedOut && w.enable_tracking ? " · resting" : "")}
80
80
  actionLabel="Edit" onPress={() => setPicked(w.id)} />)}
81
81
  </SettingsCard>}
82
82
  {!exceptions.length && <Text style={t.meta}>No exceptions. Every workspace is followed the same way.</Text>}
@@ -0,0 +1,24 @@
1
+ import { AppState } from "react-native";
2
+
3
+ type Doc = { visibilityState?: string; addEventListener(type: string, fn: () => void): void; removeEventListener(type: string, fn: () => void): void };
4
+ const doc = (): Doc | undefined => (globalThis as { document?: Doc }).document;
5
+
6
+ /**
7
+ * Whether the app is in the foreground (1.3.0-beta.4). Background polls slow down instead of flooding the daemon
8
+ * from a hidden window. Unknown counts as visible.
9
+ */
10
+ export function appVisible(): boolean {
11
+ try {
12
+ const d = doc(); if (d?.visibilityState) return d.visibilityState === "visible";
13
+ return AppState.currentState !== "background" && AppState.currentState !== "inactive";
14
+ } catch { return true; }
15
+ }
16
+ /** Calls `wake` when the app comes back to the foreground, so a slowed poll refreshes right away. */
17
+ export function onVisible(wake: () => void): () => void {
18
+ const subscription = (() => { try { return AppState.addEventListener("change", state => { if (state === "active") wake(); }); } catch { return null; } })();
19
+ const listener = () => { if (appVisible()) wake(); };
20
+ try { doc()?.addEventListener("visibilitychange", listener); } catch { /* no DOM */ }
21
+ return () => { subscription?.remove(); try { doc()?.removeEventListener("visibilitychange", listener); } catch { /* no DOM */ } };
22
+ }
23
+ /** The poll interval for now: `visibleMs` in the foreground, `hiddenMs` otherwise. */
24
+ export const pollDelay = (visibleMs: number, hiddenMs: number): number => appVisible() ? visibleMs : hiddenMs;
package/index.server.ts CHANGED
@@ -34,8 +34,13 @@ import { fileSources } from "./server/inbox-classifier";
34
34
  import { onBeforeSend } from "./server/daemon";
35
35
  import { copyFile, readFile, rename } from "node:fs/promises";
36
36
  import { HistoryCache } from "./server/history-cache";
37
+ import { Governor } from "./server/governor";
38
+ import { ListCache } from "./server/list-cache";
39
+ import { scopeWriter } from "./server/scopes";
40
+ import { batchArchive, pruneUnsent } from "./server/batch-archive";
41
+ import { NO_USAGE } from "./shared/usage";
37
42
  import { conversationGone, DEFAULT_ACTIVE_WINDOW_DAYS } from "./server/agent-load";
38
- import { ACTIVE_SCOPE_MIGRATION, autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
43
+ import { ACTIVE_SCOPE_MIGRATION, PRUNE_MIGRATION, autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
39
44
  import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
40
45
 
41
46
  export default function contribute(server: PluginServerContext) {
@@ -45,7 +50,8 @@ export default function contribute(server: PluginServerContext) {
45
50
  const questionMode = async (): Promise<QuestionMode> => { try { const state = await questionSettings.read(); return state.status === "ready" ? state.values.mode : DEFAULT_QUESTION_MODE; } catch { return DEFAULT_QUESTION_MODE; } };
46
51
  const directory = join(paseoHome, "plugin-data", "follow-through");
47
52
  const bridge = join(directory, "mcp.mjs");
48
- const store = new Store(directory);
53
+ const store = new Store(directory, 1_000);
54
+ store.onChanged = scopeWriter(directory);
49
55
  const agentHooks = new AgentHooks(directory, clientEntry());
50
56
  const gateway = new PaseoGateway(undefined, undefined, undefined, HistoryCache.at(directory));
51
57
  const graphite = new Graphite();
@@ -53,6 +59,11 @@ export default function contribute(server: PluginServerContext) {
53
59
  const browser = new BrowserBridge();
54
60
  const actions = new PrActions(store, github, graphite, browser);
55
61
  const engine = new Engine(store, github, gateway);
62
+ const governor = new Governor();
63
+ const listCache = new ListCache();
64
+ engine.paused = () => !governor.allowsBackground();
65
+ const archive = batchArchive(directory);
66
+ engine.archive = archive;
56
67
  const usage = new UsageReader(paseoHome);
57
68
  // Unknown is not off: an unreadable document keeps the inbox on with its defaults.
58
69
  const inboxSettings = async () => { try { const state = await displaySettings.read(); if (state.status === "ready") return state.values; } catch {} return displayPreferences.schema.parse({}); };
@@ -121,6 +132,8 @@ export default function contribute(server: PluginServerContext) {
121
132
  const inboxLoop = async () => {
122
133
  await ready;
123
134
  if (stopped || loadError) return;
135
+ // Critical memory: the inbox's background reads wait too; hooks still record live events.
136
+ if (!governor.allowsExtras()) { if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000); return; }
124
137
  try { await writeBriefState().catch(() => undefined); await inbox.tick(); await inbox.observeGuard(); await inbox.observeFollowUp(await store.snapshot()); inboxError = null; }
125
138
  catch (error) { inboxError = error instanceof Error ? error.message : "The Needs-you inbox couldn't update."; }
126
139
  if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000);
@@ -143,6 +156,17 @@ export default function contribute(server: PluginServerContext) {
143
156
  await copyFile(join(directory, "state.json"), join(directory, "state.before-follow-every.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
144
157
  await store.mutate(state => { migrateFollowEvery(state); });
145
158
  };
159
+ /**
160
+ * 1.3.0-beta.4: once, back up state.json and move never-sent cancelled batches beyond the newest few per
161
+ * workspace to the archive (see pruneUnsent). Archived first, so nothing is lost if the write fails.
162
+ */
163
+ const pruneHistory = async () => {
164
+ if ((await store.snapshot()).migrations?.includes(PRUNE_MIGRATION)) return;
165
+ await copyFile(join(directory, "state.json"), join(directory, "state.before-prune.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
166
+ const preview = await store.read();
167
+ for (const w of preview.workspaces) await archive(w.id, pruneUnsent(w));
168
+ await store.mutate(state => { for (const w of state.workspaces) pruneUnsent(w); state.migrations = [...state.migrations ?? [], PRUNE_MIGRATION]; });
169
+ };
146
170
  /** 1.3.0-beta.3: back up once before the active-scope migration (applied by the engine after its first discovery). */
147
171
  const backupActiveScope = async () => {
148
172
  if ((await store.snapshot()).migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return;
@@ -152,9 +176,12 @@ export default function contribute(server: PluginServerContext) {
152
176
  unlock = await store.lock();
153
177
  await mkdir(directory, { recursive: true, mode: 0o700 });
154
178
  await writeFile(bridge, mcpSource, { mode: 0o600 });
179
+ governor.start();
155
180
  await engine.recover();
156
181
  await migrateTracking();
157
182
  await backupActiveScope();
183
+ await pruneHistory();
184
+ await store.onChanged(await store.snapshot()).catch(() => undefined);
158
185
  hooksStarted = true;
159
186
  await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; following still works."; });
160
187
  hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
@@ -256,7 +283,7 @@ export default function contribute(server: PluginServerContext) {
256
283
  const view = browserWorkspaces(entries);
257
284
  return { tracked: true, ...view, workspaces: view.workspaces.map(w => ({ ...w, title: w.title.slice(0, 200), agentStatus: w.agentStatus.slice(0, 40), state: w.state.slice(0, 200) })) };
258
285
  });
259
- server.handle(rpc.listRpc, async () => { await ready; return { workspaces: (await store.snapshot()).workspaces.filter(w => w.enable_tracking), error: loadError ?? hookError ?? engine.error }; });
286
+ server.handle(rpc.listRpc, async ({ since }) => { await ready; return { ...listCache.answer(await store.snapshot(), since), error: loadError ?? hookError ?? engine.error }; });
260
287
  server.handle(rpc.getRpc, async ({ workspaceId }) => { await ready; return { workspace: (await store.snapshot()).workspaces.find(w => w.id === workspaceId && w.enable_tracking) ?? null, error: loadError ?? hookError ?? engine.error }; });
261
288
  server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
262
289
  await requireReady();
@@ -440,7 +467,8 @@ export default function contribute(server: PluginServerContext) {
440
467
  const priorityStore = new PriorityStore(directory);
441
468
  const agentActions = new AgentActions({ store, prActions: actions, engine, inbox, priorities: priorityStore });
442
469
  server.handle(rpc.prioritiesRpc, async () => ({ items: await priorityStore.read() }));
443
- server.handle(rpc.usageRpc, async () => usage.read());
470
+ // Provider limits are non-essential: at critical memory only the VM meter is served.
471
+ server.handle(rpc.usageRpc, async () => ({ ...(governor.allowsExtras() ? await usage.read() : NO_USAGE), resources: governor.current() }));
444
472
  server.handle(rpc.agentActRpc, async ({ agentId, tool, args }, { paseo }) => {
445
473
  await requireReady(); seen(paseo);
446
474
  if (!(AGENT_TOOLS as readonly string[]).includes(tool)) throw new Error(`Unknown Follow-up tool ${tool}.`);
@@ -598,8 +626,8 @@ export default function contribute(server: PluginServerContext) {
598
626
  server.on("workspace.archived", async event => { await ready; if (!loadError && !stopped) await store.mutate(state => { const w = state.workspaces.find(w => w.id === event.workspace.id); if (w) w.archived = true; }); }),
599
627
  ];
600
628
  return async () => {
601
- stopped = true; if (timer) clearTimeout(timer); if (inboxTimer) clearTimeout(inboxTimer); inbox.stop(); if (hookTimer) clearInterval(hookTimer); removers.forEach(remove => remove());
629
+ stopped = true; governor.stop(); if (timer) clearTimeout(timer); if (inboxTimer) clearTimeout(inboxTimer); inbox.stop(); if (hookTimer) clearInterval(hookTimer); removers.forEach(remove => remove());
602
630
  await ready; if (hooksStarted) await agentHooks.stop().catch(() => {});
603
- await actions.close(); await closeConnections(); await engine.stop(); await ready; await unlock?.();
631
+ await actions.close(); await closeConnections(); await engine.stop(); await ready; await store.flush().catch(() => undefined); await unlock?.();
604
632
  };
605
633
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "1.3.0-beta.3",
4
+ "version": "1.3.0-beta.4",
5
5
  "description": "Follow every workspace PR stack in Paseo, answer agent questions, batch repairs for the main agent, and see what needs you.",
6
6
  "type": "module",
7
7
  "files": [
@@ -24,10 +24,11 @@
24
24
  "scripts": {
25
25
  "typecheck": "tsc --noEmit",
26
26
  "test": "tsx --test tests/*.test.ts",
27
- "verify": "npm run typecheck && npm test && node --test button-visibility.test.mjs",
27
+ "verify": "npm run typecheck && npm test && node --test button-visibility.test.mjs && npm run test:scale",
28
28
  "preview": "node scripts/check-install.mjs --keep",
29
29
  "test:install": "node scripts/check-install.mjs",
30
- "prepare": "node scripts/build.mjs"
30
+ "prepare": "node scripts/build.mjs",
31
+ "test:scale": "node scripts/scale-check.mjs --workspaces 120 --awake 5 --churn 35 --idle 30 --rss-budget 240 --cpu-budget 3"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@getpaseo/client": "0.14.0",
@@ -59,9 +59,15 @@ async function main() {
59
59
  if (event.agent_id || event.hook_event_name === "SubagentStop") return;
60
60
  const lease = await json(join(directory, "hooks-enabled.json"));
61
61
  if (lease.expiresAt < Date.now() || !lease.clientEntry) return;
62
- const state = await json(join(directory, "state.json")).catch(() => ({ workspaces: [] }));
63
- const workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived);
64
- const contributor = !!workspace && workspace.mainAgentId !== agentId;
62
+ // scopes.json is a few KB; this hook runs on every tool call. Older workers don't write it: read state.json.
63
+ const scopes = await json(join(directory, "scopes.json")).catch(() => null);
64
+ let workspace = null; let contributor = false;
65
+ if (scopes?.agents) { const entry = scopes.agents[agentId]; if (entry && entry.role !== "recovery") { workspace = { id: entry.workspaceId }; contributor = entry.role === "contributor"; } }
66
+ else {
67
+ const state = await json(join(directory, "state.json")).catch(() => ({ workspaces: [] }));
68
+ workspace = state.workspaces.find(w => (w.mainAgentId === agentId || w.contributorAgentIds?.includes(agentId)) && w.enable_tracking && !w.archived) ?? null;
69
+ contributor = !!workspace && workspace.mainAgentId !== agentId;
70
+ }
65
71
  // The brief nudge applies to root agents launched with FOLLOW_THROUGH_BRIEF, tracked or not.
66
72
  const provider = process.env.FOLLOW_THROUGH_BRIEF || null;
67
73
  const brief = provider ? await json(join(directory, "brief.json")).catch(() => null) : null;
@@ -0,0 +1,29 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Batch, Workspace } from "../shared/model";
4
+
5
+ /** Never-sent cancelled batches kept in state.json per workspace; older ones move to the archive. */
6
+ export const KEEP_UNSENT_CANCELLED = 3;
7
+ /**
8
+ * A batch cancelled before it went out ("Conditions resolved before delivery") records nothing about delivery:
9
+ * dedupe and repair limits only look at batches that were sent, and the activity log only at settled ones. The
10
+ * completion check flips with every turn, so these piled up to ~290 per workspace (82% of state.json on the VM).
11
+ * Removes all but the newest few from `w` and returns them for the archive.
12
+ */
13
+ export function pruneUnsent(w: Pick<Workspace, "batches">, keep = KEEP_UNSENT_CANCELLED): Batch[] {
14
+ const unsent = w.batches.filter(b => b.state === "cancelled" && !b.sentAt && !b.settledAt);
15
+ if (unsent.length <= keep) return [];
16
+ const drop = new Set(unsent.slice(0, unsent.length - keep));
17
+ w.batches = w.batches.filter(b => !drop.has(b));
18
+ return [...drop];
19
+ }
20
+
21
+ /** Appends pruned batches to plugin-data/follow-through/archive/<workspace>.jsonl (kept, never read back). */
22
+ export function batchArchive(directory: string) {
23
+ return async (workspaceId: string, batches: Batch[]): Promise<void> => {
24
+ if (!batches.length) return;
25
+ const folder = join(directory, "archive");
26
+ await mkdir(folder, { recursive: true, mode: 0o700 });
27
+ await appendFile(join(folder, `${workspaceId.replace(/[^\w.-]/g, "_")}.jsonl`), batches.map(b => JSON.stringify(b)).join("\n") + "\n", { mode: 0o600 });
28
+ };
29
+ }
package/server/daemon.ts CHANGED
@@ -30,7 +30,7 @@ export interface Daemon {
30
30
  fetchAgent(id: string): Promise<{ agent: Agent } | null>;
31
31
  fetchAgents(options?: unknown): Promise<{ entries: { agent: Agent }[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
32
32
  fetchWorkspaces(options?: unknown): Promise<{ entries: WorkspaceRow[]; pageInfo: { hasMore: boolean; nextCursor: string | null } }>;
33
- fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number; timestamp?: string }[]; startCursor: { epoch: string; seq: number } | null; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
33
+ fetchAgentTimeline(id: string, options: unknown): Promise<{ entries: { item: unknown; seqStart: number; timestamp?: string }[]; startCursor: { epoch: string; seq: number } | null; endCursor?: { epoch: string; seq: number } | null; epoch?: string; reset?: boolean; hasOlder: boolean; error: string | null; gap: boolean; staleCursor: boolean }>;
34
34
  listProviderSubagents(id: string): Promise<{ subagents: { status: string }[]; error?: string | null }>;
35
35
  sendMessage(id: string, text: string, options: { messageId: string; activeTurnBehavior: "steer" }): Promise<void>;
36
36
  setWorkspaceLabel(input: { workspaceId: string; label: { name: string; color: "emerald" }; assigned: boolean }): Promise<unknown>;
@@ -111,6 +111,44 @@ async function subagentsRunning(client: Daemon, agent: Agent, failure: string):
111
111
  return children.subagents.some(child => child.status === "running");
112
112
  }
113
113
 
114
+ type Entry = { item: unknown; seqStart: number; timestamp?: string };
115
+ type Cursor = { epoch: string; seq: number };
116
+ type Read = { entries: Entry[]; complete: boolean; end: Cursor | null; incremental: boolean };
117
+ /** Entries re-read before the last cursor: canonical items (a tool call and its receipt) can update in place. */
118
+ const OVERLAP = 25;
119
+ /** The whole timeline, newest page first, up to 100 pages of 500. */
120
+ async function readAll(client: Daemon, agentId: string): Promise<Read> {
121
+ const entries: Entry[] = []; let cursor: Cursor | null = null; let end: Cursor | null = null; let complete = false;
122
+ for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
123
+ const page = await client.fetchAgentTimeline(agentId, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
124
+ if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
125
+ if (pageIndex === 0) end = page.endCursor ?? null;
126
+ entries.push(...page.entries);
127
+ if (!page.hasOlder) { complete = true; break; }
128
+ if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
129
+ cursor = page.startCursor;
130
+ }
131
+ return { entries: entries.sort((a, b) => a.seqStart - b.seqStart), complete, end, incremental: false };
132
+ }
133
+ /**
134
+ * Only what's new since `since` (plus a small overlap), for a live agent whose history is already cached.
135
+ * Null means the cursor no longer applies (new epoch, reset, gap): read everything instead.
136
+ */
137
+ async function readSince(client: Daemon, agentId: string, since: Cursor): Promise<Read | null> {
138
+ const entries: Entry[] = []; let cursor: Cursor | null = null; let end: Cursor | null = null;
139
+ for (let pageIndex = 0; pageIndex < 20; pageIndex++) {
140
+ const page = await client.fetchAgentTimeline(agentId, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 100, projection: "canonical", timeout: 15_000 });
141
+ if (page.error || page.gap || page.staleCursor || page.reset || (page.epoch && page.epoch !== since.epoch)) return null;
142
+ if (pageIndex === 0) { end = page.endCursor ?? null; if (end && end.epoch !== since.epoch) return null; }
143
+ entries.push(...page.entries);
144
+ const oldest = page.startCursor?.seq ?? page.entries[0]?.seqStart;
145
+ if (!page.hasOlder || (oldest !== undefined && oldest <= since.seq - OVERLAP)) return { entries: entries.filter(e => e.seqStart > since.seq - OVERLAP).sort((a, b) => a.seqStart - b.seqStart), complete: true, end, incremental: true };
146
+ if (!page.startCursor) return null;
147
+ cursor = page.startCursor;
148
+ }
149
+ return null;
150
+ }
151
+
114
152
  export class PaseoGateway implements Gateway {
115
153
  constructor(private connect: typeof withDaemon = withDaemon, private guard = new SessionGuard(join(paseoHome, "plugin-data/session-guard")), private compacting = compactionPending, private histories = new HistoryCache(null), private now = Date.now) {}
116
154
  private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
@@ -120,6 +158,8 @@ export class PaseoGateway implements Gateway {
120
158
  // Persisted (successful reads only) so restarts don't rescan; see HistoryCache.
121
159
  /** Timeline reads discovery has made, for tests and the scale check. */
122
160
  timelineReads = 0;
161
+ /** Of those, reads that fetched only the newest entries. */
162
+ incrementalReads = 0;
123
163
  /** The active window from the last discovery's state. */
124
164
  private windowMs = DEFAULT_ACTIVE_WINDOW_DAYS * DAY_MS;
125
165
  async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
@@ -170,25 +210,20 @@ export class PaseoGateway implements Gateway {
170
210
  scans.sort((a, b) => (this.histories.get(a.agent.id)?.at ?? 0) - (this.histories.get(b.agent.id)?.at ?? 0) || (b.agent.lastUserMessageAt ?? "").localeCompare(a.agent.lastUserMessageAt ?? ""));
171
211
  // Session opening and Track need only ownership metadata. Read transcripts in the poll.
172
212
  for (const { agent, repo } of scans.slice(0, workspaceId ? 0 : 4)) {
173
- let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = [];
213
+ let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = []; let end: { epoch: string; seq: number } | null = null;
174
214
  this.timelineReads++;
215
+ const cached = this.histories.get(agent.id);
175
216
  try {
176
- const entries: { item: unknown; seqStart: number; timestamp?: string }[] = [];
177
- let cursor: { epoch: string; seq: number } | null = null;
178
- let complete = false;
179
- for (let pageIndex = 0; pageIndex < 100; pageIndex++) {
180
- const page = await client.fetchAgentTimeline(agent.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
181
- if (page.error || page.gap || page.staleCursor) throw new Error("Conversation history was unavailable or changed during recovery");
182
- entries.push(...page.entries);
183
- if (!page.hasOlder) { complete = true; break; }
184
- if (!page.startCursor || (cursor && page.startCursor.epoch === cursor.epoch && page.startCursor.seq >= cursor.seq)) throw new Error("Conversation history pagination was incomplete");
185
- cursor = page.startCursor;
186
- }
187
- entries.sort((a, b) => a.seqStart - b.seqStart);
188
- const items = entries.map(e => e.item);
189
- artifacts = publishedArtifacts(entries, agent.id);
190
- history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
191
- submissions = submittedPrs(items, agent.id, repo);
217
+ const read = (cached?.cursor && cached.history.complete && !cached.history.error ? await readSince(client, agent.id, cached.cursor) : null) ?? await readAll(client, agent.id);
218
+ end = read.end;
219
+ const items = read.entries.map(e => e.item);
220
+ const fresh = { artifacts: publishedArtifacts(read.entries, agent.id), history: extractHistory(items, read.complete, read.complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory"), submissions: submittedPrs(items, agent.id, repo) };
221
+ if (read.incremental && cached) {
222
+ this.incrementalReads++;
223
+ history = { candidates: [...new Set([...cached.history.candidates, ...fresh.history.candidates])].sort(), goal: cached.history.goal ?? fresh.history.goal, complete: true, error: null };
224
+ submissions = [...new Map([...cached.submissions, ...fresh.submissions].map(x => [`${x.repo}#${x.number}`, x])).values()];
225
+ artifacts = [...new Map([...cached.artifacts, ...fresh.artifacts].map(x => [x.url, x])).values()];
226
+ } else ({ history, submissions, artifacts } = fresh);
192
227
  } catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
193
228
  // An unreadable timeline is retried with backoff instead of every poll. New user messages and
194
229
  // live turns retry immediately.
@@ -196,7 +231,7 @@ export class PaseoGateway implements Gateway {
196
231
  const live = ["running", "initializing"].includes(agent.status);
197
232
  const failures = !history.error ? 0 : previous?.history.error && previous.messageAt === agent.lastUserMessageAt && !live ? previous.failures + 1 : 1;
198
233
  const at = this.now();
199
- this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts });
234
+ this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts, cursor: history.error ? null : end });
200
235
  this.dirty.delete(agent.id);
201
236
  }
202
237
  for (const row of discovered) {