@popoverinstall/cli 0.8.1 → 0.9.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 +147 -78
- package/LICENSE +21 -21
- package/README.md +142 -141
- package/dist/config-command.d.ts +2 -0
- package/dist/config-command.d.ts.map +1 -0
- package/dist/config-command.js +80 -0
- package/dist/config-command.js.map +1 -0
- package/dist/cursor-hooks.d.ts +18 -0
- package/dist/cursor-hooks.d.ts.map +1 -0
- package/dist/cursor-hooks.js +105 -0
- package/dist/cursor-hooks.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -25
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +44 -0
- package/dist/keys.js.map +1 -0
- package/dist/repo-scan.d.ts +130 -0
- package/dist/repo-scan.d.ts.map +1 -0
- package/dist/repo-scan.js +281 -0
- package/dist/repo-scan.js.map +1 -0
- package/dist/repos.d.ts +180 -0
- package/dist/repos.d.ts.map +1 -0
- package/dist/repos.js +1002 -0
- package/dist/repos.js.map +1 -0
- package/dist/snapshot.d.ts +35 -0
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +16 -16
- package/dist/snapshot.js.map +1 -1
- package/dist/vaults.d.ts +276 -0
- package/dist/vaults.d.ts.map +1 -0
- package/dist/vaults.js +1224 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +47 -47
- package/plugin/.claude-plugin/plugin.json +19 -19
- package/plugin/.mcp.json +9 -9
- package/plugin/README.md +84 -77
- package/plugin/commands/ask.md +65 -65
- package/plugin/commands/fork.md +119 -119
- package/plugin/commands/repos.md +107 -0
- package/plugin/commands/team.md +60 -60
- package/plugin/commands/tell.md +66 -66
- package/plugin/commands/vault.md +173 -0
- package/plugin/hooks/hooks.json +111 -111
- package/plugin/mcp/index.mjs +585 -355
- package/plugin/scripts/_ipc.mjs +146 -146
- package/plugin/scripts/announce-roster.mjs +141 -141
- package/plugin/scripts/deliver-messages.mjs +77 -77
- package/plugin/scripts/emit-event.mjs +44 -44
- package/plugin/scripts/ensure-daemon.mjs +156 -156
- package/plugin/scripts/roster.mjs +52 -52
- package/plugin/skills/popover/SKILL.md +175 -168
package/plugin/scripts/_ipc.mjs
CHANGED
|
@@ -1,146 +1,146 @@
|
|
|
1
|
-
// Zero-dependency IPC helpers for the plugin's hook scripts.
|
|
2
|
-
//
|
|
3
|
-
// These deliberately duplicate a small amount of logic from @popoverinstall/shared. The plugin is
|
|
4
|
-
// installed standalone from a marketplace, where there is no node_modules to import
|
|
5
|
-
// from, and a hook that fails to resolve an import would break every tool call in the
|
|
6
|
-
// session. Fifteen duplicated lines is the cheaper trade.
|
|
7
|
-
//
|
|
8
|
-
// Keep this file dependency-free and fast: a cold node spawn already costs ~50ms, and
|
|
9
|
-
// PreToolUse runs on every tool call.
|
|
10
|
-
|
|
11
|
-
import net from "node:net";
|
|
12
|
-
import os from "node:os";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
import { appendFileSync, mkdirSync } from "node:fs";
|
|
15
|
-
|
|
16
|
-
export function popoverHome() {
|
|
17
|
-
return process.env.POPOVER_HOME ?? path.join(os.homedir(), ".popover");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function daemonAddress() {
|
|
21
|
-
if (process.env.POPOVER_DAEMON_ADDR) return process.env.POPOVER_DAEMON_ADDR;
|
|
22
|
-
if (process.platform === "win32") {
|
|
23
|
-
const user = process.env.USERNAME || process.env.USER || "default";
|
|
24
|
-
return `\\\\.\\pipe\\popover-${user.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
25
|
-
}
|
|
26
|
-
return path.join(popoverHome(), "daemon.sock");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Send one message and do not wait for a reply.
|
|
31
|
-
*
|
|
32
|
-
* Used by the status hooks. Any failure falls back to the spool file, which the daemon
|
|
33
|
-
* drains the next time it starts — so events survive the daemon being down.
|
|
34
|
-
*/
|
|
35
|
-
export function sendFireAndForget(message, { timeoutMs = 400 } = {}) {
|
|
36
|
-
return new Promise((resolve) => {
|
|
37
|
-
let done = false;
|
|
38
|
-
const finish = (ok) => {
|
|
39
|
-
if (done) return;
|
|
40
|
-
done = true;
|
|
41
|
-
clearTimeout(timer);
|
|
42
|
-
try {
|
|
43
|
-
socket.destroy();
|
|
44
|
-
} catch {}
|
|
45
|
-
if (!ok) spool(message);
|
|
46
|
-
resolve(ok);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const socket = net.createConnection(daemonAddress());
|
|
50
|
-
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
51
|
-
|
|
52
|
-
socket.on("connect", () => {
|
|
53
|
-
socket.write(`${JSON.stringify(message)}\n`, () => finish(true));
|
|
54
|
-
});
|
|
55
|
-
socket.on("error", () => finish(false));
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Send a request and wait for the matching reply. */
|
|
60
|
-
export function request(message, { timeoutMs = 5000 } = {}) {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
let buffer = "";
|
|
63
|
-
let done = false;
|
|
64
|
-
const finish = (value) => {
|
|
65
|
-
if (done) return;
|
|
66
|
-
done = true;
|
|
67
|
-
clearTimeout(timer);
|
|
68
|
-
try {
|
|
69
|
-
socket.destroy();
|
|
70
|
-
} catch {}
|
|
71
|
-
resolve(value);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const socket = net.createConnection(daemonAddress());
|
|
75
|
-
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
76
|
-
|
|
77
|
-
socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
|
|
78
|
-
socket.on("data", (chunk) => {
|
|
79
|
-
buffer += chunk.toString();
|
|
80
|
-
let idx = buffer.indexOf("\n");
|
|
81
|
-
while (idx !== -1) {
|
|
82
|
-
const line = buffer.slice(0, idx).trim();
|
|
83
|
-
buffer = buffer.slice(idx + 1);
|
|
84
|
-
if (line) {
|
|
85
|
-
try {
|
|
86
|
-
return finish(JSON.parse(line));
|
|
87
|
-
} catch {
|
|
88
|
-
/* keep reading */
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
idx = buffer.indexOf("\n");
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
socket.on("error", () => finish(null));
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function spool(message) {
|
|
99
|
-
try {
|
|
100
|
-
mkdirSync(popoverHome(), { recursive: true });
|
|
101
|
-
// Only status payloads are worth replaying; a stale roster request is meaningless.
|
|
102
|
-
if (message?.t !== "status") return;
|
|
103
|
-
appendFileSync(
|
|
104
|
-
path.join(popoverHome(), "spool.jsonl"),
|
|
105
|
-
`${JSON.stringify(message.payload)}\n`,
|
|
106
|
-
"utf8",
|
|
107
|
-
);
|
|
108
|
-
} catch {
|
|
109
|
-
/* never let a hook fail because of logging */
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Read this hook's JSON payload from stdin. */
|
|
114
|
-
export async function readStdin(timeoutMs = 2000) {
|
|
115
|
-
return new Promise((resolve) => {
|
|
116
|
-
let data = "";
|
|
117
|
-
const timer = setTimeout(() => resolve(data), timeoutMs);
|
|
118
|
-
process.stdin.setEncoding("utf8");
|
|
119
|
-
process.stdin.on("data", (chunk) => {
|
|
120
|
-
data += chunk;
|
|
121
|
-
});
|
|
122
|
-
process.stdin.on("end", () => {
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
resolve(data);
|
|
125
|
-
});
|
|
126
|
-
process.stdin.on("error", () => {
|
|
127
|
-
clearTimeout(timer);
|
|
128
|
-
resolve(data);
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* The Claude session this process belongs to, or undefined outside one.
|
|
135
|
-
*
|
|
136
|
-
* Claude Code exports `CLAUDE_CODE_SESSION_ID`. This was previously read as
|
|
137
|
-
* `CLAUDE_SESSION_ID`, which does not exist — so every ask recorded a null
|
|
138
|
-
* `from_session_id` and the dashboard could never say which agent had asked. Verified
|
|
139
|
-
* against a live session: the value is the same id the daemon publishes as
|
|
140
|
-
* `cc_session_id`, so it joins directly to a roster entry.
|
|
141
|
-
*
|
|
142
|
-
* The older name is still consulted second, in case a future or older Claude Code uses it.
|
|
143
|
-
*/
|
|
144
|
-
export function callerSessionId() {
|
|
145
|
-
return process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || undefined;
|
|
146
|
-
}
|
|
1
|
+
// Zero-dependency IPC helpers for the plugin's hook scripts.
|
|
2
|
+
//
|
|
3
|
+
// These deliberately duplicate a small amount of logic from @popoverinstall/shared. The plugin is
|
|
4
|
+
// installed standalone from a marketplace, where there is no node_modules to import
|
|
5
|
+
// from, and a hook that fails to resolve an import would break every tool call in the
|
|
6
|
+
// session. Fifteen duplicated lines is the cheaper trade.
|
|
7
|
+
//
|
|
8
|
+
// Keep this file dependency-free and fast: a cold node spawn already costs ~50ms, and
|
|
9
|
+
// PreToolUse runs on every tool call.
|
|
10
|
+
|
|
11
|
+
import net from "node:net";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
export function popoverHome() {
|
|
17
|
+
return process.env.POPOVER_HOME ?? path.join(os.homedir(), ".popover");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function daemonAddress() {
|
|
21
|
+
if (process.env.POPOVER_DAEMON_ADDR) return process.env.POPOVER_DAEMON_ADDR;
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
const user = process.env.USERNAME || process.env.USER || "default";
|
|
24
|
+
return `\\\\.\\pipe\\popover-${user.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
|
25
|
+
}
|
|
26
|
+
return path.join(popoverHome(), "daemon.sock");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send one message and do not wait for a reply.
|
|
31
|
+
*
|
|
32
|
+
* Used by the status hooks. Any failure falls back to the spool file, which the daemon
|
|
33
|
+
* drains the next time it starts — so events survive the daemon being down.
|
|
34
|
+
*/
|
|
35
|
+
export function sendFireAndForget(message, { timeoutMs = 400 } = {}) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let done = false;
|
|
38
|
+
const finish = (ok) => {
|
|
39
|
+
if (done) return;
|
|
40
|
+
done = true;
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
try {
|
|
43
|
+
socket.destroy();
|
|
44
|
+
} catch {}
|
|
45
|
+
if (!ok) spool(message);
|
|
46
|
+
resolve(ok);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const socket = net.createConnection(daemonAddress());
|
|
50
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
51
|
+
|
|
52
|
+
socket.on("connect", () => {
|
|
53
|
+
socket.write(`${JSON.stringify(message)}\n`, () => finish(true));
|
|
54
|
+
});
|
|
55
|
+
socket.on("error", () => finish(false));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Send a request and wait for the matching reply. */
|
|
60
|
+
export function request(message, { timeoutMs = 5000 } = {}) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let buffer = "";
|
|
63
|
+
let done = false;
|
|
64
|
+
const finish = (value) => {
|
|
65
|
+
if (done) return;
|
|
66
|
+
done = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
try {
|
|
69
|
+
socket.destroy();
|
|
70
|
+
} catch {}
|
|
71
|
+
resolve(value);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const socket = net.createConnection(daemonAddress());
|
|
75
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
76
|
+
|
|
77
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
|
|
78
|
+
socket.on("data", (chunk) => {
|
|
79
|
+
buffer += chunk.toString();
|
|
80
|
+
let idx = buffer.indexOf("\n");
|
|
81
|
+
while (idx !== -1) {
|
|
82
|
+
const line = buffer.slice(0, idx).trim();
|
|
83
|
+
buffer = buffer.slice(idx + 1);
|
|
84
|
+
if (line) {
|
|
85
|
+
try {
|
|
86
|
+
return finish(JSON.parse(line));
|
|
87
|
+
} catch {
|
|
88
|
+
/* keep reading */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
idx = buffer.indexOf("\n");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
socket.on("error", () => finish(null));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spool(message) {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(popoverHome(), { recursive: true });
|
|
101
|
+
// Only status payloads are worth replaying; a stale roster request is meaningless.
|
|
102
|
+
if (message?.t !== "status") return;
|
|
103
|
+
appendFileSync(
|
|
104
|
+
path.join(popoverHome(), "spool.jsonl"),
|
|
105
|
+
`${JSON.stringify(message.payload)}\n`,
|
|
106
|
+
"utf8",
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
/* never let a hook fail because of logging */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read this hook's JSON payload from stdin. */
|
|
114
|
+
export async function readStdin(timeoutMs = 2000) {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
let data = "";
|
|
117
|
+
const timer = setTimeout(() => resolve(data), timeoutMs);
|
|
118
|
+
process.stdin.setEncoding("utf8");
|
|
119
|
+
process.stdin.on("data", (chunk) => {
|
|
120
|
+
data += chunk;
|
|
121
|
+
});
|
|
122
|
+
process.stdin.on("end", () => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
resolve(data);
|
|
125
|
+
});
|
|
126
|
+
process.stdin.on("error", () => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
resolve(data);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The Claude session this process belongs to, or undefined outside one.
|
|
135
|
+
*
|
|
136
|
+
* Claude Code exports `CLAUDE_CODE_SESSION_ID`. This was previously read as
|
|
137
|
+
* `CLAUDE_SESSION_ID`, which does not exist — so every ask recorded a null
|
|
138
|
+
* `from_session_id` and the dashboard could never say which agent had asked. Verified
|
|
139
|
+
* against a live session: the value is the same id the daemon publishes as
|
|
140
|
+
* `cc_session_id`, so it joins directly to a roster entry.
|
|
141
|
+
*
|
|
142
|
+
* The older name is still consulted second, in case a future or older Claude Code uses it.
|
|
143
|
+
*/
|
|
144
|
+
export function callerSessionId() {
|
|
145
|
+
return process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID || undefined;
|
|
146
|
+
}
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Announces which teammate agents are here, once per change in the cast.
|
|
3
|
-
//
|
|
4
|
-
// The skill file tells an agent that popover exists. This tells it who is present *right
|
|
5
|
-
// now* — the part doctrine cannot reliably produce. An agent acts on a fact sitting in its
|
|
6
|
-
// context long before it remembers to go looking for one, and the case that pays for this
|
|
7
|
-
// hook is the cheap one: it is about to edit a file a teammate's agent is already in, and
|
|
8
|
-
// the roster line is what makes the overlap visible before the edit rather than at merge.
|
|
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.
|
|
14
|
-
//
|
|
15
|
-
// It announces on change rather than on every prompt. A line repeated ahead of all fifty
|
|
16
|
-
// prompts in a session stops being information and becomes wallpaper: the model habituates,
|
|
17
|
-
// the user pays for it every turn, and the one turn where it mattered looks like the other
|
|
18
|
-
// forty-nine. So the cast last announced is remembered on disk and nothing is printed until
|
|
19
|
-
// it differs.
|
|
20
|
-
|
|
21
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import { callerSessionId, popoverHome, request } from "./_ipc.mjs";
|
|
24
|
-
|
|
25
|
-
// A fork answering a teammate's question must not be handed this. Its output goes back to
|
|
26
|
-
// whoever asked, and the cast of the machine it happens to run on is not theirs.
|
|
27
|
-
if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
|
|
28
|
-
|
|
29
|
-
const sessionId = callerSessionId();
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
// No stdin read at all. deliver-messages.mjs spends 150ms on the payload because a missed
|
|
33
|
-
// tell is a real loss; here the environment variable is enough, and 150ms of latency the
|
|
34
|
-
// user pays on every prompt buys nothing this cannot get for free.
|
|
35
|
-
if (!sessionId) process.exit(0);
|
|
36
|
-
|
|
37
|
-
// `refresh: false` means "do not force a refresh". It does NOT mean "do not use the
|
|
38
|
-
// network", which is what a comment here used to claim: the daemon caches a roster for 5s,
|
|
39
|
-
// and past that this call awaits a cloud fetch measuring ~355ms. Since a human types
|
|
40
|
-
// prompts minutes apart, nearly every one of these went to the cloud — so the 250ms budget
|
|
41
|
-
// this used to carry lost the race almost every time, failed open, and printed nothing. A
|
|
42
|
-
// hook that silently does not fire is indistinguishable from having no teammates, which is
|
|
43
|
-
// why it went unnoticed for a release.
|
|
44
|
-
//
|
|
45
|
-
// 2000ms is a ceiling on how long a *degraded* backend may hold up a prompt, not a target:
|
|
46
|
-
// the happy path is 2ms cached or ~355ms fetched. It must stay in step with
|
|
47
|
-
// ROSTER_HOOK_TIMEOUT_MS in @popoverinstall/shared, which this dependency-free script
|
|
48
|
-
// cannot import — packages/shared/test/roster-cache.test.ts fails if they drift.
|
|
49
|
-
//
|
|
50
|
-
// `cwd` matters most here of anywhere: this fires on the first prompt of a session, which
|
|
51
|
-
// is exactly when the daemon may not yet have resolved that session's repo — and the line
|
|
52
|
-
// it prints claims the agents are "in this repo". Without the hint an unplaceable caller
|
|
53
|
-
// is scoped by recency across the whole machine, so the claim could be false.
|
|
54
|
-
const reply = await request(
|
|
55
|
-
{ t: "roster", id: "hook", refresh: false, fromSessionId: sessionId, cwd: process.cwd() },
|
|
56
|
-
{ timeoutMs: 2000 },
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
// Daemon down, or signed out. Silence is right: the skill tells an agent how to read a
|
|
60
|
-
// daemon-down error when it actually reaches for a tool, and guessing here would put a
|
|
61
|
-
// wrong explanation in front of the model instead.
|
|
62
|
-
if (reply?.t !== "roster.ok") process.exit(0);
|
|
63
|
-
|
|
64
|
-
const peers = reply.entries.filter(
|
|
65
|
-
(e) => !e.isSelf && e.reachable && e.status !== "offline",
|
|
66
|
-
);
|
|
67
|
-
if (peers.length === 0) process.exit(0); // Working alone, which is most of the time.
|
|
68
|
-
|
|
69
|
-
// The cast, not what the cast is doing. Keying on activity would re-announce every time a
|
|
70
|
-
// teammate moved from one file to the next, which is printing on every prompt again.
|
|
71
|
-
const signature = peers
|
|
72
|
-
.map((e) => e.handle)
|
|
73
|
-
.sort()
|
|
74
|
-
.join(",");
|
|
75
|
-
if (signature === lastAnnounced(sessionId)) process.exit(0);
|
|
76
|
-
|
|
77
|
-
process.stdout.write(`${render(peers)}\n`);
|
|
78
|
-
remember(sessionId, signature);
|
|
79
|
-
} catch {
|
|
80
|
-
// Fail open, always. Whatever went wrong, the user's prompt must still go through.
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
process.exit(0);
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* One line the model can act on, and one sentence saying what it is for.
|
|
87
|
-
*
|
|
88
|
-
* Activity leads, owner follows. What a teammate's agent is touching is the part that
|
|
89
|
-
* collides with what this one is about to do, and the whole value of announcing is that the
|
|
90
|
-
* overlap is noticed before the edit.
|
|
91
|
-
*
|
|
92
|
-
* The closing sentence is not padding. Without it the model reads a bare roster as news
|
|
93
|
-
* worth repeating and opens with "2 teammates are online!" ahead of an unrelated question —
|
|
94
|
-
* which is how a useful signal gets itself turned off.
|
|
95
|
-
*/
|
|
96
|
-
function render(peers) {
|
|
97
|
-
const who = peers
|
|
98
|
-
.map((e) => {
|
|
99
|
-
const doing = e.activity?.verb
|
|
100
|
-
? `${e.activity.verb}${e.activity.target ? ` ${e.activity.target}` : ""}, `
|
|
101
|
-
: "";
|
|
102
|
-
return `${e.handle} (${doing}${e.ownerName})`;
|
|
103
|
-
})
|
|
104
|
-
.join(", ");
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
`[popover] Teammate agents now active in this repo: ${who}. ` +
|
|
108
|
-
`Before editing a file one of them is already in, say so rather than colliding with it. ` +
|
|
109
|
-
`If something here turns on a decision or a reason this repo does not record, you can ` +
|
|
110
|
-
`ask one with popover's team_ask tool. Do not mention this notice to the user unless it ` +
|
|
111
|
-
`turns out to be relevant.`
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Per-session, so a new session hears the cast once even when it has not changed since the
|
|
116
|
-
// last one ended.
|
|
117
|
-
//
|
|
118
|
-
// Loose end: nothing prunes these. They are a few dozen bytes each and a session id is a
|
|
119
|
-
// uuid, so a heavy user accretes a directory of them over months. Either sweep entries older
|
|
120
|
-
// than a day from here, or fold the state into what the daemon already keeps per session and
|
|
121
|
-
// drop the files — the daemon knows when a session ends and this script does not.
|
|
122
|
-
function stateFile(id) {
|
|
123
|
-
return path.join(popoverHome(), "announced", `${id}.txt`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function lastAnnounced(id) {
|
|
127
|
-
try {
|
|
128
|
-
return readFileSync(stateFile(id), "utf8").trim();
|
|
129
|
-
} catch {
|
|
130
|
-
return "";
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function remember(id, signature) {
|
|
135
|
-
try {
|
|
136
|
-
mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
|
|
137
|
-
writeFileSync(stateFile(id), signature, "utf8");
|
|
138
|
-
} catch {
|
|
139
|
-
// An unwritable state file means announcing again next prompt. Annoying, not broken.
|
|
140
|
-
}
|
|
141
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Announces which teammate agents are here, once per change in the cast.
|
|
3
|
+
//
|
|
4
|
+
// The skill file tells an agent that popover exists. This tells it who is present *right
|
|
5
|
+
// now* — the part doctrine cannot reliably produce. An agent acts on a fact sitting in its
|
|
6
|
+
// context long before it remembers to go looking for one, and the case that pays for this
|
|
7
|
+
// hook is the cheap one: it is about to edit a file a teammate's agent is already in, and
|
|
8
|
+
// the roster line is what makes the overlap visible before the edit rather than at merge.
|
|
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.
|
|
14
|
+
//
|
|
15
|
+
// It announces on change rather than on every prompt. A line repeated ahead of all fifty
|
|
16
|
+
// prompts in a session stops being information and becomes wallpaper: the model habituates,
|
|
17
|
+
// the user pays for it every turn, and the one turn where it mattered looks like the other
|
|
18
|
+
// forty-nine. So the cast last announced is remembered on disk and nothing is printed until
|
|
19
|
+
// it differs.
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { callerSessionId, popoverHome, request } from "./_ipc.mjs";
|
|
24
|
+
|
|
25
|
+
// A fork answering a teammate's question must not be handed this. Its output goes back to
|
|
26
|
+
// whoever asked, and the cast of the machine it happens to run on is not theirs.
|
|
27
|
+
if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
|
|
28
|
+
|
|
29
|
+
const sessionId = callerSessionId();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// No stdin read at all. deliver-messages.mjs spends 150ms on the payload because a missed
|
|
33
|
+
// tell is a real loss; here the environment variable is enough, and 150ms of latency the
|
|
34
|
+
// user pays on every prompt buys nothing this cannot get for free.
|
|
35
|
+
if (!sessionId) process.exit(0);
|
|
36
|
+
|
|
37
|
+
// `refresh: false` means "do not force a refresh". It does NOT mean "do not use the
|
|
38
|
+
// network", which is what a comment here used to claim: the daemon caches a roster for 5s,
|
|
39
|
+
// and past that this call awaits a cloud fetch measuring ~355ms. Since a human types
|
|
40
|
+
// prompts minutes apart, nearly every one of these went to the cloud — so the 250ms budget
|
|
41
|
+
// this used to carry lost the race almost every time, failed open, and printed nothing. A
|
|
42
|
+
// hook that silently does not fire is indistinguishable from having no teammates, which is
|
|
43
|
+
// why it went unnoticed for a release.
|
|
44
|
+
//
|
|
45
|
+
// 2000ms is a ceiling on how long a *degraded* backend may hold up a prompt, not a target:
|
|
46
|
+
// the happy path is 2ms cached or ~355ms fetched. It must stay in step with
|
|
47
|
+
// ROSTER_HOOK_TIMEOUT_MS in @popoverinstall/shared, which this dependency-free script
|
|
48
|
+
// cannot import — packages/shared/test/roster-cache.test.ts fails if they drift.
|
|
49
|
+
//
|
|
50
|
+
// `cwd` matters most here of anywhere: this fires on the first prompt of a session, which
|
|
51
|
+
// is exactly when the daemon may not yet have resolved that session's repo — and the line
|
|
52
|
+
// it prints claims the agents are "in this repo". Without the hint an unplaceable caller
|
|
53
|
+
// is scoped by recency across the whole machine, so the claim could be false.
|
|
54
|
+
const reply = await request(
|
|
55
|
+
{ t: "roster", id: "hook", refresh: false, fromSessionId: sessionId, cwd: process.cwd() },
|
|
56
|
+
{ timeoutMs: 2000 },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Daemon down, or signed out. Silence is right: the skill tells an agent how to read a
|
|
60
|
+
// daemon-down error when it actually reaches for a tool, and guessing here would put a
|
|
61
|
+
// wrong explanation in front of the model instead.
|
|
62
|
+
if (reply?.t !== "roster.ok") process.exit(0);
|
|
63
|
+
|
|
64
|
+
const peers = reply.entries.filter(
|
|
65
|
+
(e) => !e.isSelf && e.reachable && e.status !== "offline",
|
|
66
|
+
);
|
|
67
|
+
if (peers.length === 0) process.exit(0); // Working alone, which is most of the time.
|
|
68
|
+
|
|
69
|
+
// The cast, not what the cast is doing. Keying on activity would re-announce every time a
|
|
70
|
+
// teammate moved from one file to the next, which is printing on every prompt again.
|
|
71
|
+
const signature = peers
|
|
72
|
+
.map((e) => e.handle)
|
|
73
|
+
.sort()
|
|
74
|
+
.join(",");
|
|
75
|
+
if (signature === lastAnnounced(sessionId)) process.exit(0);
|
|
76
|
+
|
|
77
|
+
process.stdout.write(`${render(peers)}\n`);
|
|
78
|
+
remember(sessionId, signature);
|
|
79
|
+
} catch {
|
|
80
|
+
// Fail open, always. Whatever went wrong, the user's prompt must still go through.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.exit(0);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One line the model can act on, and one sentence saying what it is for.
|
|
87
|
+
*
|
|
88
|
+
* Activity leads, owner follows. What a teammate's agent is touching is the part that
|
|
89
|
+
* collides with what this one is about to do, and the whole value of announcing is that the
|
|
90
|
+
* overlap is noticed before the edit.
|
|
91
|
+
*
|
|
92
|
+
* The closing sentence is not padding. Without it the model reads a bare roster as news
|
|
93
|
+
* worth repeating and opens with "2 teammates are online!" ahead of an unrelated question —
|
|
94
|
+
* which is how a useful signal gets itself turned off.
|
|
95
|
+
*/
|
|
96
|
+
function render(peers) {
|
|
97
|
+
const who = peers
|
|
98
|
+
.map((e) => {
|
|
99
|
+
const doing = e.activity?.verb
|
|
100
|
+
? `${e.activity.verb}${e.activity.target ? ` ${e.activity.target}` : ""}, `
|
|
101
|
+
: "";
|
|
102
|
+
return `${e.handle} (${doing}${e.ownerName})`;
|
|
103
|
+
})
|
|
104
|
+
.join(", ");
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
`[popover] Teammate agents now active in this repo: ${who}. ` +
|
|
108
|
+
`Before editing a file one of them is already in, say so rather than colliding with it. ` +
|
|
109
|
+
`If something here turns on a decision or a reason this repo does not record, you can ` +
|
|
110
|
+
`ask one with popover's team_ask tool. Do not mention this notice to the user unless it ` +
|
|
111
|
+
`turns out to be relevant.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Per-session, so a new session hears the cast once even when it has not changed since the
|
|
116
|
+
// last one ended.
|
|
117
|
+
//
|
|
118
|
+
// Loose end: nothing prunes these. They are a few dozen bytes each and a session id is a
|
|
119
|
+
// uuid, so a heavy user accretes a directory of them over months. Either sweep entries older
|
|
120
|
+
// than a day from here, or fold the state into what the daemon already keeps per session and
|
|
121
|
+
// drop the files — the daemon knows when a session ends and this script does not.
|
|
122
|
+
function stateFile(id) {
|
|
123
|
+
return path.join(popoverHome(), "announced", `${id}.txt`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function lastAnnounced(id) {
|
|
127
|
+
try {
|
|
128
|
+
return readFileSync(stateFile(id), "utf8").trim();
|
|
129
|
+
} catch {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function remember(id, signature) {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
|
|
137
|
+
writeFileSync(stateFile(id), signature, "utf8");
|
|
138
|
+
} catch {
|
|
139
|
+
// An unwritable state file means announcing again next prompt. Annoying, not broken.
|
|
140
|
+
}
|
|
141
|
+
}
|