paseo-follow-up 1.3.0-beta.2 → 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.2
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
@@ -8,6 +8,7 @@ import { needsYou } from "../shared/attention";
8
8
  import { briefPane, followUpLog, lastWorkdayEnd, nextNeeds, paneButtons, sinceLabel, tailSteps, type HomeGroupId, type HomeModel, type LogLine, type TailStep, type Workstream } from "../shared/home";
9
9
  import { age, snoozeUntil, type InboxRow } from "../shared/inbox";
10
10
  import { errorText } from "../shared/errors";
11
+ import { isLoaded, type LoadSignals } from "../shared/agent-load";
11
12
  import { BriefCard, type BriefDeps } from "./brief";
12
13
  import { Button, IconButton, text, type Theme } from "./kit";
13
14
  import { NeedsYouList } from "./needs-you";
@@ -77,7 +78,9 @@ export function FollowHome(props: PluginScreenProps & { deps: HomeDeps }) {
77
78
  const list = <HomeList model={model} theme={theme} dense={compact} touch={compact} selectedKey={selected} onSelect={onSelect} briefLine={briefLine}
78
79
  results={omnibox.results} activeResult={omnibox.active} onResult={item => setSelected(item.key)} filter={filter}
79
80
  top={<ListTop theme={theme} dense={compact} title={<ListHeader theme={theme} compact={compact} onSettings={deps.openSettings} />}>
80
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: compact ? 12 : 16 }}>
81
+ {/* zIndex: on web every View is its own stacking context, so the sort menu only paints and hit-tests above the
82
+ strip and meters below if this row sits above them. */}
83
+ <View style={{ zIndex: 2, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: compact ? 12 : 16 }}>
81
84
  <SearchField theme={theme} omnibox={omnibox} dense={compact} onEnter={item => setSelected(item.key)} />
82
85
  <SortMenu theme={theme} mode={model.sort} onChange={deps.sources.extras.setSort} rankedAt={model.rankedAt} touch={compact} />
83
86
  </View>
@@ -125,21 +128,45 @@ function useAgentLive(agentId: string | null) {
125
128
  return { handle, permissions: (handle?.pendingPermissions ?? []) as unknown as Permission[], status: handle?.status ?? null };
126
129
  }
127
130
 
128
- /** "Recent · last N steps", read only for the selected agent, refreshed when its status or flag changes. */
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
+ /**
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 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
+ */
129
138
  function useTail(handle: ReturnType<typeof useAgentLive>["handle"], key: string) {
130
139
  const [steps, setSteps] = useState<TailStep[] | null>(null);
131
140
  const [error, setError] = useState<string | null>(null);
141
+ const [seenAt, setSeenAt] = useState<number | null>(null);
142
+ const [wake, setWake] = useState(0);
143
+ const [asleep, setAsleep] = useState(false);
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]);
132
149
  useEffect(() => {
133
150
  if (!handle) { setSteps([]); return; }
134
- let live = true;
151
+ const current = handle.current();
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);
135
160
  handle.timeline.refetch({ direction: "tail", limit: 40 }).then(page => {
136
161
  if (!live) return;
137
162
  const entries = [...(page as unknown as { entries: { seqStart: number; item: Parameters<typeof tailSteps>[0][number] }[] }).entries].sort((a, b) => a.seqStart - b.seqStart);
138
- setSteps(tailSteps(entries.map(e => e.item), 6)); setError(null);
139
- }).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } });
163
+ const next = tailSteps(entries.map(e => e.item), 6);
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); });
140
167
  return () => { live = false; };
141
- }, [handle, key]);
142
- return { steps, error };
168
+ }, [handle, key, wake, unknown]);
169
+ return { steps, error, seenAt, asleep, loading, woken: wake > 0, load: () => setWake(n => n + 1) };
143
170
  }
144
171
 
145
172
  function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onBack }: {
@@ -212,11 +239,16 @@ function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onB
212
239
  actions={{ openPr: (key, intent) => setSheet({ key, intent }), openQuestion: setQuestion, openAgent: () => openFull(s) }} />}
213
240
  {actionBar}
214
241
  {s.agentId && <View style={{ gap: 8 }}>
215
- <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>}
216
244
  {tail.steps === null ? <Spinner color={c.foregroundMuted} size={12} label="Loading the agent's steps" />
217
- : tail.steps.length === 0 ? <Text style={t.meta}>{tail.error ? `Couldn't load the agent's steps: ${tail.error}` : "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>)
218
246
  : tail.steps.map((step, i) => <Step key={i} step={step} c={c} last={i === tail.steps!.length - 1} />)}
219
- <Text style={t.small}>A preview, not the whole session. Open full for the chat.</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>
250
+ </View>}
251
+ {!!tail.steps?.length && <Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>}
220
252
  </View>}
221
253
  </ScrollView>
222
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>;
@@ -50,7 +50,8 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
50
50
  });
51
51
  const data = query.data;
52
52
  const chosen = data?.workspaces.find(w => w.id === picked) ?? null;
53
- const exceptions = data?.workspaces.filter(w => w.optedOut || (w.enable_tracking && w.enable_auto_sends !== data.enable_auto_sends_default)) ?? [];
53
+ const exceptions = data?.workspaces.filter(w => w.optedOut || (w.enable_tracking && !w.parked && w.enable_auto_sends !== data.enable_auto_sends_default)) ?? [];
54
+ const windowDays = data?.activeWindowDays ?? 3;
54
55
  return <>
55
56
  <SettingsSection title="Following" info="Follow-up follows every workspace's PR stack, tasks and repairs as soon as the workspace has an agent. Turn Follow every workspace off to follow only the projects switched on below. Follow-up never merges, requests reviewers or archives on its own.">
56
57
  {query.isPending && <ActivityIndicator accessibilityLabel="Loading following settings" color={c.foregroundMuted} style={{ alignSelf: "flex-start", marginVertical: 8 }} />}
@@ -60,18 +61,22 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
60
61
  onValueChange={value => change.mutate({ enable_follow_every: value })} />
61
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}
62
63
  onValueChange={value => change.mutate({ enable_auto_sends_default: value })} />
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
+ value={String(windowDays)} disabled={change.isPending}
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
+ onValueChange={value => change.mutate({ activeWindowDays: Number(value) })} />
63
68
  </SettingsCard>}
64
69
  </SettingsSection>
65
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.">
66
71
  <SettingsCard>
67
- <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} />
68
73
  {chosen && <SettingsSwitch label="Follow this workspace" value={!chosen.optedOut && chosen.enable_tracking} disabled={change.isPending}
69
74
  onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, follow: value } }, { onSuccess: () => toast.show(value ? `Following ${chosen.title}` : `Stopped following ${chosen.title}`, { variant: "success" }) })} />}
70
- {chosen && !chosen.optedOut && chosen.enable_tracking && <SettingsSwitch label="Send repairs automatically" 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}
71
76
  onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, enable_auto_sends: value } })} />}
72
77
  </SettingsCard>
73
78
  {exceptions.length > 0 && <SettingsCard>
74
- {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" : "")}
75
80
  actionLabel="Edit" onPress={() => setPicked(w.id)} />)}
76
81
  </SettingsCard>}
77
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
@@ -33,7 +33,14 @@ import { daemonDirectory, guardPath } from "./server/inbox-directory";
33
33
  import { fileSources } from "./server/inbox-classifier";
34
34
  import { onBeforeSend } from "./server/daemon";
35
35
  import { copyFile, readFile, rename } from "node:fs/promises";
36
- import { autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
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";
42
+ import { conversationGone, DEFAULT_ACTIVE_WINDOW_DAYS } from "./server/agent-load";
43
+ import { ACTIVE_SCOPE_MIGRATION, PRUNE_MIGRATION, autoSendsByDefault, FOLLOW_EVERY_MIGRATION, followsByDefault, followsEvery, migrateFollowEvery, optedOut } from "./shared/settings";
37
44
  import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
38
45
 
39
46
  export default function contribute(server: PluginServerContext) {
@@ -43,14 +50,20 @@ export default function contribute(server: PluginServerContext) {
43
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; } };
44
51
  const directory = join(paseoHome, "plugin-data", "follow-through");
45
52
  const bridge = join(directory, "mcp.mjs");
46
- const store = new Store(directory);
53
+ const store = new Store(directory, 1_000);
54
+ store.onChanged = scopeWriter(directory);
47
55
  const agentHooks = new AgentHooks(directory, clientEntry());
48
- const gateway = new PaseoGateway();
56
+ const gateway = new PaseoGateway(undefined, undefined, undefined, HistoryCache.at(directory));
49
57
  const graphite = new Graphite();
50
58
  const github = new Github(undefined, graphite, new Deployments());
51
59
  const browser = new BrowserBridge();
52
60
  const actions = new PrActions(store, github, graphite, browser);
53
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;
54
67
  const usage = new UsageReader(paseoHome);
55
68
  // Unknown is not off: an unreadable document keeps the inbox on with its defaults.
56
69
  const inboxSettings = async () => { try { const state = await displaySettings.read(); if (state.status === "ready") return state.values; } catch {} return displayPreferences.schema.parse({}); };
@@ -119,6 +132,8 @@ export default function contribute(server: PluginServerContext) {
119
132
  const inboxLoop = async () => {
120
133
  await ready;
121
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; }
122
137
  try { await writeBriefState().catch(() => undefined); await inbox.tick(); await inbox.observeGuard(); await inbox.observeFollowUp(await store.snapshot()); inboxError = null; }
123
138
  catch (error) { inboxError = error instanceof Error ? error.message : "The Needs-you inbox couldn't update."; }
124
139
  if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000);
@@ -141,12 +156,32 @@ export default function contribute(server: PluginServerContext) {
141
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; });
142
157
  await store.mutate(state => { migrateFollowEvery(state); });
143
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
+ };
170
+ /** 1.3.0-beta.3: back up once before the active-scope migration (applied by the engine after its first discovery). */
171
+ const backupActiveScope = async () => {
172
+ if ((await store.snapshot()).migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return;
173
+ await copyFile(join(directory, "state.json"), join(directory, "state.before-active-scope.json.bak"), 1).catch(error => { if (!["ENOENT", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; });
174
+ };
144
175
  const ready = (async () => {
145
176
  unlock = await store.lock();
146
177
  await mkdir(directory, { recursive: true, mode: 0o700 });
147
178
  await writeFile(bridge, mcpSource, { mode: 0o600 });
179
+ governor.start();
148
180
  await engine.recover();
149
181
  await migrateTracking();
182
+ await backupActiveScope();
183
+ await pruneHistory();
184
+ await store.onChanged(await store.snapshot()).catch(() => undefined);
150
185
  hooksStarted = true;
151
186
  await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; following still works."; });
152
187
  hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
@@ -202,20 +237,25 @@ export default function contribute(server: PluginServerContext) {
202
237
  });
203
238
  const followSettings = async () => {
204
239
  const state = await store.snapshot();
205
- return { enable_follow_every: followsEvery(state), enable_auto_sends_default: autoSendsByDefault(state),
206
- workspaces: state.workspaces.filter(w => !w.archived).map(w => ({ id: w.id, title: w.title, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, optedOut: optedOut(state, w.id) }))
240
+ return { enable_follow_every: followsEvery(state), enable_auto_sends_default: autoSendsByDefault(state), activeWindowDays: state.activeWindowDays ?? DEFAULT_ACTIVE_WINDOW_DAYS,
241
+ workspaces: state.workspaces.filter(w => !w.archived).map(w => ({ id: w.id, title: w.title, enable_tracking: w.enable_tracking, enable_auto_sends: w.enable_auto_sends, optedOut: optedOut(state, w.id), dormant: !!w.dormant, parked: !!state.autoSendsParked?.includes(w.id) }))
207
242
  .sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)) };
208
243
  };
209
244
  server.handle(rpc.followSettingsRpc, async () => { await requireReady(); return followSettings(); });
210
- server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, workspace }) => {
245
+ server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, activeWindowDays, workspace }) => {
211
246
  await requireReady();
212
247
  await store.mutate(state => {
248
+ if (activeWindowDays !== undefined) state.activeWindowDays = activeWindowDays;
213
249
  if (enable_follow_every !== undefined) state.enable_follow_every = enable_follow_every;
214
250
  if (enable_auto_sends_default !== undefined) state.enable_auto_sends_default = enable_auto_sends_default;
215
251
  if (!workspace) return;
216
252
  const w = state.workspaces.find(w => w.id === workspace.workspaceId && !w.archived);
217
253
  if (!w) throw new Error("This workspace is no longer available. Refresh settings.");
218
- if (workspace.enable_auto_sends !== undefined) w.enable_auto_sends = workspace.enable_auto_sends;
254
+ if (workspace.enable_auto_sends !== undefined) {
255
+ w.enable_auto_sends = workspace.enable_auto_sends;
256
+ // An explicit choice replaces the migration's parking.
257
+ if (state.autoSendsParked) { state.autoSendsParked = state.autoSendsParked.filter(id => id !== w.id); if (!state.autoSendsParked.length) delete state.autoSendsParked; }
258
+ }
219
259
  // Opting out stops following (discovery skips it); following again ends the opt-out below.
220
260
  if (workspace.follow === false) { state.workspaceOptOuts = [...new Set([...state.workspaceOptOuts ?? [], w.id])]; w.enable_tracking = false; }
221
261
  if (workspace.follow === true) state.workspaceOptOuts = (state.workspaceOptOuts ?? []).filter(id => id !== w.id);
@@ -243,7 +283,7 @@ export default function contribute(server: PluginServerContext) {
243
283
  const view = browserWorkspaces(entries);
244
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) })) };
245
285
  });
246
- 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 }; });
247
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 }; });
248
288
  server.handle(rpc.syncWorkspaceRpc, async ({ agentId, summary }, { paseo }) => {
249
289
  await requireReady();
@@ -427,7 +467,8 @@ export default function contribute(server: PluginServerContext) {
427
467
  const priorityStore = new PriorityStore(directory);
428
468
  const agentActions = new AgentActions({ store, prActions: actions, engine, inbox, priorities: priorityStore });
429
469
  server.handle(rpc.prioritiesRpc, async () => ({ items: await priorityStore.read() }));
430
- 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() }));
431
472
  server.handle(rpc.agentActRpc, async ({ agentId, tool, args }, { paseo }) => {
432
473
  await requireReady(); seen(paseo);
433
474
  if (!(AGENT_TOOLS as readonly string[]).includes(tool)) throw new Error(`Unknown Follow-up tool ${tool}.`);
@@ -504,7 +545,18 @@ export default function contribute(server: PluginServerContext) {
504
545
  server.on("workspace.archived", async event => inboxHook(() => inbox.workspaceArchived(event.workspace.id))),
505
546
  server.on("agent.turn_started", async event => {
506
547
  await ready;
507
- if (!stopped && !loadError) await store.mutate(state => { const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id))); if (w) w.completion = null; });
548
+ if (stopped || loadError) return;
549
+ // A turn in a dormant workspace makes it active again; discovery reads the now-loaded agent on the next poll.
550
+ const saved = await store.snapshot();
551
+ const owned = saved.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
552
+ const waking = saved.workspaces.find(w => w.dormant && w.id === event.agent.workspaceId);
553
+ if (!owned && !waking) return;
554
+ if (owned) gateway.markDirty(event.agent.id);
555
+ await store.mutate(state => {
556
+ const w = owned && state.workspaces.find(w => w.id === owned.id); if (w) w.completion = null;
557
+ const d = waking && state.workspaces.find(w => w.id === waking.id); if (d) delete d.dormant;
558
+ });
559
+ if (waking) void engine.tick();
508
560
  }),
509
561
  server.before("agent.session_open", async ({ request }, { paseo }) => {
510
562
  await ready;
@@ -564,15 +616,18 @@ export default function contribute(server: PluginServerContext) {
564
616
  const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
565
617
  if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
566
618
  const w = state.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
567
- if (w && w.mainAgentId === event.agent.id && interruption) { w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null; }
619
+ if (!w || w.mainAgentId !== event.agent.id || !interruption) return;
620
+ // The provider lost the conversation: every later send fails the same way, so stop sending instead of holding.
621
+ if (event.outcome.kind === "failed" && conversationGone(event.outcome.error.message)) { w.goneAgent = { agentId: event.agent.id, at: interruption.at, reason: event.outcome.error.message }; w.completion = null; return; }
622
+ w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null;
568
623
  });
569
624
  void engine.tick();
570
625
  }),
571
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; }); }),
572
627
  ];
573
628
  return async () => {
574
- 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());
575
630
  await ready; if (hooksStarted) await agentHooks.stop().catch(() => {});
576
- 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?.();
577
632
  };
578
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.2",
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,35 @@
1
+ import { settled, type Workspace } from "../shared/model";
2
+
3
+ export { isLoaded, LIVE_STATUSES, type LoadSignals } from "../shared/agent-load";
4
+ import { isLoaded, type LoadSignals } from "../shared/agent-load";
5
+
6
+ export const DAY_MS = 86_400_000;
7
+ export const DEFAULT_ACTIVE_WINDOW_DAYS = 3;
8
+ export const activeWindowMs = (state: { activeWindowDays?: number }): number => (state.activeWindowDays ?? DEFAULT_ACTIVE_WINDOW_DAYS) * DAY_MS;
9
+
10
+ /** A user message within the active window. */
11
+ export function recentlyUsed(agent: { lastUserMessageAt: string | null }, windowMs: number, now: number): boolean {
12
+ if (!agent.lastUserMessageAt) return false;
13
+ const at = Date.parse(agent.lastUserMessageAt);
14
+ return Number.isFinite(at) && now - at < windowMs;
15
+ }
16
+
17
+ /** A PR this workspace owns that isn't merged or closed. An unobserved PR counts as open until it's seen. */
18
+ export function ownsOpenPr(w: Pick<Workspace, "prs"> | undefined): boolean {
19
+ return !!w?.prs.some(p => !settled(p) && (p.snapshot ? p.snapshot.state === "OPEN" : true));
20
+ }
21
+
22
+ /**
23
+ * The active scope: Follow-up polls a followed workspace only while it has a loaded agent, a user message in
24
+ * the active window, or an open PR it owns. Everything else stays listed and costs nothing until a turn starts
25
+ * there (the turn hooks mark it dirty and the next discovery sees the loaded agent).
26
+ */
27
+ export function workspaceActive(agents: (LoadSignals & { lastUserMessageAt: string | null })[], saved: Pick<Workspace, "prs"> | undefined, windowMs: number, now: number): boolean {
28
+ return agents.some(a => isLoaded(a) || recentlyUsed(a, windowMs, now)) || ownsOpenPr(saved);
29
+ }
30
+
31
+ /** The provider no longer has this conversation, so every send to it fails. */
32
+ export function conversationGone(error: unknown): boolean {
33
+ const text = typeof error === "string" ? error : error instanceof Error ? error.message : error && typeof error === "object" && "message" in error ? String((error as { message: unknown }).message) : "";
34
+ return /no conversation found|conversation (?:not found|no longer exists)|session (?:id )?(?:not found|does not exist|no longer exists)|no (?:such )?session/i.test(text);
35
+ }
@@ -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
+ }