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 +1 -1
- package/client/home-chrome.tsx +19 -1
- package/client/home.tsx +42 -10
- package/client/inbox-store.ts +6 -2
- package/client/live.tsx +18 -5
- package/client/settings.tsx +9 -4
- package/client/visibility.ts +24 -0
- package/index.server.ts +68 -13
- package/package.json +4 -3
- package/server/agent-hook.mjs +9 -3
- package/server/agent-load.ts +35 -0
- package/server/batch-archive.ts +29 -0
- package/server/daemon.ts +92 -35
- package/server/delivery.ts +3 -0
- package/server/discovery.ts +12 -1
- package/server/engine.ts +43 -5
- package/server/governor.ts +96 -0
- package/server/history-cache.ts +54 -0
- package/server/hook-source.ts +1 -1
- package/server/inbox-directory.ts +4 -0
- package/server/list-cache.ts +48 -0
- package/server/mcp-source.ts +1 -1
- package/server/mcp.mjs +25 -8
- package/server/recovery-handoff.ts +6 -0
- package/server/scopes.ts +34 -0
- package/server/store.ts +75 -14
- package/shared/agent-load.ts +20 -0
- package/shared/contracts.ts +9 -2
- package/shared/model.ts +8 -0
- package/shared/resources.ts +71 -0
- package/shared/settings.ts +21 -0
- package/shared/usage.ts +3 -0
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.
|
|
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
|
```
|
package/client/home-chrome.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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" }]}>
|
|
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.
|
|
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
|
-
<
|
|
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 => {
|
package/client/inbox-store.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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>;
|
package/client/settings.tsx
CHANGED
|
@@ -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 {
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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",
|
package/server/agent-hook.mjs
CHANGED
|
@@ -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
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
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
|
+
}
|