@popoverinstall/cli 0.9.0 → 0.10.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.
@@ -11,12 +11,60 @@
11
11
  import net from "node:net";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
- import { appendFileSync, mkdirSync } from "node:fs";
14
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
15
15
 
16
16
  export function popoverHome() {
17
17
  return process.env.POPOVER_HOME ?? path.join(os.homedir(), ".popover");
18
18
  }
19
19
 
20
+ /**
21
+ * Path of the daemon's "something is waiting for this session" flag.
22
+ *
23
+ * **Canonical definition: `inboxFlagPath` in `packages/shared/src/paths.ts`. This is a copy.**
24
+ * Hook scripts stay dependency-free — there is no node_modules beside a marketplace-installed
25
+ * plugin — so the path is duplicated here and the two must change together, the same
26
+ * arrangement `daemonEntryPointerPath` already documents for ensure-daemon.mjs. Read that one
27
+ * for why the file exists; this one only has to agree with it.
28
+ *
29
+ * Two properties of that contract are load-bearing here and are easy to break by accident:
30
+ *
31
+ * - **Nothing reads the bytes.** The file exists or it does not. `pending` is what hands over
32
+ * rendered text, on this machine, from the columns the database filled in — a flag holding
33
+ * the message would be a second copy a hook could print without the framing (docs/messages.md
34
+ * §5). So this stats; it never opens.
35
+ * - **The session id is reduced before it becomes a filename**, identically to paths.ts. Two
36
+ * sessions folding onto one flag costs an unnecessary IPC call; a `..` surviving into the
37
+ * path costs a file somewhere else on disk.
38
+ *
39
+ * Existence is also the capability handshake, without needing a version byte: a daemon old
40
+ * enough to render only tell-shaped framing is a daemon that never writes this file.
41
+ */
42
+ export function inboxFlagPath(sessionId) {
43
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "unknown";
44
+ return path.join(popoverHome(), "inbox", `${safe}.flag`);
45
+ }
46
+
47
+ /**
48
+ * When the flag was last written, or null when there is nothing waiting.
49
+ *
50
+ * The mtime comes free in the same `stat` that answers the existence question, and it is the
51
+ * only clock available once the contract says the contents are not a payload. inject-message.mjs
52
+ * uses it to bound how long it will hold a message back during a run of edits.
53
+ *
54
+ * What it measures is "when the daemon last touched this flag", which is the arrival of the
55
+ * *most recent* waiting item rather than the oldest. Close enough for a patience deadline
56
+ * measured in tens of seconds, and worth knowing before anyone builds something finer on it.
57
+ */
58
+ export function inboxWaitingSince(sessionId) {
59
+ if (!sessionId) return null;
60
+ try {
61
+ return statSync(inboxFlagPath(sessionId)).mtimeMs;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+
20
68
  export function daemonAddress() {
21
69
  if (process.env.POPOVER_DAEMON_ADDR) return process.env.POPOVER_DAEMON_ADDR;
22
70
  if (process.platform === "win32") {
@@ -7,10 +7,11 @@
7
7
  // hook is the cheap one: it is about to edit a file a teammate's agent is already in, and
8
8
  // the roster line is what makes the overlap visible before the edit rather than at merge.
9
9
  //
10
- // Registered synchronously on UserPromptSubmit, because that is the one event whose stdout
11
- // is injected into the model's context. That makes this the second hook here allowed to
12
- // print see the header of deliver-messages.mjs and it inherits every constraint that
13
- // one documents: it runs before every prompt, so it must be fast and must fail open.
10
+ // Registered synchronously on UserPromptSubmit, because that is the event this line wants:
11
+ // its stdout is injected into the model's context ahead of the prompt it is about to answer.
12
+ // That makes this the second hook here allowed to print see the header of
13
+ // deliver-messages.mjs and it inherits every constraint that one documents: it runs before
14
+ // every prompt, so it must be fast and must fail open.
14
15
  //
15
16
  // It announces on change rather than on every prompt. A line repeated ahead of all fifty
16
17
  // prompts in a session stops being information and becomes wallpaper: the model habituates,
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Delivers tells into a live session.
3
3
  //
4
- // One of the two hooks in the plugin that write to stdout on purpose — announce-roster.mjs
5
- // is the other. Every one of the rest is registered `async: true` and forbidden from
6
- // printing, because for UserPromptSubmit stdout is injected into the model's context see
7
- // the header of emit-event.mjs. That is precisely the channel a tell needs, so this hook is
8
- // synchronous and its output is the message.
4
+ // One of the two hooks in the plugin that write to plain stdout on purpose —
5
+ // announce-roster.mjs is the other, and nudge-vault.mjs reaches the model a third way, with
6
+ // JSON `additionalContext` on Stop. Every one of the rest is registered `async: true` and
7
+ // forbidden from printing, because for UserPromptSubmit stdout is injected into the model's
8
+ // context see the header of emit-event.mjs for which events that holds for. That is
9
+ // precisely the channel a tell needs, so this hook is synchronous and its output is the
10
+ // message.
9
11
  //
10
12
  // Which makes it the riskiest script here, and it is written accordingly:
11
13
  //
@@ -6,8 +6,17 @@
6
6
  // PreToolUse, which fires on every single tool call.
7
7
  //
8
8
  // Rules this script must never break:
9
- // - never write to stdout (for UserPromptSubmit and SessionStart, stdout is injected
10
- // into the model's context)
9
+ // - never write to stdout. Plain stdout from a hook is injected into the model's context on
10
+ // UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch — that is the
11
+ // verified full list for *plain stdout*, and this script is registered on one of them,
12
+ // UserPromptSubmit.
13
+ // What that list is not is the set of events that can reach the model at all: JSON
14
+ // `hookSpecificOutput.additionalContext` is delivered on many more, including PreToolUse,
15
+ // PostToolUse, PostToolBatch and Stop. The comment here used to name only the first two
16
+ // events, which read as "nothing can reach the model mid-turn" and shaped this plugin's
17
+ // design around waiting for the next prompt. It is wrong, and nudge-vault.mjs is built on
18
+ // the correction: it answers Stop with `additionalContext` and reaches the model at the
19
+ // end of the turn that earned it.
11
20
  // - never exit non-zero (exit 2 would BLOCK the tool call it is reporting on)
12
21
  // - never hang (hooks run async, but a wedged process still costs a handle)
13
22
 
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ // Hands a waiting message to a session that is in the middle of working.
3
+ //
4
+ // docs/messages.md §5. The routing table in §1 sends a message to the live session when the
5
+ // target is `working`, and this is the only thing that can carry it there: hooks react, they
6
+ // do not initiate, so the message rides on a tool batch the agent was already running. The
7
+ // agent answers, briefly, and goes back to what it was doing.
8
+ //
9
+ // Registered synchronously on `PostToolBatch`, which fires once after a whole batch of
10
+ // parallel tool calls resolves and before the next model request. `PostToolUse` would work and
11
+ // would pay this hook's cost once per *tool* to deliver the same message once. Async would
12
+ // deliver a model call late — measured, and a message that arrives a call late arrives after
13
+ // the decision it was meant to inform.
14
+ //
15
+ // ## This is the second deliberate exception to popover's trust model, and it says so
16
+ //
17
+ // README.md rests popover's safety on one property: content enters an agent's context only as
18
+ // the return value of a tool that agent chose to call. A tell is named there as the single
19
+ // deliberate exception. This is the same exception, not a softer third category — a teammate's
20
+ // words entering a running, fully-tooled session that did not ask for them — and §5 requires
21
+ // it be described that way rather than discovered.
22
+ //
23
+ // So it carries the defences a tell carries, and **it does not build any of them here**:
24
+ //
25
+ // - The text is rendered by the daemon, on this machine, from a template. A sender cannot
26
+ // strip framing they never touched. This script prints what it is handed, exactly as
27
+ // deliver-messages.mjs does — read that file's header for why that constraint is the
28
+ // riskiest one in the plugin to break.
29
+ // - Fencing and `sanitizeField`-grade sanitisation of the quoted body — control characters
30
+ // and newlines stripped, so a body cannot emit a line at all, cannot close the fence and
31
+ // cannot forge a `[popover]` header — live in `packages/shared/src/envelope.ts` beside
32
+ // `renderReceiptRecord`, which already does exactly this for ask receipts.
33
+ // - Which means this hook must never print text a daemon that predates live injection
34
+ // rendered. The flag file's existence is that handshake — a daemon that renders only
35
+ // tell-shaped framing never writes one. See `inboxFlagPath` in _ipc.mjs.
36
+ //
37
+ // ## Cost, which is the whole reason for the flag file
38
+ //
39
+ // This runs inside the agentic loop, after every batch, on every session. An IPC round trip
40
+ // per batch is not affordable and §5 rules it out. So the daemon drops a file when something
41
+ // is waiting and this stats for it: on the overwhelmingly common path the file is absent, the
42
+ // stat fails, and the process exits having done nothing else — no stdin, no socket, and not
43
+ // even an import of _ipc.mjs, which is loaded dynamically below so that node:net stays
44
+ // unloaded on the path that does not need it.
45
+ //
46
+ // The floor is therefore a bare Node start, which nothing here can improve on and which is
47
+ // most of the measured cost.
48
+ //
49
+ // Fail open, always. No daemon, no flag, a malformed payload, a wedged socket: exit 0 in
50
+ // silence. A message arriving one batch later is a small loss; a wedged hook between an
51
+ // agent's tool call and its next thought is not.
52
+
53
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
54
+ import os from "node:os";
55
+ import path from "node:path";
56
+
57
+ // A fork answering a teammate's question must not be handed a third party's message: its
58
+ // output goes back to whoever asked. The daemon's own `handlePending` documents the backstop
59
+ // behind this guard — a fork's session id is never published, so it addresses no inbox — but
60
+ // the guard is what makes it free rather than merely harmless.
61
+ if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
62
+
63
+ /**
64
+ * How long a colleague may be left waiting while the agent is mid-edit.
65
+ *
66
+ * The discipline in §5 is to inject after read-only tools and never inside a multi-edit
67
+ * sequence — interrupting between a read and the write that depends on it is worse than
68
+ * interrupting after the read. Held batches cost nothing, because the flag stays on disk and
69
+ * the next batch tries again.
70
+ *
71
+ * But patience without a deadline is a dropped message: a long edit run is minutes of
72
+ * mutating batches, and the sender is waiting on a reply the whole time. After this, the
73
+ * message goes in on the next batch whatever it contains. Twenty seconds is long enough to
74
+ * clear a normal edit-then-verify sequence and short enough that nobody watching a colleague's
75
+ * agent work would call it lost.
76
+ */
77
+ const PATIENCE_MS = 20_000;
78
+
79
+ /**
80
+ * Tools whose batch is a bad moment to interrupt.
81
+ *
82
+ * File mutations only. `Bash` is deliberately absent, and that is the one judgement call in
83
+ * here: it is the most common *read-only* tool in practice — git status, a test run, a grep —
84
+ * so treating it as mutating would collapse "prefer read-only moments" into "never inject",
85
+ * which is the failure mode §5 is not asking for.
86
+ */
87
+ const MUTATING = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
88
+
89
+ /** One item per batch. The drain below says why this is not deliver-messages.mjs's three. */
90
+ const LIMIT = 1;
91
+
92
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || "";
93
+
94
+ try {
95
+ // The env var rather than the payload, and this is the whole trick: the session id has to be
96
+ // known *before* deciding whether this batch is worth reading stdin for. announce-roster.mjs
97
+ // makes the same trade for the same reason and records it — a hook that spends 150ms on a
98
+ // payload it will discard has already lost. The cost is that a session where Claude Code
99
+ // does not export the id never injects, which is the safe direction to fail in.
100
+ const waitingSince = inboxWaitingSince(sessionId);
101
+ if (waitingSince === null) process.exit(0);
102
+
103
+ // A flag whose drain already came back empty. The daemon deletes the file when the inbox
104
+ // empties, so reaching here means either a daemon that did not, or a race with another
105
+ // drain — deliver-messages.mjs runs on every prompt and takes from the same queue. Either
106
+ // way, one wasted round trip per flag is the budget, not one per batch.
107
+ if (!drainedEmpty(waitingSince)) {
108
+ const payload = await readPayload();
109
+
110
+ if (goodMoment(payload, waitingSince)) {
111
+ // Loaded here and not at the top of the file: this pulls in node:net, and the path that
112
+ // does not need it is the path that runs thousands of times a session.
113
+ const { request } = await import("./_ipc.mjs");
114
+
115
+ const reply = await request(
116
+ {
117
+ t: "pending",
118
+ id: "hook",
119
+ fromSessionId: sessionId,
120
+ limit: LIMIT,
121
+ // Which surface is draining, so the daemon can render for it. A tell delivered at a
122
+ // prompt boundary and a message injected mid-turn need different framing — §5 wants
123
+ // the fence and the newline-stripped body for the second — and the daemon cannot
124
+ // tell the two callers apart otherwise. Unknown fields are dropped by the zod arm in
125
+ // packages/shared/src/ipc.ts today, so this is inert until that arm accepts it, and
126
+ // inert is correct: a daemon not knowing the field is also one whose flag file
127
+ // never appears.
128
+ surface: "injection",
129
+ },
130
+ // Local socket, answered from memory by design — `handlePending` is documented as
131
+ // never touching the network. 400ms is a ceiling on a degraded daemon, not a target.
132
+ { timeoutMs: 400 },
133
+ );
134
+
135
+ if (reply?.t === "pending.ok" && reply.rendered) emit(reply.rendered);
136
+ // An empty drain is not an error: something else got there first. Remember it so the
137
+ // next batch does not ask again.
138
+ else rememberEmpty(waitingSince);
139
+ }
140
+ }
141
+ } catch {
142
+ // Fail open, always.
143
+ }
144
+
145
+ process.exit(0);
146
+
147
+ /**
148
+ * The one line of stdout this script may write.
149
+ *
150
+ * `hookEventName` must name the event being answered. The 10,000-character cap on
151
+ * `additionalContext` is bounded by the daemon's render and by LIMIT above, and is not
152
+ * enforced here on purpose: by the time this text exists the inbox has been drained and
153
+ * marked delivered, so refusing to print it would lose the message outright. Over the cap,
154
+ * Claude Code writes a file and passes a path — a documented degradation, and strictly
155
+ * better than a silent drop. If this ever fires the render is at fault, and that is where
156
+ * to fix it.
157
+ */
158
+ function emit(additionalContext) {
159
+ process.stdout.write(
160
+ `${JSON.stringify({
161
+ hookSpecificOutput: { hookEventName: "PostToolBatch", additionalContext },
162
+ })}\n`,
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Is this batch a decent moment to interrupt?
168
+ *
169
+ * Honest about its own limits. The `PostToolBatch` payload's representation of the tools in
170
+ * the batch is **not documented** — `session_id`, `cwd`, `transcript_path` and
171
+ * `hook_event_name` are, the batch is not — so `toolNames` guesses at spellings, and a batch
172
+ * it cannot read is delivered into rather than held. That direction is deliberate: the
173
+ * alternative is a message that is never delivered live at all on a payload shape nobody here
174
+ * has seen, which would quietly defeat §1's routing while looking implemented.
175
+ *
176
+ * The consequence to be honest about: until the payload shape is confirmed, the multi-edit
177
+ * exclusion may be doing nothing. It is real code against a guessed key, not a verified rule.
178
+ */
179
+ function goodMoment(payload, waitingSince) {
180
+ if (Date.now() - waitingSince > PATIENCE_MS) return true;
181
+ const names = toolNames(payload);
182
+ if (names.length === 0) return true;
183
+ return !names.some((name) => MUTATING.has(name));
184
+ }
185
+
186
+ /**
187
+ * Every tool name this payload might be carrying.
188
+ *
189
+ * One array of candidate keys, in one place, so that confirming the real shape is a one-line
190
+ * edit rather than an archaeology exercise. `tool_name` is the documented single-tool spelling
191
+ * from PostToolUse and is read first because a batch of one may well look like one.
192
+ */
193
+ function toolNames(payload) {
194
+ const names = [];
195
+ const add = (value) => {
196
+ if (typeof value === "string" && value) names.push(value);
197
+ };
198
+
199
+ add(payload?.tool_name);
200
+ for (const key of ["tool_uses", "tools", "tool_calls", "tool_results", "batch"]) {
201
+ const items = payload?.[key];
202
+ if (!Array.isArray(items)) continue;
203
+ for (const item of items) {
204
+ if (typeof item === "string") add(item);
205
+ else if (item && typeof item === "object") {
206
+ add(item.tool_name);
207
+ add(item.name);
208
+ add(item.tool);
209
+ }
210
+ }
211
+ }
212
+ return names;
213
+ }
214
+
215
+ /**
216
+ * Read the hook payload, but never wait long for it.
217
+ *
218
+ * Duplicated from deliver-messages.mjs rather than imported, because importing it would mean
219
+ * loading _ipc.mjs on a path that has already decided not to talk to the daemon. That file's
220
+ * own header says fifteen duplicated lines beat an import a hook can trip over, and this is
221
+ * the same trade one level down. 200ms because the payload is already written by the time the
222
+ * process starts; if it is not there, the batch is unclassifiable and goodMoment says so.
223
+ */
224
+ function readPayload() {
225
+ return new Promise((resolve) => {
226
+ let data = "";
227
+ const done = () => {
228
+ try {
229
+ resolve(JSON.parse(data));
230
+ } catch {
231
+ resolve({});
232
+ }
233
+ };
234
+ const timer = setTimeout(done, 200);
235
+ process.stdin.setEncoding("utf8");
236
+ process.stdin.on("data", (chunk) => {
237
+ data += chunk;
238
+ });
239
+ process.stdin.on("end", () => {
240
+ clearTimeout(timer);
241
+ done();
242
+ });
243
+ process.stdin.on("error", () => {
244
+ clearTimeout(timer);
245
+ done();
246
+ });
247
+ });
248
+ }
249
+
250
+ /*
251
+ * The flag file, and the marker beside it.
252
+ *
253
+ * `inboxFlagPath` is duplicated from _ipc.mjs — which is itself a copy of the canonical one in
254
+ * `packages/shared/src/paths.ts` — for the single reason this file exists to serve: importing
255
+ * _ipc.mjs is what the fast path is avoiding. Three copies is one too many, so the ranking is
256
+ * written down rather than guessed at: paths.ts is right, _ipc.mjs agrees with it, and this
257
+ * agrees with _ipc.mjs.
258
+ *
259
+ * The contract's own rule is what keeps the duplication cheap — nothing reads the bytes, so
260
+ * there is no format here to drift, only a path and a `stat`.
261
+ */
262
+ function popoverHome() {
263
+ return process.env.POPOVER_HOME ?? path.join(os.homedir(), ".popover");
264
+ }
265
+
266
+ function inboxWaitingSince(id) {
267
+ if (!id) return null;
268
+ try {
269
+ const safe = String(id).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 128) || "unknown";
270
+ return statSync(path.join(popoverHome(), "inbox", `${safe}.flag`)).mtimeMs;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /** Where this hook remembers a flag it already drained to nothing. */
277
+ function markerPath() {
278
+ return path.join(popoverHome(), "announced", `${sessionId}.inject.json`);
279
+ }
280
+
281
+ function drainedEmpty(waitingSince) {
282
+ try {
283
+ return JSON.parse(readFileSync(markerPath(), "utf8"))?.emptyAt === waitingSince;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ function rememberEmpty(waitingSince) {
290
+ try {
291
+ mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
292
+ writeFileSync(markerPath(), JSON.stringify({ v: 1, emptyAt: waitingSince }), "utf8");
293
+ } catch {
294
+ // Unwritable means one wasted round trip per batch while a stale flag sits there. It is a
295
+ // cost, not a break, and it stops the moment the daemon removes the file.
296
+ }
297
+ }