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

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.3
120
120
  # or, for a fresh install:
121
121
  paseo plugin install npm:paseo-follow-up@beta
122
122
  ```
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,36 @@ 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, shown while the agent is unloaded. */
132
+ const seenSteps = new Map<string, TailStep[]>();
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 server/agent-load.ts), so an unloaded agent
136
+ * shows the steps Home saw last 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 [wake, setWake] = useState(0);
142
+ const [asleep, setAsleep] = useState(false);
143
+ useEffect(() => { setWake(0); }, [handle]);
132
144
  useEffect(() => {
133
145
  if (!handle) { setSteps([]); return; }
146
+ 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; }
134
151
  let live = true;
135
152
  handle.timeline.refetch({ direction: "tail", limit: 40 }).then(page => {
136
153
  if (!live) return;
137
154
  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);
155
+ const next = tailSteps(entries.map(e => e.item), 6);
156
+ seenSteps.set(handle.id, next); setSteps(next); setError(null);
139
157
  }).catch(e => { if (live) { setError(errorText(e)); setSteps(s => s ?? []); } });
140
158
  return () => { live = false; };
141
- }, [handle, key]);
142
- return { steps, error };
159
+ }, [handle, key, wake]);
160
+ return { steps, error, asleep: asleep && !wake, load: () => setWake(n => n + 1) };
143
161
  }
144
162
 
145
163
  function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onBack }: {
@@ -214,8 +232,12 @@ function DetailPane({ s, model, theme, compact, deps, openFull, signOffNext, onB
214
232
  {s.agentId && <View style={{ gap: 8 }}>
215
233
  <Text style={[t.small, { fontWeight: "600", letterSpacing: 0.5, textTransform: "uppercase" }]}>Recent · last {tail.steps?.length || 6} steps</Text>
216
234
  {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>
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>
218
236
  : 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>
240
+ </View>}
219
241
  <Text style={t.small}>A preview, not the whole session. Open full for the chat.</Text>
220
242
  </View>}
221
243
  </ScrollView>
@@ -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,6 +61,10 @@ 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="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."
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.">
@@ -67,7 +72,7 @@ function FollowingSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
67
72
  <SettingsSelect label="Workspace" value={picked} options={[{ value: "", label: "Choose a workspace" }, ...data.workspaces.map(w => ({ value: w.id, label: 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. 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}
71
76
  onValueChange={value => change.mutate({ workspace: { workspaceId: chosen.id, enable_auto_sends: value } })} />}
72
77
  </SettingsCard>
73
78
  {exceptions.length > 0 && <SettingsCard>
package/index.server.ts CHANGED
@@ -33,7 +33,9 @@ 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 { 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";
37
39
  import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
38
40
 
39
41
  export default function contribute(server: PluginServerContext) {
@@ -45,7 +47,7 @@ export default function contribute(server: PluginServerContext) {
45
47
  const bridge = join(directory, "mcp.mjs");
46
48
  const store = new Store(directory);
47
49
  const agentHooks = new AgentHooks(directory, clientEntry());
48
- const gateway = new PaseoGateway();
50
+ const gateway = new PaseoGateway(undefined, undefined, undefined, HistoryCache.at(directory));
49
51
  const graphite = new Graphite();
50
52
  const github = new Github(undefined, graphite, new Deployments());
51
53
  const browser = new BrowserBridge();
@@ -141,12 +143,18 @@ export default function contribute(server: PluginServerContext) {
141
143
  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
144
  await store.mutate(state => { migrateFollowEvery(state); });
143
145
  };
146
+ /** 1.3.0-beta.3: back up once before the active-scope migration (applied by the engine after its first discovery). */
147
+ const backupActiveScope = async () => {
148
+ if ((await store.snapshot()).migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return;
149
+ 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; });
150
+ };
144
151
  const ready = (async () => {
145
152
  unlock = await store.lock();
146
153
  await mkdir(directory, { recursive: true, mode: 0o700 });
147
154
  await writeFile(bridge, mcpSource, { mode: 0o600 });
148
155
  await engine.recover();
149
156
  await migrateTracking();
157
+ await backupActiveScope();
150
158
  hooksStarted = true;
151
159
  await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; following still works."; });
152
160
  hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
@@ -202,20 +210,25 @@ export default function contribute(server: PluginServerContext) {
202
210
  });
203
211
  const followSettings = async () => {
204
212
  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) }))
213
+ return { enable_follow_every: followsEvery(state), enable_auto_sends_default: autoSendsByDefault(state), activeWindowDays: state.activeWindowDays ?? DEFAULT_ACTIVE_WINDOW_DAYS,
214
+ 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
215
  .sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)) };
208
216
  };
209
217
  server.handle(rpc.followSettingsRpc, async () => { await requireReady(); return followSettings(); });
210
- server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, workspace }) => {
218
+ server.handle(rpc.setFollowSettingsRpc, async ({ enable_follow_every, enable_auto_sends_default, activeWindowDays, workspace }) => {
211
219
  await requireReady();
212
220
  await store.mutate(state => {
221
+ if (activeWindowDays !== undefined) state.activeWindowDays = activeWindowDays;
213
222
  if (enable_follow_every !== undefined) state.enable_follow_every = enable_follow_every;
214
223
  if (enable_auto_sends_default !== undefined) state.enable_auto_sends_default = enable_auto_sends_default;
215
224
  if (!workspace) return;
216
225
  const w = state.workspaces.find(w => w.id === workspace.workspaceId && !w.archived);
217
226
  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;
227
+ if (workspace.enable_auto_sends !== undefined) {
228
+ w.enable_auto_sends = workspace.enable_auto_sends;
229
+ // An explicit choice replaces the migration's parking.
230
+ if (state.autoSendsParked) { state.autoSendsParked = state.autoSendsParked.filter(id => id !== w.id); if (!state.autoSendsParked.length) delete state.autoSendsParked; }
231
+ }
219
232
  // Opting out stops following (discovery skips it); following again ends the opt-out below.
220
233
  if (workspace.follow === false) { state.workspaceOptOuts = [...new Set([...state.workspaceOptOuts ?? [], w.id])]; w.enable_tracking = false; }
221
234
  if (workspace.follow === true) state.workspaceOptOuts = (state.workspaceOptOuts ?? []).filter(id => id !== w.id);
@@ -504,7 +517,18 @@ export default function contribute(server: PluginServerContext) {
504
517
  server.on("workspace.archived", async event => inboxHook(() => inbox.workspaceArchived(event.workspace.id))),
505
518
  server.on("agent.turn_started", async event => {
506
519
  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; });
520
+ if (stopped || loadError) return;
521
+ // A turn in a dormant workspace makes it active again; discovery reads the now-loaded agent on the next poll.
522
+ const saved = await store.snapshot();
523
+ const owned = saved.workspaces.find(w => w.enable_tracking && (w.mainAgentId === event.agent.id || w.contributorAgentIds?.includes(event.agent.id)));
524
+ const waking = saved.workspaces.find(w => w.dormant && w.id === event.agent.workspaceId);
525
+ if (!owned && !waking) return;
526
+ if (owned) gateway.markDirty(event.agent.id);
527
+ await store.mutate(state => {
528
+ const w = owned && state.workspaces.find(w => w.id === owned.id); if (w) w.completion = null;
529
+ const d = waking && state.workspaces.find(w => w.id === waking.id); if (d) delete d.dormant;
530
+ });
531
+ if (waking) void engine.tick();
508
532
  }),
509
533
  server.before("agent.session_open", async ({ request }, { paseo }) => {
510
534
  await ready;
@@ -564,7 +588,10 @@ export default function contribute(server: PluginServerContext) {
564
588
  const guarded = interruption && await sessionGuardOwnsInterruption(event.agent.id, interruption).catch(() => false);
565
589
  if (["canceled", "failed"].includes(event.outcome.kind) && !guarded) await store.mutate(state => {
566
590
  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; }
591
+ if (!w || w.mainAgentId !== event.agent.id || !interruption) return;
592
+ // The provider lost the conversation: every later send fails the same way, so stop sending instead of holding.
593
+ 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; }
594
+ w.hold = interruptionHold(interruption.kind); w.interruption = interruption; w.completion = null;
568
595
  });
569
596
  void engine.tick();
570
597
  }),
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.3",
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": [
@@ -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
+ }
package/server/daemon.ts CHANGED
@@ -15,11 +15,14 @@ import { extractHistory, type History, type DiscoveredWorkspace } from "./discov
15
15
  import type { PluginBeforeRequests } from "@getpaseo/plugin/server";
16
16
  import { isWorkspaceRoot, type WorkspaceAgent } from "./workspace-agent";
17
17
  import { SessionGuard } from "./session-guard";
18
- import { DeliveryDeferred } from "./delivery";
18
+ import { DeliveryDeferred, ConversationGone } from "./delivery";
19
+ export { ConversationGone } from "./delivery";
20
+ import { activeWindowMs, isLoaded, recentlyUsed, workspaceActive, conversationGone, DEFAULT_ACTIVE_WINDOW_DAYS, DAY_MS } from "./agent-load";
21
+ import { HistoryCache } from "./history-cache";
19
22
  type RecoveryAgentOptions = Partial<PluginBeforeRequests["agent.create"]["config"]> & { workspaceId: string; labels: Record<string, string>; idempotencyKey: string; env: Record<string, string> };
20
23
 
21
24
  export const paseoHome = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
22
- export type Agent = { id: string; provider: string; cwd: string; model?: string | null; currentModeId?: string | null; thinkingOptionId?: string | null; features?: { id: string; value: unknown }[]; persistence?: { sessionId: string; nativeHandle?: string } | null; title?: string | null; createdAt: string; workspaceId?: string; status: string; archivedAt?: string | null; labels: Record<string, string>; lastUserMessageAt: string | null; pendingPermissions: unknown[] };
25
+ export type Agent = { id: string; provider: string; cwd: string; model?: string | null; currentModeId?: string | null; thinkingOptionId?: string | null; features?: { id: string; value: unknown }[]; persistence?: { sessionId: string; nativeHandle?: string } | null; title?: string | null; createdAt: string; workspaceId?: string; status: string; archivedAt?: string | null; labels: Record<string, string>; lastUserMessageAt: string | null; pendingPermissions: unknown[]; activeTurn?: unknown; lastError?: string | null };
23
26
  type WorkspaceRow = { id: string; name: string; projectId?: string | null; labels?: string[]; archivingAt?: string | null };
24
27
  export interface Daemon {
25
28
  connect(): Promise<void>; close(): Promise<void>;
@@ -97,25 +100,32 @@ export function historyRetryDelay(failures: number): number {
97
100
  return failures <= 0 ? HISTORY_RETRY_MS : Math.min(HISTORY_RETRY_MS * 2 ** (failures - 1), HISTORY_RETRY_MAX_MS);
98
101
  }
99
102
  /**
100
- * A closed agent has no live runtime, so it cannot have running provider subagents. Asking the
101
- * daemon anyway resumes the session from persistence (spawning its provider process) only for the
102
- * idle-runtime collector to close it again, so each poll would resume it.
103
+ * An unloaded agent has no live runtime, so it cannot have running provider subagents. Asking the
104
+ * daemon anyway resumes the session from persistence (spawning its provider process) and keeps it
105
+ * loaded for an hour, so each poll would wake it. See isLoaded for how "unloaded" is told apart.
103
106
  */
104
107
  async function subagentsRunning(client: Daemon, agent: Agent, failure: string): Promise<boolean> {
105
- if (agent.status === "closed") return false;
108
+ if (!isLoaded(agent)) return false;
106
109
  const children = await client.listProviderSubagents(agent.id);
107
110
  if (children.error) throw new Error(failure);
108
111
  return children.subagents.some(child => child.status === "running");
109
112
  }
110
113
 
111
114
  export class PaseoGateway implements Gateway {
112
- constructor(private connect: typeof withDaemon = withDaemon, private guard = new SessionGuard(join(paseoHome, "plugin-data/session-guard")), private compacting = compactionPending) {}
115
+ 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) {}
113
116
  private directory: { agents: { agent: Agent }[]; workspaces: WorkspaceRow[] } | null = null;
114
117
  private dirty = new Set<string>();
115
118
  markDirty(agentId: string): void { this.dirty.add(agentId); }
116
119
  // `failures` counts consecutive unreadable reads of one transcript; `retryAt` backs them off.
117
- private histories = new Map<string, { messageAt: string | null; at: number; failures: number; retryAt: number; history: History; submissions: Submission[]; artifacts: Artifact[] }>();
120
+ // Persisted (successful reads only) so restarts don't rescan; see HistoryCache.
121
+ /** Timeline reads discovery has made, for tests and the scale check. */
122
+ timelineReads = 0;
123
+ /** The active window from the last discovery's state. */
124
+ private windowMs = DEFAULT_ACTIVE_WINDOW_DAYS * DAY_MS;
118
125
  async discover(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]> {
126
+ await this.histories.load();
127
+ const now = this.now();
128
+ const windowMs = this.windowMs = activeWindowMs(state);
119
129
  return this.connect(async client => {
120
130
  const [agents, workspaces, catalog] = await Promise.all([
121
131
  pages(cursor => client.fetchAgents({ page: { limit: 100, ...(cursor ? { cursor } : {}) } })),
@@ -146,9 +156,13 @@ export class PaseoGateway implements Gateway {
146
156
  // sessions can still contribute their PRs without acquiring completion authority.
147
157
  const project = catalog.projects.find(p => p.projectId === workspace.projectId);
148
158
  const repo = project?.projectKind === "git" ? /^remote:github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(project.projectKey)?.[1] ?? null : null;
149
- for (const agent of roots) {
159
+ // Active scope: a dormant workspace stays listed but is never scanned. Within an active one only
160
+ // loaded agents are read, because a timeline read loads the agent (see isLoaded).
161
+ row.active = workspaceActive(roots, existing, windowMs, now);
162
+ if (row.active) for (const agent of roots) {
163
+ if (!isLoaded(agent)) continue;
150
164
  const cached = this.histories.get(agent.id);
151
- if (!cached || cached.messageAt !== agent.lastUserMessageAt || this.dirty.has(agent.id) || (cached.history.error && Date.now() >= cached.retryAt) || (["running", "initializing"].includes(agent.status) && Date.now() - cached.at >= 15_000)) scans.push({ row, agent, repo });
165
+ if (!cached || cached.messageAt !== agent.lastUserMessageAt || this.dirty.has(agent.id) || (cached.history.error && now >= cached.retryAt) || (["running", "initializing"].includes(agent.status) && now - cached.at >= 15_000)) scans.push({ row, agent, repo });
152
166
  }
153
167
  discovered.push(row);
154
168
  }
@@ -157,6 +171,7 @@ export class PaseoGateway implements Gateway {
157
171
  // Session opening and Track need only ownership metadata. Read transcripts in the poll.
158
172
  for (const { agent, repo } of scans.slice(0, workspaceId ? 0 : 4)) {
159
173
  let history: History; let submissions: Submission[] = []; let artifacts: Artifact[] = [];
174
+ this.timelineReads++;
160
175
  try {
161
176
  const entries: { item: unknown; seqStart: number; timestamp?: string }[] = [];
162
177
  let cursor: { epoch: string; seq: number } | null = null;
@@ -175,12 +190,12 @@ export class PaseoGateway implements Gateway {
175
190
  history = extractHistory(items, complete, complete ? null : "History exceeds the recovery window; inspect older transcripts during inventory");
176
191
  submissions = submittedPrs(items, agent.id, repo);
177
192
  } catch { history = extractHistory([], false, "History could not be read; inspect the session or register submitted PRs explicitly"); }
178
- // Reading a closed session's timeline resumes its runtime, so an unreadable one is retried
179
- // with backoff instead of every poll. New user messages and live turns retry immediately.
193
+ // An unreadable timeline is retried with backoff instead of every poll. New user messages and
194
+ // live turns retry immediately.
180
195
  const previous = this.histories.get(agent.id);
181
196
  const live = ["running", "initializing"].includes(agent.status);
182
197
  const failures = !history.error ? 0 : previous?.history.error && previous.messageAt === agent.lastUserMessageAt && !live ? previous.failures + 1 : 1;
183
- const at = Date.now();
198
+ const at = this.now();
184
199
  this.histories.set(agent.id, { messageAt: agent.lastUserMessageAt, at, failures, retryAt: at + historyRetryDelay(failures), history, submissions, artifacts });
185
200
  this.dirty.delete(agent.id);
186
201
  }
@@ -195,6 +210,7 @@ export class PaseoGateway implements Gateway {
195
210
  }
196
211
  const known = new Set(agents.map(a => a.agent.id));
197
212
  for (const id of this.histories.keys()) if (!known.has(id)) this.histories.delete(id);
213
+ await this.histories.save();
198
214
  return discovered;
199
215
  });
200
216
  }
@@ -230,6 +246,8 @@ export class PaseoGateway implements Gateway {
230
246
  const context = { guardedInterruptionId };
231
247
  if (guarded || !pauses && workspace.labels?.some(label => /^(Paused|Paused by Session Guard)$/i.test(label))) return { ...context, guarded: true, busy: false, reason: "Waiting for Session Guard to resume this workspace", archived: false };
232
248
  if (compacting) return { busy: true, reason: "Smart Session is compacting the main agent", archived: false };
249
+ const mainLoaded = isLoaded(a), mainRecent = recentlyUsed(a, this.windowMs, this.now());
250
+ if (conversationGone(a.lastError)) return { busy: false, reason: a.lastError ?? "Conversation not found", archived: false, gone: a.lastError ?? "Conversation not found", mainLoaded, mainRecent };
233
251
  if (a.pendingPermissions.length || a.status === "error") return { busy: false, reason: a.status === "error" ? "Main agent needs recovery" : "Main agent is waiting for a permission or answer", archived: false };
234
252
  let busy = false; let backgroundBusy = false;
235
253
  for (const { agent } of agents.filter(row => row.agent.workspaceId === w.id)) {
@@ -237,7 +255,7 @@ export class PaseoGateway implements Gateway {
237
255
  if (["running", "initializing"].includes(agent.status)) { busy = true; if (agent.id !== w.mainAgentId) backgroundBusy = true; }
238
256
  if (await subagentsRunning(client, agent, "Subagent observation unavailable")) { busy = true; backgroundBusy = true; }
239
257
  }
240
- return { ...context, busy, reason: null, archived: false, backgroundBusy };
258
+ return { ...context, busy, reason: null, archived: false, backgroundBusy, mainLoaded, mainRecent };
241
259
  });
242
260
  }
243
261
  async recoveryRuntime(w: Workspace, batch: Batch): Promise<{ busy: boolean; reason: string | null }> {
@@ -271,8 +289,10 @@ export class PaseoGateway implements Gateway {
271
289
  }
272
290
  catch { throw new DeliveryDeferred("Session Guard state is unavailable; follow-up will wait and retry."); }
273
291
  if (guarded) throw new DeliveryDeferred("Waiting for Session Guard to resume the assigned session.");
292
+ if (conversationGone(a.lastError)) throw new ConversationGone(recipient, a.lastError!);
274
293
  await beforeSend(recipient, batch.messageId).catch(() => undefined);
275
- await client.sendMessage(recipient, text, { messageId: batch.messageId, activeTurnBehavior: "steer" });
294
+ try { await client.sendMessage(recipient, text, { messageId: batch.messageId, activeTurnBehavior: "steer" }); }
295
+ catch (error) { if (conversationGone(error)) throw new ConversationGone(recipient, error instanceof Error ? error.message : String(error)); throw error; }
276
296
  });
277
297
  }
278
298
  /**
@@ -324,7 +344,9 @@ export class PaseoGateway implements Gateway {
324
344
  const entries: { item: unknown; seqStart: number }[] = [];
325
345
  let cursor: { epoch: string; seq: number } | null = null;
326
346
  let historyError: string | null = "History exceeds the recovery window; inspect local transcripts for older work";
327
- try {
347
+ // Reading an unloaded main agent's timeline would wake it; the recovery agent reads local transcripts instead.
348
+ if (!isLoaded(main)) historyError = "The main agent isn't loaded, so its timeline wasn't read (that would wake it). Use its local provider transcript and git history.";
349
+ else try {
328
350
  for (let i = 0; i < 100; i++) {
329
351
  const page = await client.fetchAgentTimeline(main.id, { direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 500, projection: "canonical", timeout: 15_000 });
330
352
  if (page.error || page.gap || page.staleCursor) throw new Error("History changed or is unavailable");
@@ -1 +1,4 @@
1
1
  export class DeliveryDeferred extends Error {}
2
+
3
+ /** The provider no longer has the agent's conversation: every send fails, so Follow-up stops sending to it. */
4
+ export class ConversationGone extends Error { constructor(readonly agentId: string, reason: string) { super(reason); this.name = "ConversationGone"; } }
@@ -5,7 +5,7 @@ import type { State, Workspace } from "../shared/model";
5
5
  import { autoSendsByDefault, followsByDefault } from "../shared/settings";
6
6
 
7
7
  export type History = { candidates: string[]; goal: string | null; complete: boolean; error: string | null };
8
- export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; /** Set when mainAgentId is a live successor to this closed or gone main agent. */ replacesMainAgentId?: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[] };
8
+ export type DiscoveredWorkspace = { id: string; title: string; mainAgentId: string; /** Set when mainAgentId is a live successor to this closed or gone main agent. */ replacesMainAgentId?: string; messageAt: string | null; projectId?: string | null; enable_default_tracking?: boolean; history?: History; contributorAgentIds?: string[]; submissions?: Submission[]; artifacts?: Artifact[]; /** In the active scope (see workspaceActive). Undefined means unknown, treated as active. */ active?: boolean };
9
9
 
10
10
  export function extractHistory(items: readonly unknown[], complete = true, error: string | null = null): History {
11
11
  const candidates = new Set<string>();
@@ -48,6 +48,15 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
48
48
  state.workspaces.push(w);
49
49
  }
50
50
  w.title = row.title;
51
+ if (row.active !== undefined) {
52
+ if (row.active) delete w.dormant; else w.dormant = true;
53
+ // Auto-send parked by the active-scope migration comes back once the workspace is active again.
54
+ if (row.active && state.autoSendsParked?.includes(w.id)) {
55
+ if (w.enable_tracking && !w.archived) w.enable_auto_sends = true;
56
+ state.autoSendsParked = state.autoSendsParked.filter(id => id !== w.id);
57
+ if (!state.autoSendsParked.length) delete state.autoSendsParked;
58
+ }
59
+ }
51
60
  // A closed or gone main agent hands the workspace to its live successor (discovery decides; see
52
61
  // successorMainAgent). Any other mismatch keeps the recorded owner.
53
62
  if (row.replacesMainAgentId && row.replacesMainAgentId === w.mainAgentId && !w.archived) {
@@ -55,6 +64,8 @@ export function applyDiscovery(state: State, discovered: DiscoveredWorkspace[],
55
64
  for (const b of w.batches) if (b.state === "pending" && b.recipientAgentId === row.replacesMainAgentId) b.recipientAgentId = null;
56
65
  }
57
66
  if (w.mainAgentId !== row.mainAgentId || w.archived) continue;
67
+ // A new main agent, or a new message to the gone one, means its conversation can be tried again.
68
+ if (w.goneAgent && (w.goneAgent.agentId !== w.mainAgentId || (row.messageAt && row.messageAt > w.goneAgent.at))) w.goneAgent = null;
58
69
  if (row.contributorAgentIds) w.contributorAgentIds = row.contributorAgentIds;
59
70
  if (!w.enable_tracking && followsByDefault(state, row)) { w.enable_auto_sends = startsWithAutoSend(state, w); w.enable_tracking = true; }
60
71
  if (!w.inventory) {
package/server/engine.ts CHANGED
@@ -7,15 +7,24 @@ import { answerRecipients, developerAnswer, syncTaskQuestions } from "./question
7
7
  import { answerMessage, isOpen as isOpenQuestion, type Question } from "../shared/questions";
8
8
  import { reconcileSubmittedStacks } from "./submissions";
9
9
  import { applyDiscovery, startsWithAutoSend, type DiscoveredWorkspace } from "./discovery";
10
- import { followsByDefault } from "../shared/settings";
10
+ import { followsByDefault, migrateActiveScope } from "../shared/settings";
11
11
  import { replyRecorded } from "../shared/communication";
12
- import { DeliveryDeferred } from "./delivery";
12
+ import { DeliveryDeferred, ConversationGone } from "./delivery";
13
13
  import { recordSnapshot } from "./deployment-anchor";
14
14
 
15
15
  export interface Runtime {
16
16
  busy: boolean; reason: string | null; archived: boolean; backgroundBusy?: boolean;
17
17
  guarded?: boolean; guardedInterruptionId?: string;
18
+ /** The main agent's runtime is loaded (see isLoaded). Undefined means unknown. */
19
+ mainLoaded?: boolean;
20
+ /** The main agent had a user message within the active window. Undefined means unknown. */
21
+ mainRecent?: boolean;
22
+ /** The main agent's provider conversation no longer exists. */
23
+ gone?: string | null;
18
24
  }
25
+ const GONE_REASON = "The main agent's conversation no longer exists, so Follow-up stopped sending to it. Start a new agent in this workspace to continue.";
26
+ /** A batch that asks the agent to verify it's finished and declare Done. These never go out on their own. */
27
+ export const completionBatch = (b: Batch): boolean => b.tasks.some(t => t.kind === "completion");
19
28
  export const interruptionHold = (kind: "canceled" | "failed") => kind === "canceled" ? "You stopped the main agent. Resume follow-up when ready." : "The main agent's turn failed. Check it before resuming follow-up.";
20
29
  export interface Gateway {
21
30
  discover?(state: State, workspaceId?: string): Promise<DiscoveredWorkspace[]>;
@@ -65,9 +74,13 @@ export class Engine {
65
74
  if (this.stopped) return;
66
75
  if (this.gateway.discover) {
67
76
  const discovered = await this.gateway.discover(saved);
68
- await this.store.mutate(state => applyDiscovery(state, discovered.filter(row => followsByDefault(state, row) || saved.workspaces.some(w => w.id === row.id && w.enable_tracking && !w.archived)), this.now()));
77
+ await this.store.mutate(state => {
78
+ applyDiscovery(state, discovered.filter(row => followsByDefault(state, row) || saved.workspaces.some(w => w.id === row.id && w.enable_tracking && !w.archived)), this.now());
79
+ migrateActiveScope(state, new Set(discovered.filter(row => row.active !== false).map(row => row.id)));
80
+ });
69
81
  }
70
- const active = (await this.store.snapshot()).workspaces.filter(w => w.enable_tracking && !w.archived);
82
+ // Dormant workspaces stay listed but cost nothing: no PR or agent observation until they're active again.
83
+ const active = (await this.store.snapshot()).workspaces.filter(w => w.enable_tracking && !w.archived && !w.dormant);
71
84
  if (!active.length) { this.error = null; return; }
72
85
  const observations = await this.github.observe(active.flatMap(w => w.prs));
73
86
  for (const original of active) {
@@ -77,6 +90,8 @@ export class Engine {
77
90
  const w = findWorkspace(state, original.id);
78
91
  w.runtimeBusy = runtime.busy; w.runtimeReason = runtime.reason;
79
92
  w.backgroundBusy = runtime.backgroundBusy ?? false;
93
+ if (runtime.gone && w.goneAgent?.agentId !== w.mainAgentId) w.goneAgent = { agentId: w.mainAgentId, at: new Date(this.now()).toISOString(), reason: runtime.gone };
94
+ if (w.goneAgent?.agentId === w.mainAgentId) w.runtimeReason = GONE_REASON;
80
95
  if (runtime.guardedInterruptionId && w.interruption && w.hold === interruptionHold(w.interruption.kind)) {
81
96
  w.hold = null; w.interruption.guardPauseId = runtime.guardedInterruptionId;
82
97
  }
@@ -114,7 +129,10 @@ export class Engine {
114
129
  for (const q of findWorkspace(await this.store.snapshot(), original.id).questions.filter(q => q.answer?.delivery.state === "queued")) await this.deliverAnswer(original.id, q.id).catch(() => undefined);
115
130
  let current = findWorkspace(await this.store.snapshot(), original.id);
116
131
  const pending = current.batches.find(b => b.state === "pending" && !isRecovery(b));
117
- if (current.enable_auto_sends && !current.archived && !current.hold && pending &&
132
+ // Auto-send only reaches an agent that is loaded and recently used: sending to a dormant one would wake it.
133
+ // Completion checks never go out on their own; they wait for Tom or the agent's next live turn.
134
+ if (current.enable_auto_sends && !current.archived && !current.hold && pending && !current.dormant && !current.goneAgent &&
135
+ !completionBatch(pending) && runtime.mainLoaded !== false && runtime.mainRecent !== false &&
118
136
  !runtime.busy && !runtime.backgroundBusy && !runtime.reason &&
119
137
  !current.batches.some(b => !isRecovery(b) && ["sending", "sent", "blocked", "uncertain"].includes(b.state)) &&
120
138
  this.now() - Date.parse(pending.createdAt) >= this.settleMs &&
@@ -197,6 +215,7 @@ export class Engine {
197
215
  const batch = current.batches.find(b => b.id === batchId && b.state === "pending");
198
216
  if (batch && isRecovery(batch)) throw new Error("Recovery requires its own explicit action.");
199
217
  if (!batch || !current.enable_tracking || current.archived || current.hold || this.stopped || (automatic && !current.enable_auto_sends)) throw new Error("Workspace or queue is no longer ready to send");
218
+ if (current.goneAgent?.agentId === current.mainAgentId) throw new Error(GONE_REASON);
200
219
  if (needsPrObservations(current, batch) && current.prs.some(p => p.error || !p.snapshot?.complete)) throw new Error("PR status is incomplete; refresh before sending repairs");
201
220
  const runtime = await this.gateway.observe(current, true);
202
221
  if (runtime.busy || runtime.backgroundBusy || runtime.reason || runtime.archived) throw new Error(runtime.reason ?? "The workspace has active agent work. Send when it is idle.");
@@ -256,6 +275,14 @@ export class Engine {
256
275
  } catch (error) {
257
276
  await this.store.mutate(state => {
258
277
  const b = findWorkspace(state, workspaceId).batches.find(b => b.id === batchId)!;
278
+ if (b.state === "sending" && error instanceof ConversationGone) {
279
+ // Nothing reached the agent. Keep the batch for a new agent and stop retrying this one.
280
+ const w = findWorkspace(state, workspaceId);
281
+ b.state = "pending"; b.result = ""; b.recipientAgentId = null;
282
+ w.goneAgent = { agentId: error.agentId, at: new Date(this.now()).toISOString(), reason: error.message };
283
+ w.runtimeReason = GONE_REASON;
284
+ return;
285
+ }
259
286
  if (b.state === "sending" && error instanceof DeliveryDeferred) {
260
287
  b.state = "pending"; b.result = "";
261
288
  if (!isRecovery(b)) findWorkspace(state, workspaceId).runtimeReason = error.message;
@@ -0,0 +1,53 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { History } from "./discovery";
5
+ import type { Submission } from "./submissions";
6
+ import type { Artifact } from "../shared/spec";
7
+
8
+ /** What discovery keeps per agent: the distilled history, never raw timeline items. */
9
+ export type HistoryEntry = { messageAt: string | null; at: number; failures: number; retryAt: number; history: History; submissions: Submission[]; artifacts: Artifact[] };
10
+
11
+ /**
12
+ * Discovery's per-agent history, persisted so a worker or daemon restart doesn't rescan every timeline.
13
+ * Keyed by agent id and valid while the agent's lastUserMessageAt is unchanged. Only successful reads are
14
+ * saved; failures stay in memory with their backoff. A missing or unreadable file means an empty cache.
15
+ */
16
+ export class HistoryCache {
17
+ private entries = new Map<string, HistoryEntry>();
18
+ private loaded: Promise<void> | null = null;
19
+ private changed = false;
20
+ constructor(private path: string | null) {}
21
+ static at(directory: string): HistoryCache { return new HistoryCache(join(directory, "history-cache.json")); }
22
+ load(): Promise<void> {
23
+ this.loaded ??= (async () => {
24
+ if (!this.path) return;
25
+ try {
26
+ const file = JSON.parse(await readFile(this.path, "utf8"));
27
+ if (file?.version !== 1 || !file.agents || typeof file.agents !== "object") return;
28
+ for (const [id, value] of Object.entries(file.agents as Record<string, Partial<HistoryEntry>>)) {
29
+ if (!value || typeof value !== "object" || !value.history || !Array.isArray(value.history.candidates)) continue;
30
+ this.entries.set(id, { messageAt: value.messageAt ?? null, at: Number(value.at) || 0, failures: 0, retryAt: 0, history: value.history as History, submissions: Array.isArray(value.submissions) ? value.submissions : [], artifacts: Array.isArray(value.artifacts) ? value.artifacts : [] });
31
+ }
32
+ } catch { /* absent or unreadable: start empty */ }
33
+ })();
34
+ return this.loaded;
35
+ }
36
+ get(id: string): HistoryEntry | undefined { return this.entries.get(id); }
37
+ set(id: string, entry: HistoryEntry): void { this.entries.set(id, entry); if (!entry.history.error) this.changed = true; }
38
+ delete(id: string): void { if (this.entries.delete(id)) this.changed = true; }
39
+ keys(): string[] { return [...this.entries.keys()]; }
40
+ async save(): Promise<void> {
41
+ if (!this.path || !this.changed) return;
42
+ this.changed = false;
43
+ const agents: Record<string, Omit<HistoryEntry, "failures" | "retryAt">> = {};
44
+ for (const [id, e] of this.entries) if (!e.history.error) agents[id] = { messageAt: e.messageAt, at: e.at, history: e.history, submissions: e.submissions, artifacts: e.artifacts };
45
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
46
+ try {
47
+ await mkdir(join(this.path, ".."), { recursive: true, mode: 0o700 });
48
+ await writeFile(temporary, JSON.stringify({ version: 1, agents }), { mode: 0o600 });
49
+ await rename(temporary, this.path);
50
+ } catch { this.changed = true; }
51
+ finally { await unlink(temporary).catch(() => undefined); }
52
+ }
53
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import { pages, withDaemon, type Agent } from "./daemon";
3
3
  import type { AgentInfo, Directory } from "./inbox";
4
+ import { isLoaded } from "./agent-load";
4
5
 
5
6
  type Snapshot = Agent & { updatedAt?: string; parentAgentId?: string | null; requiresAttention?: boolean; attentionReason?: string | null; attentionTimestamp?: string | null };
6
7
  const info = (a: Snapshot): AgentInfo => ({
@@ -36,6 +37,9 @@ export function daemonDirectory(connect: typeof withDaemon = withDaemon): Direct
36
37
  },
37
38
  async tail(id, limit) {
38
39
  return connect(async client => {
40
+ // Called from turn hooks, so the agent is normally live. Never wake one that isn't.
41
+ const row = await client.fetchAgent(id);
42
+ if (!isLoaded(row?.agent)) return [];
39
43
  const page = await client.fetchAgentTimeline(id, { direction: "tail", limit, projection: "canonical", timeout: 10_000 });
40
44
  return [...page.entries].sort((a, b) => a.seqStart - b.seqStart).map(e => e.item);
41
45
  });
@@ -1,6 +1,7 @@
1
1
  import { isWorkspaceRoot } from "./workspace-agent";
2
2
  import type { Workspace } from "../shared/model";
3
3
  import type { Agent, Daemon } from "./daemon";
4
+ import { isLoaded } from "./agent-load";
4
5
 
5
6
  async function userMessages(client: Daemon, agentId: string): Promise<string[]> {
6
7
  const entries: { item: unknown; seqStart: number }[] = [];
@@ -23,6 +24,8 @@ export async function recoveryHandoff(client: Daemon, w: Workspace, caller: Agen
23
24
  if (!w.enable_tracking || w.archived || caller.archivedAt || caller.workspaceId !== w.id || caller.id === w.mainAgentId || !await isWorkspaceRoot(caller, async id => (await client.fetchAgent(id))?.agent)) return null;
24
25
  const candidates = w.batches.filter(b => b.recipientAgentId && b.recipientAgentId !== w.mainAgentId && b.recipientAgentId !== caller.id && b.tasks.every(t => t.kind === "inventory") && ["sending", "sent", "uncertain", "blocked"].includes(b.state) && !b.settledAt);
25
26
  if (!candidates.length) return null;
27
+ // The caller is the agent making this tool call, so it's loaded and reading it wakes nothing.
28
+ if (!isLoaded(caller)) return null;
26
29
  const initialTask = (await userMessages(client, caller.id))[0];
27
30
  if (!initialTask) return null;
28
31
  for (const b of candidates) {
@@ -30,6 +33,9 @@ export async function recoveryHandoff(client: Daemon, w: Workspace, caller: Agen
30
33
  const prior = (await client.fetchAgent(b.recipientAgentId!))?.agent;
31
34
  if (!prior?.archivedAt || prior.workspaceId !== w.id || Date.parse(caller.createdAt) < Date.parse(b.sentAt ?? b.createdAt)) continue;
32
35
  // An archived recipient plus the exact initial user task proves a handoff, not a historical mention.
36
+ // Reading the prior's timeline would load it (even archived), so an unloaded prior is proven by the
37
+ // caller's first message carrying this batch's unguessable id, sent after the batch went out.
38
+ if (!isLoaded(prior)) return { batchId: b.id, fromAgentId: prior.id };
33
39
  if ((await userMessages(client, prior.id)).includes(initialTask)) return { batchId: b.id, fromAgentId: prior.id };
34
40
  }
35
41
  return null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether Paseo has this agent's runtime in memory right now.
3
+ *
4
+ * Reading an agent's timeline (fetch_agent_timeline) or its provider subagents goes through the daemon's
5
+ * ensureAgentLoaded, which resumes an unloaded agent from persistence (provider CLI plus every MCP server,
6
+ * about 650 MB) and keeps it loaded for an hour. Follow-up must never do that on its own.
7
+ *
8
+ * An unloaded agent's snapshot is projected from its stored record and reports the last persisted
9
+ * status (usually "idle", rarely "closed"), so status alone can't tell. A loaded agent's snapshot always
10
+ * carries `activeTurn` (null between turns); the stored projection never does. Running, initializing and
11
+ * permission-waiting agents are live by definition. Anything else is treated as unloaded: when in doubt,
12
+ * don't wake it.
13
+ */
14
+ export type LoadSignals = { status: string; activeTurn?: unknown; pendingPermissions?: unknown[] | null };
15
+ export const LIVE_STATUSES = ["running", "initializing"];
16
+ export function isLoaded(agent: LoadSignals | null | undefined): boolean {
17
+ if (!agent) return false;
18
+ if (LIVE_STATUSES.includes(agent.status) || !!agent.pendingPermissions?.length) return true;
19
+ return agent.status !== "closed" && Object.prototype.hasOwnProperty.call(agent, "activeTurn") && agent.activeTurn !== undefined;
20
+ }
@@ -49,11 +49,14 @@ export const setProjectTrackingRpc = defineRpc({ name: "follow.set-project-track
49
49
  /** Following (1.3.0-beta.2): the two global switches and the per-workspace exceptions, for Settings only. */
50
50
  const followSettingsSchema = z.object({
51
51
  enable_follow_every: z.boolean(), enable_auto_sends_default: z.boolean(),
52
- workspaces: z.array(z.object({ id: idSchema, title: z.string(), enable_tracking: z.boolean(), enable_auto_sends: z.boolean(), optedOut: z.boolean() })),
52
+ /** Days without a user message before a followed workspace goes dormant (1.3.0-beta.3). */
53
+ activeWindowDays: z.number().int().optional(),
54
+ workspaces: z.array(z.object({ id: idSchema, title: z.string(), enable_tracking: z.boolean(), enable_auto_sends: z.boolean(), optedOut: z.boolean(), dormant: z.boolean().optional(), parked: z.boolean().optional() })),
53
55
  });
54
56
  export const followSettingsRpc = defineRpc({ name: "follow.follow-settings", input: z.object({}), output: followSettingsSchema });
55
57
  export const setFollowSettingsRpc = defineRpc({ name: "follow.set-follow-settings", input: z.object({
56
58
  enable_follow_every: z.boolean().optional(), enable_auto_sends_default: z.boolean().optional(),
59
+ activeWindowDays: z.number().int().min(1).max(60).optional(),
57
60
  workspace: z.object({ workspaceId: idSchema, follow: z.boolean().optional(), enable_auto_sends: z.boolean().optional() }).optional(),
58
61
  }), output: followSettingsSchema });
59
62
  /** External priorities for Home's Priority sort (written by the set_priorities agent tool). */
package/shared/model.ts CHANGED
@@ -78,6 +78,10 @@ export const workspaceSchema = z.object({
78
78
  candidates: z.array(idSchema), historyComplete: z.boolean(), historyError: z.string().nullable(),
79
79
  scannedMessageAt: z.string().nullable(),
80
80
  }).nullable().default(null),
81
+ /** 1.3.0-beta.3: outside the active scope (no loaded agent, no recent message, no open PR). Absent means active. */
82
+ dormant: z.boolean().optional(),
83
+ /** The main agent's provider conversation no longer exists; Follow-up stops sending to it. */
84
+ goneAgent: z.object({ agentId: idSchema, at: z.string(), reason: z.string() }).nullable().optional(),
81
85
  });
82
86
  export const stateSchema = z.object({
83
87
  version: z.literal(1), workspaces: z.array(workspaceSchema),
@@ -91,6 +95,10 @@ export const stateSchema = z.object({
91
95
  workspaceOptOuts: z.array(idSchema).optional(),
92
96
  /** One-time migrations already applied, so they never run twice. */
93
97
  migrations: z.array(z.string()).optional(),
98
+ /** Days without a user message before a followed workspace goes dormant. Absent means 3. */
99
+ activeWindowDays: z.number().int().min(1).max(60).optional(),
100
+ /** Workspaces whose auto-send the active-scope migration turned off; it turns back on when they become active. */
101
+ autoSendsParked: z.array(idSchema).optional(),
94
102
  });
95
103
  export type Snapshot = z.infer<typeof snapshotSchema>;
96
104
  export type Deployment = z.infer<typeof deploymentSchema>;
@@ -43,3 +43,21 @@ export function migrateFollowEvery(state: { workspaces: { id: string; archived:
43
43
  }
44
44
  return { tracked, autoSends };
45
45
  }
46
+
47
+ export const ACTIVE_SCOPE_MIGRATION = "active-scope-1";
48
+ /**
49
+ * 1.3.0-beta.3: undo the beta.2 blast radius. Once, turn auto-send off for followed workspaces outside the active
50
+ * scope and remember them; each turns back on when its workspace becomes active (see applyDiscovery).
51
+ * `active` holds the workspace ids discovery found active. Returns how many were parked, or null if already applied.
52
+ */
53
+ export function migrateActiveScope(state: { workspaces: { id: string; archived: boolean; enable_tracking: boolean; enable_auto_sends: boolean }[]; migrations?: string[]; autoSendsParked?: string[] }, active: Set<string>): number | null {
54
+ if (state.migrations?.includes(ACTIVE_SCOPE_MIGRATION)) return null;
55
+ state.migrations = [...state.migrations ?? [], ACTIVE_SCOPE_MIGRATION];
56
+ const parked = new Set(state.autoSendsParked ?? []);
57
+ for (const w of state.workspaces) {
58
+ if (w.archived || !w.enable_tracking || !w.enable_auto_sends || active.has(w.id)) continue;
59
+ w.enable_auto_sends = false; parked.add(w.id);
60
+ }
61
+ if (parked.size) state.autoSendsParked = [...parked];
62
+ return parked.size;
63
+ }