@popoverinstall/cli 0.8.0 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +196 -69
  2. package/LICENSE +21 -21
  3. package/README.md +142 -141
  4. package/dist/config-command.d.ts +2 -0
  5. package/dist/config-command.d.ts.map +1 -0
  6. package/dist/config-command.js +80 -0
  7. package/dist/config-command.js.map +1 -0
  8. package/dist/cursor-hooks.d.ts +18 -0
  9. package/dist/cursor-hooks.d.ts.map +1 -0
  10. package/dist/cursor-hooks.js +105 -0
  11. package/dist/cursor-hooks.js.map +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +46 -25
  14. package/dist/index.js.map +1 -1
  15. package/dist/keys.d.ts +10 -0
  16. package/dist/keys.d.ts.map +1 -0
  17. package/dist/keys.js +44 -0
  18. package/dist/keys.js.map +1 -0
  19. package/dist/next-steps.d.ts +1 -5
  20. package/dist/next-steps.d.ts.map +1 -1
  21. package/dist/next-steps.js +9 -5
  22. package/dist/next-steps.js.map +1 -1
  23. package/dist/repo-scan.d.ts +130 -0
  24. package/dist/repo-scan.d.ts.map +1 -0
  25. package/dist/repo-scan.js +281 -0
  26. package/dist/repo-scan.js.map +1 -0
  27. package/dist/repos.d.ts +180 -0
  28. package/dist/repos.d.ts.map +1 -0
  29. package/dist/repos.js +1002 -0
  30. package/dist/repos.js.map +1 -0
  31. package/dist/snapshot.d.ts +35 -0
  32. package/dist/snapshot.d.ts.map +1 -1
  33. package/dist/snapshot.js +16 -16
  34. package/dist/snapshot.js.map +1 -1
  35. package/dist/terminal.d.ts.map +1 -1
  36. package/dist/terminal.js +21 -0
  37. package/dist/terminal.js.map +1 -1
  38. package/dist/vaults.d.ts +276 -0
  39. package/dist/vaults.d.ts.map +1 -0
  40. package/dist/vaults.js +1224 -0
  41. package/dist/vaults.js.map +1 -0
  42. package/package.json +47 -47
  43. package/plugin/.claude-plugin/plugin.json +19 -19
  44. package/plugin/.mcp.json +9 -9
  45. package/plugin/README.md +84 -76
  46. package/plugin/commands/ask.md +65 -65
  47. package/plugin/commands/fork.md +119 -118
  48. package/plugin/commands/repos.md +107 -0
  49. package/plugin/commands/team.md +60 -60
  50. package/plugin/commands/tell.md +66 -66
  51. package/plugin/commands/vault.md +173 -0
  52. package/plugin/hooks/hooks.json +111 -111
  53. package/plugin/mcp/index.mjs +585 -355
  54. package/plugin/scripts/_ipc.mjs +146 -146
  55. package/plugin/scripts/announce-roster.mjs +141 -131
  56. package/plugin/scripts/deliver-messages.mjs +77 -77
  57. package/plugin/scripts/emit-event.mjs +44 -44
  58. package/plugin/scripts/ensure-daemon.mjs +156 -156
  59. package/plugin/scripts/roster.mjs +52 -52
  60. package/plugin/skills/popover/SKILL.md +175 -160
@@ -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,131 +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 would be a
34
- // sixth of the whole budget.
35
- if (!sessionId) process.exit(0);
36
-
37
- // `refresh: false` is what makes the tight timeout survivable this reads whatever the
38
- // daemon already has, so it costs one socket round trip and no network. A hook sitting
39
- // between a keystroke and the model must never be the thing that calls the cloud.
40
- // `cwd` matters most here of anywhere: this fires on the first prompt of a session, which
41
- // is exactly when the daemon may not yet have resolved that session's repo and the line
42
- // it prints claims the agents are "in this repo". Without the hint an unplaceable caller
43
- // is scoped by recency across the whole machine, so the claim could be false.
44
- const reply = await request(
45
- { t: "roster", id: "hook", refresh: false, fromSessionId: sessionId, cwd: process.cwd() },
46
- { timeoutMs: 250 },
47
- );
48
-
49
- // Daemon down, or signed out. Silence is right: the skill tells an agent how to read a
50
- // daemon-down error when it actually reaches for a tool, and guessing here would put a
51
- // wrong explanation in front of the model instead.
52
- if (reply?.t !== "roster.ok") process.exit(0);
53
-
54
- const peers = reply.entries.filter(
55
- (e) => !e.isSelf && e.reachable && e.status !== "offline",
56
- );
57
- if (peers.length === 0) process.exit(0); // Working alone, which is most of the time.
58
-
59
- // The cast, not what the cast is doing. Keying on activity would re-announce every time a
60
- // teammate moved from one file to the next, which is printing on every prompt again.
61
- const signature = peers
62
- .map((e) => e.handle)
63
- .sort()
64
- .join(",");
65
- if (signature === lastAnnounced(sessionId)) process.exit(0);
66
-
67
- process.stdout.write(`${render(peers)}\n`);
68
- remember(sessionId, signature);
69
- } catch {
70
- // Fail open, always. Whatever went wrong, the user's prompt must still go through.
71
- }
72
-
73
- process.exit(0);
74
-
75
- /**
76
- * One line the model can act on, and one sentence saying what it is for.
77
- *
78
- * Activity leads, owner follows. What a teammate's agent is touching is the part that
79
- * collides with what this one is about to do, and the whole value of announcing is that the
80
- * overlap is noticed before the edit.
81
- *
82
- * The closing sentence is not padding. Without it the model reads a bare roster as news
83
- * worth repeating and opens with "2 teammates are online!" ahead of an unrelated question —
84
- * which is how a useful signal gets itself turned off.
85
- */
86
- function render(peers) {
87
- const who = peers
88
- .map((e) => {
89
- const doing = e.activity?.verb
90
- ? `${e.activity.verb}${e.activity.target ? ` ${e.activity.target}` : ""}, `
91
- : "";
92
- return `${e.handle} (${doing}${e.ownerName})`;
93
- })
94
- .join(", ");
95
-
96
- return (
97
- `[popover] Teammate agents now active in this repo: ${who}. ` +
98
- `Before editing a file one of them is already in, say so rather than colliding with it. ` +
99
- `If something here turns on a decision or a reason this repo does not record, you can ` +
100
- `ask one with popover's team_ask tool. Do not mention this notice to the user unless it ` +
101
- `turns out to be relevant.`
102
- );
103
- }
104
-
105
- // Per-session, so a new session hears the cast once even when it has not changed since the
106
- // last one ended.
107
- //
108
- // Loose end: nothing prunes these. They are a few dozen bytes each and a session id is a
109
- // uuid, so a heavy user accretes a directory of them over months. Either sweep entries older
110
- // than a day from here, or fold the state into what the daemon already keeps per session and
111
- // drop the files — the daemon knows when a session ends and this script does not.
112
- function stateFile(id) {
113
- return path.join(popoverHome(), "announced", `${id}.txt`);
114
- }
115
-
116
- function lastAnnounced(id) {
117
- try {
118
- return readFileSync(stateFile(id), "utf8").trim();
119
- } catch {
120
- return "";
121
- }
122
- }
123
-
124
- function remember(id, signature) {
125
- try {
126
- mkdirSync(path.join(popoverHome(), "announced"), { recursive: true });
127
- writeFileSync(stateFile(id), signature, "utf8");
128
- } catch {
129
- // An unwritable state file means announcing again next prompt. Annoying, not broken.
130
- }
131
- }
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
+ }