@popoverinstall/cli 0.9.1 → 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.
- package/CHANGELOG.md +79 -0
- package/README.md +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -1
- package/dist/threads.d.ts +163 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/threads.js +576 -0
- package/dist/threads.js.map +1 -0
- package/package.json +3 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/ask.md +18 -9
- package/plugin/commands/message.md +152 -0
- package/plugin/commands/team.md +9 -7
- package/plugin/commands/tell.md +12 -3
- package/plugin/commands/vault.md +34 -13
- package/plugin/hooks/hooks.json +25 -1
- package/plugin/mcp/index.mjs +323 -7
- package/plugin/scripts/_ipc.mjs +49 -1
- package/plugin/scripts/announce-roster.mjs +5 -4
- package/plugin/scripts/deliver-messages.mjs +7 -5
- package/plugin/scripts/emit-event.mjs +11 -2
- package/plugin/scripts/inject-message.mjs +297 -0
- package/plugin/scripts/nudge-vault.mjs +517 -0
- package/plugin/skills/popover/SKILL.md +159 -70
|
@@ -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
|
+
}
|