paseo-follow-up 1.1.0-beta.0 → 1.2.0-beta.0

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/OVERVIEW.md CHANGED
@@ -24,6 +24,8 @@ Auto-send starts off for every workspace. Until you turn it on, each repair batc
24
24
 
25
25
  ## Data and access
26
26
 
27
- This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks only for tracked sessions. To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
27
+ This is a trusted, unsandboxed plugin. It reads workspace metadata, saved agent history and configuration, GitHub status and existing CLI authentication, and keeps its state in `plugin-data/follow-through` on the daemon host. It registers scoped MCP tools and reversible provider hooks for tracked sessions and, while the Needs-you inbox is on, the `needs_you` tool and its Stop-hook nudge for root agents (see below). To find the spec link it reads completed Claude Artifact publish results in the workspace's root sessions, never page content, chat links or the repository; a link you set wins over the agent's, which wins over detection. Repair prompts go to your configured agent and provider. It has no Slack identity and no background channel scanning; replies to stakeholders need your authorization and a sent-message receipt from the agent.
28
28
 
29
- The Needs-you inbox (beta) keeps one flag per root agent in `plugin-data/follow-through/inbox.json`. When a turn starts it reads the newest user message from the agent's recent timeline to tell your messages from automation, and it reads, never writes, Defer's `plugin-data/defer/queue.json`, Smart Session's `plugin-data/smart-session/compactions.json`, Session Guard's `plugin-data/session-guard/state.json` and Paseo's schedule records. An unknown or unreadable format counts as your message, which clears a flag rather than adding one. Signing off also clears Paseo's own unread dot for that agent. Turning the inbox off stops all of this.
29
+ The Needs-you inbox (beta) keeps one flag per root agent in `plugin-data/follow-through/inbox.json`. When a turn starts it reads the newest user message from the agent's recent timeline to tell your messages from automation, and it reads, never writes, Defer's `plugin-data/defer/queue.json`, Smart Session's `plugin-data/smart-session/compactions.json`, Session Guard's `plugin-data/session-guard/state.json` and Paseo's schedule records. An unknown or unreadable format counts as your message, which clears a flag rather than adding one. Signing off also clears Paseo's own unread dot for that agent. The first-run import and the restart check read only Paseo's agent list: an agent's last activity is its unread time or your last message to it, never Paseo's `updatedAt` (which the daemon rewrites in bulk), and they don't fetch timelines, which would load every agent. Closed and archived agents are never imported. Turning the inbox off stops all of this.
30
+
31
+ The brief (beta) is stored on the agent's inbox entry and appended to its timeline as a Follow-up row; re-appending replaces that row, and the restart check ignores the `updatedAt` change the append causes because it never reads `updatedAt`. To build a brief Follow-up reads only what it already has: the agent's `needs_you` call, the permission request's title, the failed turn's error message, Session Guard's note and reset time for a paused agent, and its own PR state. It does not read or quote the agent's reply. While the inbox is on, every root Paseo agent on Claude Code or Codex gets Follow-up's MCP bridge with the `needs_you` tool only (tracked main agents keep their full set), and `plugin-data/follow-through/brief.json` records whether the inbox and the nudge are on and which agents have a permission prompt showing. Clicking an option or Retry sends one message as you; it is logged in the delivery ledger as yours, so it clears the flag. The Stop hook, part of the same reversible provider hooks Follow-up already installs, now also runs for those root Claude Code sessions: if the turn ended without `needs_you`, it blocks the stop once with a one-line request to call it, records that it did in `hook-state`, and never blocks the same turn again. It reads `brief.json`, its own per-turn marker and the time of the agent's last `needs_you` call, and nothing else. Turn it off with *Ask agents to state what they need*; turning the inbox off stops it too.
package/README.md CHANGED
@@ -84,10 +84,23 @@ The 1.1 beta adds an inbox for your agents. An agent lands in **Needs you** when
84
84
  - On Paseo builds with sidebar views (Paseo `main` after 2026-11-07, expected in the next release), **Settings > Sidebar > Sidebar view > Follow-up: Needs you** puts the inbox in the sidebar, with your workspace list folded below it.
85
85
  - Turn the whole inbox off in **Follow-up: display settings** (⌘K). Off means nothing is flagged and no pill or sidebar item is shown.
86
86
 
87
+ ### What you need to do, front and center (1.2 beta)
88
+
89
+ When an agent needs you, Follow-up adds a **brief** at the end of its chat, right above the composer, so it's the first thing you see when you open the agent:
90
+
91
+ - **The ask in one line**, written as what you do: *Pick how the export API is rate-limited*, *Review and merge #813*, *Allow or deny: run the migration on staging*, or *Nothing needed. Daily updates are scheduled.* The same line shows on the inbox row and in the pill's tooltip, and the pill's label comes from it (*Asks*, *Merge*, *Error*, *Paused*, *Finished*).
92
+ - **Decisions as option rows.** Click one and it goes to the agent as your message, which clears the flag. Typing the letter or the option's label in the composer counts too. On a phone, or a window too short for the whole card, each option shows its letter and label, with what it means one tap away, so the card fits above the composer.
93
+ - **Only the context needed**, plus PR facts Follow-up already knows, and the one move for this kind of ask: *Squash & merge* (Follow-up's merge flow), *Retry the turn*, *Copy error* or *Sign off & next*. Permission briefs are a single line: Paseo's own prompt right below is the control.
94
+ - **Where it came from**, always: stated by the agent, from the permission request, Session Guard, the failed turn or Follow-up's PR state. If the agent stated nothing, the card says so and points at its last paragraph. It never guesses an ask.
95
+
96
+ The content comes from a new `needs_you` tool that every root agent gets while the inbox is on, with an instruction to call it once at the end of a turn. Contributors, recovery agents and subagents don't get it. After you answer, sign off or the agent keeps going, the card folds to one muted line, so the history still shows what was asked and what happened.
97
+
98
+ **Ask agents to state what they need** (on by default, in **Follow-up: display settings**): when a Claude Code root agent ends a turn without calling `needs_you`, Follow-up's Stop hook asks it once to call it, then lets it stop. It never asks twice in one turn, and skips subagents, contributors, recovery agents and agents waiting on a permission prompt. Codex and other providers get the instruction only. The settings screen shows how often agents stated what they need over the last 7 days.
99
+
87
100
  Install the beta:
88
101
 
89
102
  ```sh
90
- paseo plugin update follow-through --version 1.1.0-beta.0
103
+ paseo plugin update follow-through --version 1.2.0-beta.0
91
104
  # or, for a fresh install:
92
105
  paseo plugin install npm:paseo-follow-up@beta
93
106
  ```
@@ -0,0 +1,221 @@
1
+ import { useState } from "react";
2
+ import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { openExternalUrl, useRpc, type PluginClientContext, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
5
+ import { Icon, copyText } from "@getpaseo/plugin/client/ui";
6
+ import { BRIEF_KIND, BRIEF_LABEL, BRIEF_VERSION, briefSchema, collapsedLine, extrasFit, isTight, type Brief, type BriefDecision, type BriefMove } from "../shared/brief";
7
+ import { age } from "../shared/inbox";
8
+ import { briefActionRpc, browserStatusRpc } from "../shared/contracts";
9
+ import { errorText } from "../shared/errors";
10
+ import type { Workspace } from "../shared/model";
11
+ import { useNow } from "./kit";
12
+ import { PrSheet } from "./pr-sheet";
13
+ import { isLight, withAlpha, type Colors } from "./theme";
14
+
15
+ /**
16
+ * The brief card (design §2): the ask in one line, decisions as option rows, only the context needed, and the one
17
+ * move for this kind of ask. It sits at the end of the timeline, right above the composer and the Needs-you control.
18
+ */
19
+ export type BriefDeps = {
20
+ signOff(agentId: string): void;
21
+ openNext(from: string): void;
22
+ openAgent(agentId: string): void;
23
+ workspace(id: string): Workspace | undefined;
24
+ /** Opens Follow-up's panel for a workspace whose record this client hasn't loaded yet. */
25
+ openPanel(workspaceId: string): void;
26
+ };
27
+
28
+ const mono = () => (Platform.OS === "ios" ? "Menlo" : "ui-monospace, SFMono-Regular, Menlo, monospace");
29
+ /** Kinds the developer has to act on wear the attention tone; a plain finish and the fallback stay neutral. */
30
+ const ACTS: Brief["kind"][] = ["decision", "action", "review", "permission", "paused", "error"];
31
+ const ICON: Record<Brief["kind"], string> = { decision: "MessageCircleQuestion", action: "Hand", review: "GitPullRequest", permission: "ShieldQuestion", paused: "CirclePause", error: "CircleX", done: "CircleCheck", unknown: "CircleDashed" };
32
+ const kindTone = (kind: Brief["kind"], c: Colors) => kind === "error" ? c.statusDanger : kind === "review" || kind === "done" ? c.statusSuccess : kind === "unknown" ? c.foregroundMuted : c.statusWarning;
33
+ const factTone = (tone: string, c: Colors) => tone === "ok" ? c.statusSuccess : tone === "warn" ? c.statusWarning : tone === "bad" ? c.statusDanger : c.foregroundMuted;
34
+ const clock = (iso: string | null) => { if (!iso) return ""; const d = new Date(iso); return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }) : ""; };
35
+ /** Web keyboard focus: a visible ring, since sending an answer is one-shot. */
36
+ const ring = (focused: boolean | undefined, c: Colors) => focused && Platform.OS === "web" ? ({ outlineWidth: 2, outlineStyle: "solid", outlineColor: withAlpha(c.accent, 0.6), outlineOffset: 2 } as object) : {};
37
+ const letters = (d: BriefDecision) => { const l = d.options.map(o => o.letter); return l.length > 1 ? `${l.slice(0, -1).join(", ")} or ${l.at(-1)}` : l[0] ?? ""; };
38
+
39
+ function Btn({ label, icon, primary, onPress, c, compact, disabled }: { label: string; icon?: string; primary?: boolean; onPress: () => void; c: Colors; compact: boolean; disabled?: boolean }) {
40
+ const fg = primary ? c.accentForeground : c.foreground;
41
+ return <Pressable accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled }} disabled={disabled} onPress={onPress}
42
+ style={({ hovered, pressed, focused }: { hovered?: boolean; pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), minHeight: compact ? 44 : 32, paddingHorizontal: compact ? 14 : 12, borderRadius: 6, flexDirection: "row", alignItems: "center", gap: 6, justifyContent: "center", opacity: disabled ? 0.5 : 1,
43
+ backgroundColor: primary ? (pressed ? withAlpha(c.accent, 0.85) : hovered ? withAlpha(c.accent, 0.92) : c.accent) : pressed || hovered ? c.surface2 : "transparent",
44
+ borderWidth: primary ? 0 : 1, borderColor: c.border, flexGrow: compact ? 1 : 0 })}>
45
+ {icon ? <Icon name={icon} size={compact ? 16 : 14} color={fg} /> : null}
46
+ <Text numberOfLines={1} style={{ color: fg, fontSize: compact ? 14 : 13, lineHeight: compact ? 19 : 17, fontWeight: "600" }}>{label}</Text>
47
+ </Pressable>;
48
+ }
49
+
50
+ function OptionRow({ option, recommended, sending, disabled, tight, open, onToggle, onPress, c, compact }: {
51
+ option: BriefDecision["options"][number]; recommended: boolean; sending: boolean; disabled: boolean; tight: boolean; open: boolean; onToggle: () => void; onPress: () => void; c: Colors; compact: boolean;
52
+ }) {
53
+ const strong = withAlpha(c.foreground, isLight(c) ? 0.38 : 0.42);
54
+ const showDetail = !!option.consequence && (!tight || open);
55
+ return <View style={{ flexDirection: "row", alignItems: "stretch", borderRadius: 8, borderWidth: 1, borderColor: recommended ? strong : c.border, opacity: disabled && !sending ? 0.5 : 1, overflow: "hidden" }}>
56
+ <Pressable accessibilityRole="button" accessibilityLabel={`Answer ${option.letter}: ${option.label}${recommended ? " (recommended)" : ""}`} accessibilityHint={option.consequence ?? undefined} accessibilityState={{ disabled, busy: sending }} disabled={disabled} onPress={onPress}
57
+ style={({ hovered, pressed, focused }: { hovered?: boolean; pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), flex: 1, flexDirection: "row", alignItems: "flex-start", gap: 10, paddingVertical: compact ? 11 : 9, paddingHorizontal: 10, minHeight: compact ? 44 : 0,
58
+ backgroundColor: pressed ? c.surface2 : hovered && !disabled ? withAlpha(c.foreground, 0.05) : "transparent" })}>
59
+ <View style={{ width: 22, height: 22, borderRadius: 5, alignItems: "center", justifyContent: "center", backgroundColor: recommended ? c.foreground : c.surface2 }}>
60
+ <Text style={{ fontSize: 12, fontWeight: "700", color: recommended ? c.surface1 : c.foreground }}>{option.letter}</Text>
61
+ </View>
62
+ <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
63
+ <View style={{ flexDirection: "row", alignItems: "center", columnGap: 8, flexWrap: "wrap" }}>
64
+ <Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{option.label}</Text>
65
+ {recommended && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Recommended</Text>}
66
+ {sending && <Text accessibilityLiveRegion="polite" style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>Sending…</Text>}
67
+ </View>
68
+ {showDetail && <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{option.consequence}</Text>}
69
+ </View>
70
+ </Pressable>
71
+ {tight && !!option.consequence && <Pressable accessibilityRole="button" aria-expanded={open} accessibilityLabel={`${open ? "Hide" : "Show"} what ${option.letter} means`} onPress={onToggle}
72
+ style={({ pressed, focused }: { pressed: boolean; focused?: boolean }) => ({ ...ring(focused, c), width: 44, alignItems: "center", justifyContent: "center", borderLeftWidth: 1, borderLeftColor: c.border, backgroundColor: pressed ? c.surface2 : "transparent" })}>
73
+ <View style={{ transform: [{ rotate: open ? "180deg" : "0deg" }] }}><Icon name="ChevronDown" size={16} color={c.foregroundMuted} /></View>
74
+ </Pressable>}
75
+ </View>;
76
+ }
77
+
78
+ function Collapsed({ b, c, now }: { b: Brief; c: Colors; now: number }) {
79
+ // F8: history keeps what was asked and what happened, as one muted line (two when an answer was picked).
80
+ const answered = b.state === "answered";
81
+ const icon = b.state === "superseded" ? "History" : "CircleCheck";
82
+ const color = b.state === "superseded" ? c.foregroundMuted : c.statusSuccess;
83
+ const right = answered && b.outcomeAt ? `sent ${clock(b.outcomeAt)}` : age(b.outcomeAt ?? b.at, now);
84
+ return <View accessibilityLiveRegion="polite" style={{ marginVertical: 10, flexDirection: "row", gap: 10, alignItems: answered ? "flex-start" : "center", minHeight: 40, paddingVertical: answered ? 10 : 8, paddingHorizontal: 12, borderRadius: 10, borderWidth: 1, borderColor: c.border }}>
85
+ <View style={{ paddingTop: answered ? 2 : 0 }}><Icon name={icon} size={15} color={color} /></View>
86
+ {answered
87
+ ? <View style={{ flex: 1, minWidth: 0, gap: 2 }}>
88
+ <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{b.ask}</Text>
89
+ <Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20, fontWeight: "600" }}>{b.outcome}</Text>
90
+ </View>
91
+ : <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: c.foregroundMuted, fontSize: 13, lineHeight: 18 }}>{collapsedLine(b)}</Text>}
92
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{right}</Text>
93
+ </View>;
94
+ }
95
+
96
+ export function BriefCard({ item, theme, layout, agentId, deps }: PluginTimelineItemProps<Brief> & { deps: BriefDeps }) {
97
+ const c = theme.colors; const b = item.data; const compact = layout.compact;
98
+ const now = useNow(60_000);
99
+ const { height } = useWindowDimensions();
100
+ const act = useRpc(briefActionRpc);
101
+ const browserRead = useRpc(browserStatusRpc);
102
+ const [busy, setBusy] = useState<string | null>(null);
103
+ const [error, setError] = useState<string | null>(null);
104
+ const [open, setOpen] = useState<Record<string, boolean>>({});
105
+ const [details, setDetails] = useState(false);
106
+ const [sheet, setSheet] = useState<string | null>(null);
107
+ const workspace = b.workspaceId ? deps.workspace(b.workspaceId) : undefined;
108
+ const browser = useQuery({ queryKey: ["follow-through", "browser"], queryFn: () => browserRead({}), refetchInterval: 5000, enabled: !!sheet });
109
+ if (b.state !== "open") return <Collapsed b={b} c={c} now={now} />;
110
+
111
+ const attention = c.statusWarning;
112
+ const acts = b.flagged && ACTS.includes(b.kind);
113
+ const tone = acts || b.kind === "done" || b.kind === "review" || b.kind === "error" ? kindTone(b.kind, c) : c.foregroundMuted;
114
+ const ageText = age(b.at, now);
115
+ const border = acts ? withAlpha(attention, isLight(c) ? 0.5 : 0.45) : c.border;
116
+ const run = async (key: string, work: () => Promise<unknown>) => {
117
+ if (busy) return; setBusy(key); setError(null);
118
+ try { await work(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); }
119
+ };
120
+ const press = (m: BriefMove) => {
121
+ if (m.kind === "merge" && m.prKey) { if (workspace?.prs.some(p => p.key === m.prKey)) setSheet(m.prKey); else if (b.workspaceId) deps.openPanel(b.workspaceId); }
122
+ else if (m.kind === "open_pr" && m.url) void openExternalUrl(m.url).catch(() => undefined);
123
+ else if (m.kind === "sign_off_next") { deps.signOff(agentId); deps.openNext(agentId); }
124
+ else if (m.kind === "retry") void run("retry", () => act({ agentId, briefId: b.id, move: "retry" }));
125
+ else if (m.kind === "copy_error" && b.mono) void copyText(b.mono).catch(() => undefined);
126
+ else if (m.kind === "open_subagent" && m.agentId) deps.openAgent(m.agentId);
127
+ };
128
+ // A merge runs Follow-up's own merge flow, so it needs the tracked workspace; without one it isn't offered.
129
+ const moves = b.moves.filter(m => m.kind !== "merge" || !!b.workspaceId);
130
+
131
+ if (b.slim) {
132
+ // One line. Permission: Paseo's own prompt below is the control (F9). Fallback: the paragraph is right above.
133
+ return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you: " : ""}${b.ask}. ${b.sourceLine}`}
134
+ style={{ marginVertical: 10, flexDirection: "row", alignItems: "center", flexWrap: compact ? "wrap" : "nowrap", gap: 8, minHeight: 40, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, borderWidth: 1, borderColor: border }}>
135
+ <Icon name={ICON[b.kind]} size={14} color={acts ? attention : tone} />
136
+ {acts && <Text style={{ color: attention, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Needs you</Text>}
137
+ <Text style={{ flex: 1, minWidth: compact ? 160 : 0, color: acts ? c.foreground : c.foregroundMuted, fontSize: 14, lineHeight: 20, fontWeight: acts ? "600" : "400" }}>{b.ask}</Text>
138
+ {moves.map(m => <Btn key={m.kind} label={m.label} onPress={() => press(m)} c={c} compact={compact} />)}
139
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
140
+ </View>;
141
+ }
142
+
143
+ const tight = isTight(b, compact, height);
144
+ const extra = !!b.context || b.facts.length > 0 || b.links.length > 0;
145
+ const showExtra = !tight || details || extrasFit(b, height);
146
+ const pad = compact ? 14 : 16;
147
+ return <View accessibilityRole="summary" accessibilityLabel={`${acts ? "Needs you" : BRIEF_LABEL[b.kind]}: ${b.ask}`}
148
+ style={{ marginVertical: 10, borderRadius: 12, borderWidth: 1, borderColor: border, backgroundColor: c.surface1, overflow: "hidden" }}>
149
+ <View style={{ paddingHorizontal: pad, paddingTop: compact ? 12 : 14, paddingBottom: 14, gap: tight ? 10 : 12 }}>
150
+ <View style={{ gap: 4 }}>
151
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
152
+ <Icon name={ICON[b.kind]} size={14} color={tone} />
153
+ <Text style={{ color: acts ? attention : tone, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{acts ? "Needs you" : BRIEF_LABEL[b.kind]}</Text>
154
+ {acts && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{BRIEF_LABEL[b.kind]}</Text>}
155
+ <View style={{ flex: 1 }} />
156
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontVariant: ["tabular-nums"] }}>{ageText}</Text>
157
+ </View>
158
+ <Text accessibilityRole="header" style={{ color: c.foreground, fontSize: compact ? 17 : 19, lineHeight: compact ? 23 : 26, fontWeight: "600", letterSpacing: -0.2 }}>{b.ask}</Text>
159
+ </View>
160
+
161
+ {b.decisions.map((d, i) => <View key={d.id} style={{ gap: 8 }}>
162
+ <Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20 }}>{b.decisions.length > 1 ? `${i + 1}. ` : ""}{d.question}</Text>
163
+ {d.options.length > 0 && <View style={{ gap: 6 }}>
164
+ {d.options.map(o => <OptionRow key={o.letter} option={o} recommended={d.recommend === o.letter} sending={busy === `${d.id}:${o.letter}`} disabled={!!busy} tight={tight}
165
+ open={!!open[`${d.id}:${o.letter}`]} onToggle={() => setOpen(v => ({ ...v, [`${d.id}:${o.letter}`]: !v[`${d.id}:${o.letter}`] }))}
166
+ onPress={() => void run(`${d.id}:${o.letter}`, () => act({ agentId, briefId: b.id, decisionId: d.id, choice: o.letter }))} c={c} compact={compact} />)}
167
+ </View>}
168
+ {!tight && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{d.options.length ? `Or type ${letters(d)} in the composer, or reply in your own words.` : "Reply in the composer."}</Text>}
169
+ {tight && !d.options.length && <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>Reply in the composer.</Text>}
170
+ </View>)}
171
+
172
+ {!!b.mono && !b.ask.endsWith(b.mono) && <View style={{ borderRadius: 8, backgroundColor: withAlpha(c.statusDanger, isLight(c) ? 0.07 : 0.1), paddingHorizontal: 10, paddingVertical: 8 }}>
173
+ <Text selectable numberOfLines={tight ? 3 : undefined} style={{ color: c.foreground, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>{b.mono}</Text>
174
+ </View>}
175
+
176
+ {extra && tight && !extrasFit(b, height) && <Pressable accessibilityRole="button" aria-expanded={details} accessibilityLabel={details ? "Hide details" : "Show details"} onPress={() => setDetails(v => !v)} hitSlop={6}
177
+ style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start", minHeight: compact ? 44 : 24 }}>
178
+ <View style={{ transform: [{ rotate: details ? "90deg" : "0deg" }] }}><Icon name="ChevronRight" size={14} color={c.foregroundMuted} /></View>
179
+ <Text style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 18, fontWeight: "500" }}>Details</Text>
180
+ </Pressable>}
181
+
182
+ {showExtra && !!b.context && <Text selectable numberOfLines={4} style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{b.context}</Text>}
183
+
184
+ {showExtra && (b.facts.length > 0 || b.links.length > 0) && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
185
+ {b.facts.slice(0, 3).map((f, i) => { const col = factTone(f.tone, c); return <View key={`f${i}`} style={{ flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8, minHeight: 24, borderRadius: 6, backgroundColor: f.tone === "muted" ? c.surface2 : withAlpha(col, isLight(c) ? 0.1 : 0.14) }}>
186
+ <Icon name={f.icon} size={12} color={col} />
187
+ <Text style={{ color: f.tone === "muted" || isLight(c) ? c.foreground : col, fontSize: 12, lineHeight: 16, fontWeight: "500" }}>{f.label}</Text>
188
+ </View>; })}
189
+ {b.links.map((l, i) => <Pressable key={`l${i}`} accessibilityRole={l.url ? "link" : "text"} disabled={!l.url} onPress={() => l.url && void openExternalUrl(l.url).catch(() => undefined)} hitSlop={compact ? 10 : 4}
190
+ style={{ flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8, minHeight: 24, borderRadius: 6, backgroundColor: c.surface2 }}>
191
+ <Icon name={l.url ? "ExternalLink" : l.pr ? "GitPullRequest" : "File"} size={12} color={c.foregroundMuted} />
192
+ <Text selectable={!l.url} numberOfLines={1} style={{ color: c.foreground, fontSize: 12, lineHeight: 16, fontWeight: "500", maxWidth: 260 }}>{l.label}</Text>
193
+ </Pressable>)}
194
+ </View>}
195
+
196
+ {moves.length > 0 && <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
197
+ {moves.map((m, i) => <Btn key={m.kind} label={busy === m.kind ? `${m.label}…` : m.label} icon={MOVE_ICON[m.kind]} primary={i === 0} disabled={!!busy} onPress={() => press(m)} c={c} compact={compact} />)}
198
+ </View>}
199
+
200
+ {!!error && <Text accessibilityRole="alert" style={{ color: c.statusDanger, fontSize: 13, lineHeight: 18 }}>{error}</Text>}
201
+
202
+ {!!b.also && <View style={{ gap: 3, borderTopWidth: 1, borderTopColor: c.border, paddingTop: 10 }}>
203
+ <Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>Also, from the agent:</Text>
204
+ <Text style={{ color: c.foreground, fontSize: 14, lineHeight: 20 }}>{b.also.ask}</Text>
205
+ {showExtra && !!b.also.context && <Text numberOfLines={3} style={{ color: c.foregroundMuted, fontSize: 13, lineHeight: 19 }}>{b.also.context}</Text>}
206
+ </View>}
207
+ </View>
208
+ <View style={{ borderTopWidth: 1, borderTopColor: c.border, paddingHorizontal: pad, paddingVertical: 8, flexDirection: "row", alignItems: "center", gap: 6 }}>
209
+ <Icon name="Info" size={12} color={c.foregroundMuted} />
210
+ <Text style={{ flex: 1, color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{b.sourceLine}</Text>
211
+ </View>
212
+ {workspace && sheet && <PrSheet workspace={workspace} prKey={sheet} intent="merge" theme={theme} compact={compact} browserReady={browser.data?.ready ?? false} onClose={() => setSheet(null)} />}
213
+ </View>;
214
+ }
215
+
216
+ const MOVE_ICON: Record<BriefMove["kind"], string> = { merge: "GitMerge", open_pr: "ExternalLink", sign_off_next: "ArrowRight", retry: "RotateCcw", copy_error: "Copy", open_subagent: "SquareArrowOutUpRight" };
217
+
218
+ export function contributeBrief(client: PluginClientContext, deps: BriefDeps) {
219
+ const Component = (props: PluginTimelineItemProps<Brief>) => <BriefCard {...props} deps={deps} />;
220
+ return client.addTimelineRenderer({ kind: BRIEF_KIND, version: BRIEF_VERSION, schema: briefSchema, Component });
221
+ }
package/client/chat.tsx CHANGED
@@ -104,7 +104,7 @@ function AnswerCardView({ item, theme }: PluginTimelineItemProps<AnswerCard>) {
104
104
  <View style={{ borderRadius: 10, borderWidth: 1, borderColor: c.border, backgroundColor: c.surface1, overflow: "hidden" }}>
105
105
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
106
106
  <Icon name="MessageCircleReply" size={14} color={c.statusSuccess} />
107
- <Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Your answer to {card.id} · {card.title}</Text>
107
+ <Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>Your answer{/^D\d+$/.test(card.id) ? "" : ` to ${card.id}`} · {card.title}</Text>
108
108
  <Pressable accessibilityRole="button" aria-expanded={expanded} accessibilityLabel={expanded ? "Hide the full message" : "Show the full message"} onPress={() => setExpanded(v => !v)} hitSlop={6}
109
109
  style={state => ({ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, backgroundColor: state.pressed ? c.surface2 : "transparent" })}>
110
110
  <Text style={{ color: c.foregroundMuted, fontSize: 12 }}>{expanded ? "Hide" : "Full message"}</Text>
@@ -1,5 +1,7 @@
1
1
  import type { PluginClientContext, PluginSidebarItemContribution, PluginSurfaceProps } from "@getpaseo/plugin/client";
2
- import { useSettings } from "@getpaseo/plugin/client";
2
+ import { useRpc, useSettings } from "@getpaseo/plugin/client";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { inboxListRpc } from "../shared/contracts";
3
5
  import { SettingsAction, SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
4
6
  import { useEffect } from "react";
5
7
  import { ActivityIndicator, ScrollView, Text, View } from "react-native";
@@ -10,8 +12,19 @@ type Display = import("zod").infer<typeof displayPreferences.schema>;
10
12
  const listeners = new Set<(values: Display) => void>();
11
13
  export function subscribeDisplay(listener: (values: Display) => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }
12
14
 
15
+ /** R2's measurement, where it's cheap to look: how often agents stated what they need on a flagged turn. */
16
+ export function briefShareText(stats: { days: number; total: number; tool: number; structured: number; fallback: number } | undefined): string {
17
+ if (!stats) return "";
18
+ if (!stats.total) return `No flagged turns in the last ${stats.days} days yet.`;
19
+ const pct = Math.round((stats.tool / stats.total) * 100);
20
+ const rest = [stats.structured ? `${stats.structured} from a permission, pause, error or PR` : "", stats.fallback ? `${stats.fallback} with nothing stated` : ""].filter(Boolean).join(", ");
21
+ return `Last ${stats.days} days: agents stated what they need on ${stats.tool} of ${stats.total} flagged turns (${pct}%).${rest ? ` ${rest[0].toUpperCase()}${rest.slice(1)}.` : ""}`;
22
+ }
23
+
13
24
  function DisplaySettings({ theme }: PluginSurfaceProps) {
14
25
  const settings = useSettings(displayPreferences);
26
+ const list = useRpc(inboxListRpc);
27
+ const share = useQuery({ queryKey: ["follow-through", "brief-share"], queryFn: () => list({}), refetchInterval: 60_000 });
15
28
  const values = settings.status === "ready" ? settings.values : undefined;
16
29
  useEffect(() => {
17
30
  if (values !== undefined) for (const listener of listeners) listener(values);
@@ -46,7 +59,9 @@ function DisplaySettings({ theme }: PluginSurfaceProps) {
46
59
  <SettingsSwitch label="Needs-you inbox" hint="Off: nothing is flagged, and the pill and sidebar item are hidden." value={settings.values.enable_inbox} disabled={settings.saving} onValueChange={(enable_inbox) => save({ enable_inbox })} />
47
60
  <SettingsSwitch label="Show the sidebar item at zero" hint="By default the Needs you item appears only while something needs you." value={settings.values.enable_inbox_header_when_empty} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_inbox_header_when_empty) => save({ enable_inbox_header_when_empty })} />
48
61
  <SettingsSwitch label="Flag merged and closed PRs" hint="At the lowest priority, as the cue to archive." value={settings.values.enable_inbox_pr_closed} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_inbox_pr_closed) => save({ enable_inbox_pr_closed })} />
62
+ <SettingsSwitch label="Ask agents to state what they need" hint="A Claude Code agent that ends a turn without saying what you need is asked once to state it, for the card at the end of its chat. Other providers only get the instruction." value={settings.values.enable_brief_nudge} disabled={settings.saving || !settings.values.enable_inbox} onValueChange={(enable_brief_nudge) => save({ enable_brief_nudge })} />
49
63
  </SettingsCard>
64
+ {settings.values.enable_inbox && !!share.data?.briefStats && <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17, paddingHorizontal: 4 }}>{briefShareText(share.data.briefStats)}</Text>}
50
65
  </SettingsSection>
51
66
  </ScrollView>
52
67
  );
package/client/inbox.tsx CHANGED
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } fro
2
2
  import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
3
3
  import type { PluginButtonIconProps, PluginButtonRegistration, PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
4
4
  import { Icon, SidebarRow } from "@getpaseo/plugin/client/ui";
5
+ import { BRIEF_LABEL, BRIEF_WORD, briefLine } from "../shared/brief";
5
6
  import { age, displayReason, nextAfter, REASON_LABEL, REASON_WORD, rowTitle, sectionRows, snoozeUntil, wakeLabel, type InboxRow, type Reason } from "../shared/inbox";
6
7
  import { Button, IconButton, useNow, type Theme } from "./kit";
7
8
  import { Spinner } from "./motion";
@@ -71,17 +72,20 @@ export function InboxRowView({ row, theme, now, dense, touch, active, onOpen, on
71
72
  const reasonColor = shown && !row.snooze ? reasonGlyph(shown.reason, c).color : c.foregroundMuted;
72
73
  const second: ReactNode[] = [];
73
74
  if (title.agent && narrow) second.push(<Text key="a" style={{ color: c.foreground }}>{title.agent} · </Text>);
74
- if (shown) second.push(<Text key="r" style={{ color: reasonColor, fontWeight: "600" }}>{REASON_LABEL[shown.reason]}</Text>);
75
+ if (shown) second.push(<Text key="r" style={{ color: reasonColor, fontWeight: "600" }}>{row.brief?.state === "open" && shown.reason === "finished" ? BRIEF_LABEL[row.brief.kind] : REASON_LABEL[shown.reason]}</Text>);
75
76
  else if (row.signedOffAt && !running(row)) second.push(<Text key="s" style={{ color: c.statusSuccess }}>Signed off</Text>);
76
77
  else if (row.resumesAt) second.push(<Text key="p">Paused at the limit</Text>);
77
78
  else if (running(row)) second.push(<Text key="w">Working</Text>);
78
- if (shown?.detail) second.push(<Text key="d">{` · ${shown.detail}`}</Text>);
79
+ // §4: an open brief's ask is the reason line, so the row, the card and the composer say the same thing.
80
+ const asked = row.brief && shown ? briefLine(row.brief) : null;
81
+ const detail = asked ?? shown?.detail;
82
+ if (detail) second.push(<Text key="d">{` · ${detail}`}</Text>);
79
83
  if (more > 0) second.push(<Text key="m">{` · +${more}`}</Text>);
80
84
  if (!row.enable_finish_flags && !shown) second.push(<Text key="f">{second.length ? " · " : ""}Finishes not flagged</Text>);
81
85
  if (row.delegatedFrom && !shown) second.push(<Text key="g">{second.length ? " · " : ""}Delegated</Text>);
82
86
  const quiet = !row.needs || !!row.snooze;
83
87
  const showCheck = !!onSignOff && !selecting && (touch || hover || focused || !dense);
84
- const a11y = [label(row), shown ? `${REASON_LABEL[shown.reason]}${shown.detail ? `: ${shown.detail}` : ""}` : "", right ? `waiting ${right}` : ""].filter(Boolean).join(". ");
88
+ const a11y = [label(row), shown ? `${REASON_LABEL[shown.reason]}${detail ? `: ${detail}` : ""}` : "", right ? `waiting ${right}` : ""].filter(Boolean).join(". ");
85
89
  return <View onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
86
90
  {...({ onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), onFocus: () => setFocused(true), onBlur: () => setFocused(false) } as object)}
87
91
  style={{ flexDirection: "row", alignItems: "center", minHeight: touch ? 56 : dense ? 40 : 48, paddingLeft: dense ? 8 : 12, paddingRight: dense ? 4 : 8, borderRadius: dense ? 6 : 0, backgroundColor: active || moreOpen ? c.surface2 : hover && onOpen ? c.surface1 : "transparent" }}>
@@ -335,8 +339,11 @@ export function contributeInboxPills(client: PluginClientContext, store: InboxSt
335
339
  if (!row.workspaceId) continue;
336
340
  const shown = displayReason(row.needs);
337
341
  if (shown && !row.snooze) {
338
- const status = `Needs you · ${REASON_WORD[shown.reason].toLowerCase()} ${age(row.needs!.since, now)}`;
339
- wanted.set(row.agentId, { row, undo: false, label: `${REASON_WORD[shown.reason]} · ${age(row.needs!.since, now)}`, title: store.data()?.firstRun ? `${status}. Click to sign off. ${RULE}` : `${status}. Click to sign off.` });
342
+ // §4: with an open brief the label comes from its kind and the tooltip carries its ask, so the control never disagrees with the card.
343
+ const brief = row.brief?.state === "open" ? row.brief : null;
344
+ const word = brief && (shown.reason === "finished" || brief.kind === shown.reason) ? BRIEF_WORD[brief.kind] : REASON_WORD[shown.reason];
345
+ const status = brief ? `Needs you · ${briefLine(brief)}` : `Needs you · ${REASON_WORD[shown.reason].toLowerCase()} ${age(row.needs!.since, now)}`;
346
+ wanted.set(row.agentId, { row, undo: false, label: `${word} · ${age(row.needs!.since, now)}`, title: store.data()?.firstRun ? `${status}. Click to sign off. ${RULE}` : `${status}. Click to sign off.` });
340
347
  } else if (recent && recent.agentIds.includes(row.agentId) && now - recent.at < 6_000) {
341
348
  wanted.set(row.agentId, { row, undo: true, label: "Undo sign-off", title: "Signed off. Click to undo." });
342
349
  }
@@ -346,7 +353,7 @@ export function contributeInboxPills(client: PluginClientContext, store: InboxSt
346
353
  if (!next || next.row.workspaceId !== entry.workspaceId) { entry.status.remove(); entry.menu?.remove(); pills.delete(agentId); }
347
354
  }
348
355
  for (const [agentId, next] of wanted) try {
349
- const key = `${next.label}|${next.undo}|${next.row.enable_finish_flags}|${running(next.row)}`;
356
+ const key = `${next.label}|${next.title}|${next.undo}|${next.row.enable_finish_flags}|${running(next.row)}`;
350
357
  const status = { title: next.title, label: next.label, behavior: { kind: "action" as const, onPress() { if (next.undo) void store.undo([agentId]); else void store.signOff([agentId]); } } };
351
358
  const entry = pills.get(agentId);
352
359
  if (!entry) {
@@ -401,7 +408,9 @@ export function useNextRelay(store: InboxStore, params: Record<string, string>,
401
408
  const token = params.next;
402
409
  if (!token || !openAgent || !ready || done.current === token) return;
403
410
  done.current = token;
411
+ // The brief card can't navigate on its own (timeline items get no navigation), so it relays "open this agent" here.
412
+ if (params.open) { openAgent({ agentId: params.open } as InboxRow); return; }
404
413
  const target = nextTarget(store, params.from ?? null);
405
414
  if (target) openAgent(target);
406
- }, [params.next, params.from, ready, openAgent, store]);
415
+ }, [params.next, params.from, params.open, ready, openAgent, store]);
407
416
  }
package/index.client.tsx CHANGED
@@ -11,6 +11,7 @@ import { FollowLarge, LARGE_SCREEN } from "./client/large";
11
11
  import { createInboxStore } from "./client/inbox-store";
12
12
  import { contributeHeaderItem, contributeInboxCommands, contributeInboxPills } from "./client/inbox";
13
13
  import { inboxSidebarView, sidebarViewHost } from "./client/sidebar-view";
14
+ import { contributeBrief } from "./client/brief";
14
15
 
15
16
  const OVERVIEW = "follow-through";
16
17
 
@@ -57,6 +58,11 @@ export default function contribute(host: PluginClientContext) {
57
58
  contributeInboxPills(client, inbox, openNext),
58
59
  contributeInboxCommands(client, inbox, openNext),
59
60
  contributeHeaderItem(host, inbox),
61
+ contributeBrief(client, {
62
+ signOff: agentId => void inbox.signOff([agentId]), openNext,
63
+ openAgent: agentId => client.openScreen({ screenId: OVERVIEW, params: { next: String(++nextToken), open: agentId } }),
64
+ workspace: id => store.get(id), openPanel,
65
+ }),
60
66
  ];
61
67
  // Phase 2: only on hosts with the sidebar-view API (Paseo main after 2026-11-07).
62
68
  const views = sidebarViewHost(host);
package/index.server.ts CHANGED
@@ -29,7 +29,8 @@ import { Inbox, type GuardPause } from "./server/inbox";
29
29
  import { daemonDirectory, guardPath } from "./server/inbox-directory";
30
30
  import { fileSources } from "./server/inbox-classifier";
31
31
  import { onBeforeSend } from "./server/daemon";
32
- import { readFile } from "node:fs/promises";
32
+ import { readFile, rename } from "node:fs/promises";
33
+ import { BRIEF_KIND, BRIEF_VERSION, prState, validateNeedsYou, type Brief, type NeedsYouRecord } from "./shared/brief";
33
34
 
34
35
  export default function contribute(server: PluginServerContext) {
35
36
  const displaySettings = server.registerSettings(displayPreferences);
@@ -48,8 +49,50 @@ export default function contribute(server: PluginServerContext) {
48
49
  const engine = new Engine(store, github, gateway);
49
50
  // Unknown is not off: an unreadable document keeps the inbox on with its defaults.
50
51
  const inboxSettings = async () => { try { const state = await displaySettings.read(); if (state.status === "ready") return state.values; } catch {} return displayPreferences.schema.parse({}); };
52
+ // The brief's timeline rows need a plugin session; the handler contexts carry one. Rows wait until one is seen.
53
+ type PaseoApi = PluginHandlerContext["paseo"];
54
+ let api: PaseoApi | null = null;
55
+ const unsent = new Map<string, { agentId: string; brief: Brief }>();
56
+ const append = async (agentId: string, brief: Brief) => {
57
+ if (!api) { unsent.set(`${agentId}|${brief.id}`, { agentId, brief }); return; }
58
+ await api.agents.ref(agentId).timeline.append({ type: "plugin", id: brief.id, kind: BRIEF_KIND, version: BRIEF_VERSION, data: brief });
59
+ };
60
+ const seen = (paseo: PaseoApi) => {
61
+ api = paseo;
62
+ for (const [key, row] of unsent) { unsent.delete(key); void append(row.agentId, row.brief).catch(() => undefined); }
63
+ };
64
+ const needsYouPath = (agentId: string) => join(directory, "hook-state", `needs-you-${agentId}.json`);
65
+ const trackedMain = async (agentId: string) => (await store.snapshot()).workspaces.find(w => w.enable_tracking && !w.archived && w.mainAgentId === agentId) ?? null;
51
66
  const inbox: Inbox = new Inbox(directory, {
52
67
  enabled: inboxSettings,
68
+ briefs: {
69
+ sink: append,
70
+ async call(agentId) {
71
+ if (!/^[\w-]+$/.test(agentId)) return null;
72
+ try { return JSON.parse(await readFile(needsYouPath(agentId), "utf8")) as NeedsYouRecord; } catch { return null; }
73
+ },
74
+ async pr(agentId, event) {
75
+ const w = (await store.snapshot()).workspaces.find(w => w.enable_tracking && !w.archived && (w.mainAgentId === agentId || w.prs.some(p => p.registeredAgentId === agentId)));
76
+ return prState(w, agentId, event ?? null);
77
+ },
78
+ async workspaceId(agentId) { return (await trackedMain(agentId))?.id ?? null; },
79
+ // Decisions stored as questions follow the brief: a picked option is the developer's answer; a brief the agent
80
+ // moved past closes its still-open questions so the panel doesn't keep stale ones.
81
+ async settled(agentId, brief) {
82
+ if (brief.state !== "answered" && brief.state !== "superseded") return;
83
+ await store.mutate(state => {
84
+ const w = state.workspaces.find(w => w.enable_tracking && w.mainAgentId === agentId);
85
+ if (!w) return;
86
+ const at = brief.outcomeAt ?? new Date().toISOString();
87
+ for (const d of brief.decisions) {
88
+ const q = w.questions.find(q => q.id === d.id && brief.questionIds.includes(q.id));
89
+ if (!q || q.answer || q.closed) continue;
90
+ if (brief.state === "answered" && d.chosen) q.answer = { choice: d.chosen, at, by: "developer", delivery: { state: "sent", to: agentId, messageId: null, at, reason: null } };
91
+ else if (brief.state === "superseded") q.closed = { at, reason: "The agent kept going past this brief.", by: null };
92
+ }
93
+ });
94
+ },
95
+ },
53
96
  directory: daemonDirectory(),
54
97
  sources: fileSources(paseoHome, async (): Promise<{ agentId: string; messageId: string | null; at: string }[]> => (await inbox.store.snapshot()).ledger),
55
98
  guard: async () => {
@@ -58,12 +101,20 @@ export default function contribute(server: PluginServerContext) {
58
101
  },
59
102
  });
60
103
  onBeforeSend((agentId, messageId) => inbox.noteDelivery(agentId, messageId));
104
+ /** What the bridge and the Stop hook read: is the inbox on, may agents be nudged, who has a prompt showing. */
105
+ const writeBriefState = async () => {
106
+ const values = await inboxSettings();
107
+ const path = join(directory, "brief.json");
108
+ const temporary = `${path}.${randomUUID()}`;
109
+ await writeFile(temporary, JSON.stringify({ enable_inbox: values.enable_inbox, enable_brief_nudge: values.enable_brief_nudge, pending: inbox.pendingPermissions(), at: Date.now() }), { mode: 0o600 });
110
+ await rename(temporary, path);
111
+ };
61
112
  let inboxError: string | null = null;
62
113
  let inboxTimer: ReturnType<typeof setTimeout> | null = null;
63
114
  const inboxLoop = async () => {
64
115
  await ready;
65
116
  if (stopped || loadError) return;
66
- try { await inbox.tick(); await inbox.observeGuard(); await inbox.observeFollowUp(await store.snapshot()); inboxError = null; }
117
+ try { await writeBriefState().catch(() => undefined); await inbox.tick(); await inbox.observeGuard(); await inbox.observeFollowUp(await store.snapshot()); inboxError = null; }
67
118
  catch (error) { inboxError = error instanceof Error ? error.message : "The Needs-you inbox couldn't update."; }
68
119
  if (!stopped) inboxTimer = setTimeout(() => void inboxLoop(), 15_000);
69
120
  };
@@ -84,6 +135,7 @@ export default function contribute(server: PluginServerContext) {
84
135
  hooksStarted = true;
85
136
  await agentHooks.start().catch(() => { hookError = "Automatic sidebar instructions could not be registered for every provider. Check Claude/Codex hook settings; tracking is still available."; });
86
137
  hookTimer = setInterval(() => void agentHooks.heartbeat().catch(() => {}), 30_000);
138
+ await writeBriefState().catch(() => undefined);
87
139
  })().catch(() => { loadError = "Follow-through could not start. Check its saved state and worker lock."; });
88
140
  const requireReady = async () => { await ready; if (loadError || stopped) throw new Error(loadError ?? "Follow-through is stopping"); };
89
141
  const loop = async () => {
@@ -339,12 +391,60 @@ export default function contribute(server: PluginServerContext) {
339
391
 
340
392
  server.handle(rpc.inboxListRpc, async () => { await requireReady(); return { ...await inbox.list(), error: inboxError }; });
341
393
  server.handle(rpc.inboxActRpc, async input => { await requireReady(); return inbox.act(input); });
394
+ server.handle(rpc.needsYouRpc, async ({ agentId, ...input }, { paseo }) => {
395
+ await requireReady(); seen(paseo);
396
+ if (!(await inboxSettings()).enable_inbox) throw new Error("The Needs-you inbox is off, so there's no brief to fill. Finish with your normal reply.");
397
+ if (!/^[\w-]+$/.test(agentId)) throw new Error("Unsupported agent identity.");
398
+ const agent = (await paseo.agents.ref(agentId).refresh())?.agent;
399
+ if (!agent?.workspaceId) throw new Error("Cannot locate the caller's workspace.");
400
+ const saved = await store.snapshot();
401
+ const tracked = saved.workspaces.find(w => w.id === agent.workspaceId && w.enable_tracking && !w.archived);
402
+ // F2: root agents only. Contributors and recovery sessions report to the main agent; subagents to their parent.
403
+ if (agent.labels["follow-through.recovery"] || tracked && tracked.mainAgentId !== agentId) throw new Error("needs_you is for the workspace's main agent. Report to the main agent instead.");
404
+ if (!await isWorkspaceRoot(agent, async id => (await paseo.agents.ref(id).refresh())?.agent)) throw new Error("needs_you is for root agents. Report to your parent agent instead.");
405
+ const call = validateNeedsYou(input, ref => {
406
+ const key = ref.trim().replace(/^#/, "");
407
+ const match = tracked?.prs.find(p => p.key.toLowerCase() === key.toLowerCase()) ?? (tracked?.prs.filter(p => String(p.number) === key).length === 1 ? tracked.prs.find(p => String(p.number) === key) : undefined);
408
+ if (!match) throw new Error(`No registered PR matches ${ref}. Register it with register_pr first, or give the link as url.`);
409
+ return match.key;
410
+ });
411
+ const turn = inbox.currentTurn(agentId);
412
+ const previous = await readFile(needsYouPath(agentId), "utf8").then(t => JSON.parse(t) as NeedsYouRecord).catch(() => null);
413
+ const sameTurn = !!previous && (turn?.turnId ? previous.turnId === turn.turnId : !!turn && previous.at >= turn.at);
414
+ let questionIds: string[] = [];
415
+ if (tracked) {
416
+ const mode = await questionMode();
417
+ questionIds = await store.mutate(state => {
418
+ const w = findWorkspace(state, tracked.id);
419
+ const now = Date.now();
420
+ // Called twice in one turn: the last call wins, so the first call's questions close.
421
+ if (sameTurn) for (const q of w.questions) if (previous!.questionIds?.includes(q.id) && !q.answer && !q.closed) q.closed = { at: new Date(now).toISOString(), reason: "Replaced by a newer needs_you call.", by: agentId };
422
+ const ids = (call.decisions ?? []).map(d => askQuestion(w, agentId, { title: d.question.slice(0, 120), question: d.question, options: (d.options ?? []).map((o, i) => ({ letter: String.fromCharCode(65 + i), label: o.label, ...(o.consequence ? { consequence: o.consequence } : {}) })), ...(d.options?.length ? { recommend: String.fromCharCode(65 + d.recommend!) } : {}) }, mode, now).question.id);
423
+ if (ids.length) changed(w);
424
+ return ids;
425
+ });
426
+ }
427
+ const record: NeedsYouRecord = { at: Date.now(), turnId: turn?.turnId ?? null, call, questionIds };
428
+ await mkdir(join(directory, "hook-state"), { recursive: true, mode: 0o700 });
429
+ const temporary = `${needsYouPath(agentId)}.${randomUUID()}`;
430
+ await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
431
+ await rename(temporary, needsYouPath(agentId));
432
+ return { ok: true as const, briefId: `brief:${turn?.turnId ?? record.at}` };
433
+ });
434
+ server.handle(rpc.briefActionRpc, async ({ agentId, briefId, decisionId, choice, move }, { paseo }) => {
435
+ await requireReady(); seen(paseo);
436
+ const brief = await inbox.briefAction(agentId, { briefId, decisionId, choice, move }, async (text, messageId) => {
437
+ await paseo.agents.ref(agentId).send(text, { messageId, activeTurnBehavior: "steer" });
438
+ });
439
+ return { brief };
440
+ });
342
441
 
343
442
  const removers = [
344
- server.on("agent.turn_started", async event => inboxHook(() => inbox.turnStarted(event.agent))),
345
- server.on("agent.turn_ended", async event => inboxHook(() => inbox.turnEnded(event.agent, event.outcome, event.turnId))),
346
- server.on("agent.permission_requested", async event => inboxHook(() => inbox.permissionRequested(event.agent, event.request))),
347
- server.on("agent.permission_resolved", async event => inboxHook(() => inbox.permissionResolved(event.agent, event.requestId))),
443
+ displaySettings.subscribe(() => void writeBriefState().catch(() => undefined)),
444
+ server.on("agent.turn_started", async (event, { paseo }) => { seen(paseo); inboxHook(() => inbox.turnStarted(event.agent, event.turnId)); }),
445
+ server.on("agent.turn_ended", async (event, { paseo }) => { seen(paseo); inboxHook(() => inbox.turnEnded(event.agent, event.outcome, event.turnId)); }),
446
+ server.on("agent.permission_requested", async (event, { paseo }) => { seen(paseo); inboxHook(async () => { await inbox.permissionRequested(event.agent, event.request); await writeBriefState(); }); }),
447
+ server.on("agent.permission_resolved", async (event, { paseo }) => { seen(paseo); inboxHook(async () => { await inbox.permissionResolved(event.agent, event.requestId); await writeBriefState(); }); }),
348
448
  server.on("agent.closed", async event => inboxHook(() => inbox.closed(event.agent))),
349
449
  server.on("agent.archived", async event => inboxHook(() => inbox.archived([event.agent.id]))),
350
450
  server.on("workspace.archived", async event => inboxHook(() => inbox.workspaceArchived(event.workspace.id))),
@@ -368,16 +468,23 @@ export default function contribute(server: PluginServerContext) {
368
468
  return { ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry() } };
369
469
  }
370
470
  }
371
- if (!w && request.workspaceId && !request.labels["follow-through.recovery"] && await isWorkspaceRoot({ id: request.agentId, workspaceId: request.workspaceId, labels: request.labels }, async id => (await paseo.agents.ref(id).refresh())?.agent)) {
471
+ const root = !!request.workspaceId && !request.labels["follow-through.recovery"] && await isWorkspaceRoot({ id: request.agentId, workspaceId: request.workspaceId, labels: request.labels }, async id => (await paseo.agents.ref(id).refresh())?.agent);
472
+ // F2: needs_you and the Stop-hook nudge reach every root agent while the inbox is on, tracked or not.
473
+ const brief = (await inboxSettings()).enable_inbox && ["claude", "codex"].includes(request.provider) ? request.provider : null;
474
+ if (!w && root) {
372
475
  w = await store.mutate(state => {
373
476
  const workspace = state.workspaces.find(w => w.id === request.workspaceId && w.enable_tracking && !w.archived);
374
477
  if (workspace) workspace.contributorAgentIds = [...new Set([...(workspace.contributorAgentIds ?? []), request.agentId])];
375
478
  return workspace;
376
479
  });
377
480
  }
378
- if (!w) return request;
481
+ if (!w) {
482
+ if (!brief || !root) return request;
483
+ return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_BRIEF: brief } }, directory, clientEntry(), paseoHome, false, process.env, brief);
484
+ }
379
485
  const recovery = w.mainAgentId !== request.agentId && !w.contributorAgentIds?.includes(request.agentId);
380
- return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}) } }, directory, clientEntry(), paseoHome, recovery);
486
+ const main = !recovery && w.mainAgentId === request.agentId ? brief : null;
487
+ return withSessionMcp({ ...request, env: { ...request.env, FOLLOW_THROUGH_CLI: bridge, FOLLOW_THROUGH_CLIENT_ENTRY: clientEntry(), ...(recovery ? { FOLLOW_THROUGH_RECOVERY: "1" } : {}), ...(main ? { FOLLOW_THROUGH_BRIEF: main } : {}) } }, directory, clientEntry(), paseoHome, recovery, process.env, main);
381
488
  }),
382
489
  server.on("agent.turn_ended", async (event, { paseo, signal }) => {
383
490
  await ready;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-follow-up",
3
3
  "private": false,
4
- "version": "1.1.0-beta.0",
4
+ "version": "1.2.0-beta.0",
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": [