@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,77 +1,77 @@
1
- #!/usr/bin/env node
2
- // Delivers tells into a live session.
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.
9
- //
10
- // Which makes it the riskiest script here, and it is written accordingly:
11
- //
12
- // - It runs before every prompt the user submits, so it must be fast and must fail open.
13
- // A missed heads-up is survivable; a prompt that hangs waiting on a dead daemon is not.
14
- // - It prints only what the daemon hands back. The envelope is rendered on this machine
15
- // from columns the database filled in, so a sender can neither forge the attribution nor
16
- // strip the framing off their own message.
17
- // - It stays silent inside a fork. Forks run the plugin's hooks like any other session, and
18
- // a fork answering a teammate's question must never be handed a third party's text — its
19
- // output goes back to whoever asked.
20
-
21
- import { callerSessionId, request } from "./_ipc.mjs";
22
-
23
- // The fork runner sets this when it spawns a read-only answer session.
24
- if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
25
-
26
- try {
27
- // The payload carries session_id; the environment is the fallback, and one of the two is
28
- // always present in a real session. Without a session there is no inbox to drain.
29
- let sessionId = callerSessionId();
30
- try {
31
- const raw = await readStdinQuickly();
32
- if (raw) sessionId = JSON.parse(raw)?.session_id || sessionId;
33
- } catch {
34
- // A malformed payload is not a reason to skip delivery; the env var still identifies us.
35
- }
36
-
37
- if (sessionId) {
38
- const reply = await request(
39
- { t: "pending", id: "hook", fromSessionId: sessionId, limit: 3 },
40
- { timeoutMs: 300 },
41
- );
42
-
43
- if (reply?.t === "pending.ok" && reply.rendered) {
44
- // The whole point of the script. Everything above exists to make this line safe.
45
- process.stdout.write(`${reply.rendered}\n`);
46
- }
47
- }
48
- } catch {
49
- // Fail open, always. Whatever went wrong, the user's prompt must still go through.
50
- }
51
-
52
- process.exit(0);
53
-
54
- /**
55
- * Read the hook payload, but never wait long for it.
56
- *
57
- * `readStdin` in _ipc.mjs allows two seconds, which is right for a fire-and-forget reporter
58
- * and far too long for something sitting between a keystroke and the model.
59
- */
60
- function readStdinQuickly(timeoutMs = 150) {
61
- return new Promise((resolve) => {
62
- let data = "";
63
- const timer = setTimeout(() => resolve(data), timeoutMs);
64
- process.stdin.setEncoding("utf8");
65
- process.stdin.on("data", (chunk) => {
66
- data += chunk;
67
- });
68
- process.stdin.on("end", () => {
69
- clearTimeout(timer);
70
- resolve(data);
71
- });
72
- process.stdin.on("error", () => {
73
- clearTimeout(timer);
74
- resolve(data);
75
- });
76
- });
77
- }
1
+ #!/usr/bin/env node
2
+ // Delivers tells into a live session.
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.
9
+ //
10
+ // Which makes it the riskiest script here, and it is written accordingly:
11
+ //
12
+ // - It runs before every prompt the user submits, so it must be fast and must fail open.
13
+ // A missed heads-up is survivable; a prompt that hangs waiting on a dead daemon is not.
14
+ // - It prints only what the daemon hands back. The envelope is rendered on this machine
15
+ // from columns the database filled in, so a sender can neither forge the attribution nor
16
+ // strip the framing off their own message.
17
+ // - It stays silent inside a fork. Forks run the plugin's hooks like any other session, and
18
+ // a fork answering a teammate's question must never be handed a third party's text — its
19
+ // output goes back to whoever asked.
20
+
21
+ import { callerSessionId, request } from "./_ipc.mjs";
22
+
23
+ // The fork runner sets this when it spawns a read-only answer session.
24
+ if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
25
+
26
+ try {
27
+ // The payload carries session_id; the environment is the fallback, and one of the two is
28
+ // always present in a real session. Without a session there is no inbox to drain.
29
+ let sessionId = callerSessionId();
30
+ try {
31
+ const raw = await readStdinQuickly();
32
+ if (raw) sessionId = JSON.parse(raw)?.session_id || sessionId;
33
+ } catch {
34
+ // A malformed payload is not a reason to skip delivery; the env var still identifies us.
35
+ }
36
+
37
+ if (sessionId) {
38
+ const reply = await request(
39
+ { t: "pending", id: "hook", fromSessionId: sessionId, limit: 3 },
40
+ { timeoutMs: 300 },
41
+ );
42
+
43
+ if (reply?.t === "pending.ok" && reply.rendered) {
44
+ // The whole point of the script. Everything above exists to make this line safe.
45
+ process.stdout.write(`${reply.rendered}\n`);
46
+ }
47
+ }
48
+ } catch {
49
+ // Fail open, always. Whatever went wrong, the user's prompt must still go through.
50
+ }
51
+
52
+ process.exit(0);
53
+
54
+ /**
55
+ * Read the hook payload, but never wait long for it.
56
+ *
57
+ * `readStdin` in _ipc.mjs allows two seconds, which is right for a fire-and-forget reporter
58
+ * and far too long for something sitting between a keystroke and the model.
59
+ */
60
+ function readStdinQuickly(timeoutMs = 150) {
61
+ return new Promise((resolve) => {
62
+ let data = "";
63
+ const timer = setTimeout(() => resolve(data), timeoutMs);
64
+ process.stdin.setEncoding("utf8");
65
+ process.stdin.on("data", (chunk) => {
66
+ data += chunk;
67
+ });
68
+ process.stdin.on("end", () => {
69
+ clearTimeout(timer);
70
+ resolve(data);
71
+ });
72
+ process.stdin.on("error", () => {
73
+ clearTimeout(timer);
74
+ resolve(data);
75
+ });
76
+ });
77
+ }
@@ -1,44 +1,44 @@
1
- #!/usr/bin/env node
2
- // Status hook handler.
3
- //
4
- // Reads the hook payload from stdin, forwards it to the daemon, exits. That is the whole
5
- // job — all interpretation happens in the daemon, so this stays fast enough to sit on
6
- // PreToolUse, which fires on every single tool call.
7
- //
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)
11
- // - never exit non-zero (exit 2 would BLOCK the tool call it is reporting on)
12
- // - never hang (hooks run async, but a wedged process still costs a handle)
13
-
14
- import { readStdin, sendFireAndForget } from "./_ipc.mjs";
15
-
16
- /*
17
- * Stay silent inside a fork — the same guard deliver-messages.mjs and announce-roster.mjs
18
- * carry, and for a sharper reason than either of them.
19
- *
20
- * A fork is a real Claude Code session with its own session id, and it runs the plugin's
21
- * hooks like any other. Without this, every answered ask reported PreToolUse, PostToolUse and
22
- * Stop for that throwaway session, and the daemon created a session for it — so every ask put
23
- * a ghost agent on the team roster, in the answering machine's repo, that nobody could reach
24
- * and that never went away until it was reaped.
25
- *
26
- * Worse, it poisoned attribution. `SessionStore.resolveCaller` infers who is calling from the
27
- * highest `lastEventAt`, and a fork that has just finished answering is by construction the
28
- * most recent thing to have emitted an event on this machine — so a tell sent moments after
29
- * answering an ask was recorded as coming from the fork.
30
- */
31
- if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
32
-
33
- try {
34
- const raw = await readStdin();
35
- const payload = JSON.parse(raw);
36
-
37
- if (payload?.session_id && payload?.hook_event_name) {
38
- await sendFireAndForget({ t: "status", payload });
39
- }
40
- } catch {
41
- // A malformed payload or an absent daemon is not worth interrupting anyone's work.
42
- }
43
-
44
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ // Status hook handler.
3
+ //
4
+ // Reads the hook payload from stdin, forwards it to the daemon, exits. That is the whole
5
+ // job — all interpretation happens in the daemon, so this stays fast enough to sit on
6
+ // PreToolUse, which fires on every single tool call.
7
+ //
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)
11
+ // - never exit non-zero (exit 2 would BLOCK the tool call it is reporting on)
12
+ // - never hang (hooks run async, but a wedged process still costs a handle)
13
+
14
+ import { readStdin, sendFireAndForget } from "./_ipc.mjs";
15
+
16
+ /*
17
+ * Stay silent inside a fork — the same guard deliver-messages.mjs and announce-roster.mjs
18
+ * carry, and for a sharper reason than either of them.
19
+ *
20
+ * A fork is a real Claude Code session with its own session id, and it runs the plugin's
21
+ * hooks like any other. Without this, every answered ask reported PreToolUse, PostToolUse and
22
+ * Stop for that throwaway session, and the daemon created a session for it — so every ask put
23
+ * a ghost agent on the team roster, in the answering machine's repo, that nobody could reach
24
+ * and that never went away until it was reaped.
25
+ *
26
+ * Worse, it poisoned attribution. `SessionStore.resolveCaller` infers who is calling from the
27
+ * highest `lastEventAt`, and a fork that has just finished answering is by construction the
28
+ * most recent thing to have emitted an event on this machine — so a tell sent moments after
29
+ * answering an ask was recorded as coming from the fork.
30
+ */
31
+ if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
32
+
33
+ try {
34
+ const raw = await readStdin();
35
+ const payload = JSON.parse(raw);
36
+
37
+ if (payload?.session_id && payload?.hook_event_name) {
38
+ await sendFireAndForget({ t: "status", payload });
39
+ }
40
+ } catch {
41
+ // A malformed payload or an absent daemon is not worth interrupting anyone's work.
42
+ }
43
+
44
+ process.exit(0);
@@ -1,156 +1,156 @@
1
- #!/usr/bin/env node
2
- // SessionStart hook: make sure exactly one daemon is running, then register this session.
3
- //
4
- // This runs on every session start, so the common case — a daemon is already up — must
5
- // be a single cheap probe with no side effects.
6
-
7
- import { spawn } from "node:child_process";
8
- import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
9
- import path from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { daemonAddress, popoverHome, readStdin, request, sendFireAndForget } from "./_ipc.mjs";
12
-
13
- const HERE = path.dirname(fileURLToPath(import.meta.url));
14
-
15
- /*
16
- * A fork has nothing to do here, and doing it is actively harmful.
17
- *
18
- * The daemon spawns forks itself, so a daemon demonstrably exists — and registering the
19
- * fork's session id would put a ghost agent on the team roster for every ask answered. Same
20
- * guard, same reason, as emit-event.mjs; see its header for what the ghost then does to
21
- * attribution.
22
- */
23
- if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
24
-
25
- async function main() {
26
- const raw = await readStdin().catch(() => "");
27
- let payload = null;
28
- try {
29
- payload = JSON.parse(raw);
30
- } catch {
31
- /* still worth ensuring the daemon is up */
32
- }
33
-
34
- const alive = await ping();
35
- // `updateInProgress()` is the interlock, not an optimisation. `popover update` stops the
36
- // daemon and hands npm up to five minutes to replace the global install; a daemon started
37
- // in that window runs out of the tree being replaced and holds its files open, so npm's
38
- // rename fails EPERM/EBUSY on Windows and the update aborts half-done. Every session on the
39
- // machine runs this hook, so without the check one of them wins that race almost every time.
40
- if (!alive && !updateInProgress()) {
41
- const entry = resolveDaemonEntry();
42
- if (entry) {
43
- launch(entry);
44
- // Give it a moment to bind before we register, but do not block the session on it:
45
- // the hook is async and the status event falls back to the spool file regardless.
46
- await waitForDaemon(3000);
47
- }
48
- }
49
-
50
- if (payload?.session_id) {
51
- await sendFireAndForget({ t: "status", payload });
52
- }
53
- process.exit(0);
54
- }
55
-
56
- async function ping() {
57
- const reply = await request({ t: "ping", id: "ensure" }, { timeoutMs: 1200 });
58
- return reply?.t === "pong";
59
- }
60
-
61
- async function waitForDaemon(budgetMs) {
62
- const deadline = Date.now() + budgetMs;
63
- while (Date.now() < deadline) {
64
- if (await ping()) return true;
65
- await new Promise((r) => setTimeout(r, 200));
66
- }
67
- return false;
68
- }
69
-
70
- /**
71
- * Find the daemon entry point.
72
- *
73
- * Order matters: an explicit override wins, then the pointer file, then the monorepo
74
- * layouts used during development.
75
- *
76
- * The pointer file is the one that matters in production, and it holds an absolute path on
77
- * purpose. Installing a plugin *copies* it into
78
- * `~/.claude/plugins/cache/<mkt>/<plugin>/<version>/`, severed from whatever tree it was
79
- * built in — so every path relative to this file lands inside that cache directory, where
80
- * no daemon has ever existed. Only a fixed location under `~/.popover` survives the copy,
81
- * and `popover setup` is what writes it there.
82
- *
83
- * Keep in step with `daemonEntryPointerPath()` in @popoverinstall/shared. This file cannot import
84
- * it: a hook that fails to resolve an import would break every tool call in the session.
85
- */
86
- function resolveDaemonEntry() {
87
- if (process.env.POPOVER_DAEMON_ENTRY && existsSync(process.env.POPOVER_DAEMON_ENTRY)) {
88
- return process.env.POPOVER_DAEMON_ENTRY;
89
- }
90
-
91
- // Written by `popover setup`. Survives the plugin-cache copy; see above.
92
- try {
93
- const entry = readFileSync(path.join(popoverHome(), "daemon-entry"), "utf8").trim();
94
- if (entry && existsSync(entry)) return entry;
95
- } catch {
96
- /* no pointer yet — `popover setup` has not run */
97
- }
98
-
99
- const candidates = [
100
- // Vendored beside the plugin.
101
- path.join(HERE, "..", "node_modules", "@popoverinstall", "daemon", "dist", "index.js"),
102
- // Monorepo checkout.
103
- path.join(HERE, "..", "..", "daemon", "dist", "index.js"),
104
- path.join(HERE, "..", "..", "..", "packages", "daemon", "dist", "index.js"),
105
- ];
106
-
107
- for (const candidate of candidates) {
108
- if (existsSync(candidate)) return candidate;
109
- }
110
- return null;
111
- }
112
-
113
- /**
114
- * Whether `popover update` is replacing the install tree right now.
115
- *
116
- * Written by `beginUpdateLock` in packages/cli/src/daemon-lock.ts, which holds the readable
117
- * version of why this exists. Duplicated here rather than imported for the same reason
118
- * `resolveDaemonEntry` duplicates its path: this file must resolve no imports beyond the ones
119
- * it already has, because a hook that throws breaks every session on the machine. Keep the
120
- * two in step.
121
- *
122
- * Every branch fails towards starting the daemon. A marker with no deadline, or one whose
123
- * deadline has passed, is a crashed update rather than a running one, and refusing to start
124
- * on that basis would leave a machine permanently without a daemon and nothing on screen to
125
- * explain it. Only a marker that is present, readable and unexpired holds us back.
126
- */
127
- function updateInProgress() {
128
- try {
129
- const file = path.join(popoverHome(), "update.lock");
130
- if (!existsSync(file)) return false;
131
- const deadline = Number(JSON.parse(readFileSync(file, "utf8")).deadline);
132
- return Number.isFinite(deadline) && Date.now() < deadline;
133
- } catch {
134
- return false;
135
- }
136
- }
137
-
138
- function launch(entry) {
139
- try {
140
- // Detached with stdio to a log file: the daemon must outlive this hook, this session,
141
- // and the terminal window it was started from.
142
- mkdirSync(popoverHome(), { recursive: true });
143
- const out = openSync(path.join(popoverHome(), "daemon-stdout.log"), "a");
144
- const child = spawn(process.execPath, [entry], {
145
- detached: true,
146
- stdio: ["ignore", out, out],
147
- windowsHide: true,
148
- env: { ...process.env, POPOVER_DAEMON_ADDR: daemonAddress() },
149
- });
150
- child.unref();
151
- } catch {
152
- // If the daemon cannot start, /team will say so plainly. Never fail the session.
153
- }
154
- }
155
-
156
- main().catch(() => process.exit(0));
1
+ #!/usr/bin/env node
2
+ // SessionStart hook: make sure exactly one daemon is running, then register this session.
3
+ //
4
+ // This runs on every session start, so the common case — a daemon is already up — must
5
+ // be a single cheap probe with no side effects.
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { daemonAddress, popoverHome, readStdin, request, sendFireAndForget } from "./_ipc.mjs";
12
+
13
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ /*
16
+ * A fork has nothing to do here, and doing it is actively harmful.
17
+ *
18
+ * The daemon spawns forks itself, so a daemon demonstrably exists — and registering the
19
+ * fork's session id would put a ghost agent on the team roster for every ask answered. Same
20
+ * guard, same reason, as emit-event.mjs; see its header for what the ghost then does to
21
+ * attribution.
22
+ */
23
+ if (process.env.CLAUDE_CODE_ENTRYPOINT === "popover-fork") process.exit(0);
24
+
25
+ async function main() {
26
+ const raw = await readStdin().catch(() => "");
27
+ let payload = null;
28
+ try {
29
+ payload = JSON.parse(raw);
30
+ } catch {
31
+ /* still worth ensuring the daemon is up */
32
+ }
33
+
34
+ const alive = await ping();
35
+ // `updateInProgress()` is the interlock, not an optimisation. `popover update` stops the
36
+ // daemon and hands npm up to five minutes to replace the global install; a daemon started
37
+ // in that window runs out of the tree being replaced and holds its files open, so npm's
38
+ // rename fails EPERM/EBUSY on Windows and the update aborts half-done. Every session on the
39
+ // machine runs this hook, so without the check one of them wins that race almost every time.
40
+ if (!alive && !updateInProgress()) {
41
+ const entry = resolveDaemonEntry();
42
+ if (entry) {
43
+ launch(entry);
44
+ // Give it a moment to bind before we register, but do not block the session on it:
45
+ // the hook is async and the status event falls back to the spool file regardless.
46
+ await waitForDaemon(3000);
47
+ }
48
+ }
49
+
50
+ if (payload?.session_id) {
51
+ await sendFireAndForget({ t: "status", payload });
52
+ }
53
+ process.exit(0);
54
+ }
55
+
56
+ async function ping() {
57
+ const reply = await request({ t: "ping", id: "ensure" }, { timeoutMs: 1200 });
58
+ return reply?.t === "pong";
59
+ }
60
+
61
+ async function waitForDaemon(budgetMs) {
62
+ const deadline = Date.now() + budgetMs;
63
+ while (Date.now() < deadline) {
64
+ if (await ping()) return true;
65
+ await new Promise((r) => setTimeout(r, 200));
66
+ }
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Find the daemon entry point.
72
+ *
73
+ * Order matters: an explicit override wins, then the pointer file, then the monorepo
74
+ * layouts used during development.
75
+ *
76
+ * The pointer file is the one that matters in production, and it holds an absolute path on
77
+ * purpose. Installing a plugin *copies* it into
78
+ * `~/.claude/plugins/cache/<mkt>/<plugin>/<version>/`, severed from whatever tree it was
79
+ * built in — so every path relative to this file lands inside that cache directory, where
80
+ * no daemon has ever existed. Only a fixed location under `~/.popover` survives the copy,
81
+ * and `popover setup` is what writes it there.
82
+ *
83
+ * Keep in step with `daemonEntryPointerPath()` in @popoverinstall/shared. This file cannot import
84
+ * it: a hook that fails to resolve an import would break every tool call in the session.
85
+ */
86
+ function resolveDaemonEntry() {
87
+ if (process.env.POPOVER_DAEMON_ENTRY && existsSync(process.env.POPOVER_DAEMON_ENTRY)) {
88
+ return process.env.POPOVER_DAEMON_ENTRY;
89
+ }
90
+
91
+ // Written by `popover setup`. Survives the plugin-cache copy; see above.
92
+ try {
93
+ const entry = readFileSync(path.join(popoverHome(), "daemon-entry"), "utf8").trim();
94
+ if (entry && existsSync(entry)) return entry;
95
+ } catch {
96
+ /* no pointer yet — `popover setup` has not run */
97
+ }
98
+
99
+ const candidates = [
100
+ // Vendored beside the plugin.
101
+ path.join(HERE, "..", "node_modules", "@popoverinstall", "daemon", "dist", "index.js"),
102
+ // Monorepo checkout.
103
+ path.join(HERE, "..", "..", "daemon", "dist", "index.js"),
104
+ path.join(HERE, "..", "..", "..", "packages", "daemon", "dist", "index.js"),
105
+ ];
106
+
107
+ for (const candidate of candidates) {
108
+ if (existsSync(candidate)) return candidate;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Whether `popover update` is replacing the install tree right now.
115
+ *
116
+ * Written by `beginUpdateLock` in packages/cli/src/daemon-lock.ts, which holds the readable
117
+ * version of why this exists. Duplicated here rather than imported for the same reason
118
+ * `resolveDaemonEntry` duplicates its path: this file must resolve no imports beyond the ones
119
+ * it already has, because a hook that throws breaks every session on the machine. Keep the
120
+ * two in step.
121
+ *
122
+ * Every branch fails towards starting the daemon. A marker with no deadline, or one whose
123
+ * deadline has passed, is a crashed update rather than a running one, and refusing to start
124
+ * on that basis would leave a machine permanently without a daemon and nothing on screen to
125
+ * explain it. Only a marker that is present, readable and unexpired holds us back.
126
+ */
127
+ function updateInProgress() {
128
+ try {
129
+ const file = path.join(popoverHome(), "update.lock");
130
+ if (!existsSync(file)) return false;
131
+ const deadline = Number(JSON.parse(readFileSync(file, "utf8")).deadline);
132
+ return Number.isFinite(deadline) && Date.now() < deadline;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function launch(entry) {
139
+ try {
140
+ // Detached with stdio to a log file: the daemon must outlive this hook, this session,
141
+ // and the terminal window it was started from.
142
+ mkdirSync(popoverHome(), { recursive: true });
143
+ const out = openSync(path.join(popoverHome(), "daemon-stdout.log"), "a");
144
+ const child = spawn(process.execPath, [entry], {
145
+ detached: true,
146
+ stdio: ["ignore", out, out],
147
+ windowsHide: true,
148
+ env: { ...process.env, POPOVER_DAEMON_ADDR: daemonAddress() },
149
+ });
150
+ child.unref();
151
+ } catch {
152
+ // If the daemon cannot start, /team will say so plainly. Never fail the session.
153
+ }
154
+ }
155
+
156
+ main().catch(() => process.exit(0));