@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.
- package/CHANGELOG.md +196 -69
- 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/next-steps.d.ts +1 -5
- package/dist/next-steps.d.ts.map +1 -1
- package/dist/next-steps.js +9 -5
- package/dist/next-steps.js.map +1 -1
- 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/terminal.d.ts.map +1 -1
- package/dist/terminal.js +21 -0
- package/dist/terminal.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 -76
- package/plugin/commands/ask.md +65 -65
- package/plugin/commands/fork.md +119 -118
- 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 -131
- 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 -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));
|