@nocville/npc 0.0.0-stage → 0.2.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 (210) hide show
  1. package/README.md +243 -2
  2. package/dist/cjs/cli.js +533 -0
  3. package/dist/cjs/core/api-key.js +69 -0
  4. package/dist/cjs/core/base-npc.js +160 -0
  5. package/dist/cjs/core/config.js +108 -0
  6. package/dist/cjs/core/event-npc.js +14 -0
  7. package/dist/cjs/core/logger.js +39 -0
  8. package/dist/cjs/core/polling-npc.js +65 -0
  9. package/dist/cjs/core/screen.js +28 -0
  10. package/dist/cjs/core/text.js +15 -0
  11. package/dist/cjs/core/thresholds.js +48 -0
  12. package/dist/cjs/core/types.js +10 -0
  13. package/dist/cjs/core/user-config.js +92 -0
  14. package/dist/cjs/doctor.js +130 -0
  15. package/dist/cjs/index.js +90 -0
  16. package/dist/cjs/npcs/agent-common/activity.js +291 -0
  17. package/dist/cjs/npcs/agent-common/git-stats.js +71 -0
  18. package/dist/cjs/npcs/agent-common/hook-runner.js +252 -0
  19. package/dist/cjs/npcs/agent-common/hook-settings.js +279 -0
  20. package/dist/cjs/npcs/agent-common/liveness.js +112 -0
  21. package/dist/cjs/npcs/agent-common/lock.js +99 -0
  22. package/dist/cjs/npcs/agent-common/runtime.js +154 -0
  23. package/dist/cjs/npcs/agent-common/session.js +390 -0
  24. package/dist/cjs/npcs/agent-common/shim.js +96 -0
  25. package/dist/cjs/npcs/agent-common/watchdog.js +149 -0
  26. package/dist/cjs/npcs/claude-code/defaults.js +20 -0
  27. package/dist/cjs/npcs/claude-code/hook-handler.js +139 -0
  28. package/dist/cjs/npcs/claude-code/index.js +104 -0
  29. package/dist/cjs/npcs/claude-code/setup.js +133 -0
  30. package/dist/cjs/npcs/claude-code/state-machine.js +130 -0
  31. package/dist/cjs/npcs/codex/defaults.js +32 -0
  32. package/dist/cjs/npcs/codex/hook-handler.js +116 -0
  33. package/dist/cjs/npcs/codex/index.js +92 -0
  34. package/dist/cjs/npcs/codex/rollout.js +246 -0
  35. package/dist/cjs/npcs/codex/setup.js +104 -0
  36. package/dist/cjs/npcs/codex/watcher.js +346 -0
  37. package/dist/cjs/npcs/cursor/defaults.js +15 -0
  38. package/dist/cjs/npcs/cursor/hook-handler.js +65 -0
  39. package/dist/cjs/npcs/cursor/index.js +72 -0
  40. package/dist/cjs/npcs/cursor/normalize.js +54 -0
  41. package/dist/cjs/npcs/cursor/setup.js +98 -0
  42. package/dist/cjs/npcs/gemini/defaults.js +15 -0
  43. package/dist/cjs/npcs/gemini/hook-handler.js +93 -0
  44. package/dist/cjs/npcs/gemini/index.js +69 -0
  45. package/dist/cjs/npcs/gemini/setup.js +98 -0
  46. package/dist/cjs/npcs/github-watcher/defaults.js +11 -0
  47. package/dist/cjs/npcs/github-watcher/format.js +21 -0
  48. package/dist/cjs/npcs/github-watcher/github-api.js +126 -0
  49. package/dist/cjs/npcs/github-watcher/index.js +148 -0
  50. package/dist/cjs/npcs/internet-connectivity/checks.js +92 -0
  51. package/dist/cjs/npcs/internet-connectivity/defaults.js +22 -0
  52. package/dist/cjs/npcs/internet-connectivity/index.js +251 -0
  53. package/dist/cjs/npcs/log-watcher/buffer.js +40 -0
  54. package/dist/cjs/npcs/log-watcher/defaults.js +12 -0
  55. package/dist/cjs/npcs/log-watcher/index.js +161 -0
  56. package/dist/cjs/npcs/log-watcher/tail.js +81 -0
  57. package/dist/cjs/npcs/process-watcher/defaults.js +9 -0
  58. package/dist/cjs/npcs/process-watcher/index.js +140 -0
  59. package/dist/cjs/npcs/process-watcher/process-scanner.js +114 -0
  60. package/dist/cjs/npcs/receptionist/defaults.js +9 -0
  61. package/dist/cjs/npcs/receptionist/index.js +120 -0
  62. package/dist/cjs/npcs/receptionist/weather.js +129 -0
  63. package/dist/cjs/npcs/system-monitor/collectors.js +68 -0
  64. package/dist/cjs/npcs/system-monitor/defaults.js +13 -0
  65. package/dist/cjs/npcs/system-monitor/index.js +105 -0
  66. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +58 -0
  67. package/dist/cjs/npcs/web-page-watcher/capture.js +80 -0
  68. package/dist/cjs/npcs/web-page-watcher/defaults.js +31 -0
  69. package/dist/cjs/npcs/web-page-watcher/index.js +234 -0
  70. package/dist/cjs/npcs/web-page-watcher/pixelate.js +181 -0
  71. package/dist/cjs/package.json +1 -0
  72. package/dist/cli.d.ts +3 -0
  73. package/dist/cli.js +498 -0
  74. package/dist/core/api-key.d.ts +34 -0
  75. package/dist/core/api-key.js +64 -0
  76. package/dist/core/base-npc.d.ts +41 -0
  77. package/dist/core/base-npc.js +156 -0
  78. package/dist/core/config.d.ts +22 -0
  79. package/dist/core/config.js +101 -0
  80. package/dist/core/event-npc.d.ts +8 -0
  81. package/dist/core/event-npc.js +10 -0
  82. package/dist/core/logger.d.ts +11 -0
  83. package/dist/core/logger.js +35 -0
  84. package/dist/core/polling-npc.d.ts +18 -0
  85. package/dist/core/polling-npc.js +61 -0
  86. package/dist/core/screen.d.ts +11 -0
  87. package/dist/core/screen.js +24 -0
  88. package/dist/core/text.d.ts +6 -0
  89. package/dist/core/text.js +12 -0
  90. package/dist/core/thresholds.d.ts +10 -0
  91. package/dist/core/thresholds.js +44 -0
  92. package/dist/core/types.d.ts +60 -0
  93. package/dist/core/types.js +7 -0
  94. package/dist/core/user-config.d.ts +43 -0
  95. package/dist/core/user-config.js +85 -0
  96. package/dist/doctor.d.ts +8 -0
  97. package/dist/doctor.js +126 -0
  98. package/dist/index.d.ts +39 -0
  99. package/dist/index.js +25 -0
  100. package/dist/npcs/agent-common/activity.d.ts +42 -0
  101. package/dist/npcs/agent-common/activity.js +280 -0
  102. package/dist/npcs/agent-common/git-stats.d.ts +11 -0
  103. package/dist/npcs/agent-common/git-stats.js +67 -0
  104. package/dist/npcs/agent-common/hook-runner.d.ts +62 -0
  105. package/dist/npcs/agent-common/hook-runner.js +245 -0
  106. package/dist/npcs/agent-common/hook-settings.d.ts +75 -0
  107. package/dist/npcs/agent-common/hook-settings.js +265 -0
  108. package/dist/npcs/agent-common/liveness.d.ts +12 -0
  109. package/dist/npcs/agent-common/liveness.js +105 -0
  110. package/dist/npcs/agent-common/lock.d.ts +6 -0
  111. package/dist/npcs/agent-common/lock.js +95 -0
  112. package/dist/npcs/agent-common/runtime.d.ts +16 -0
  113. package/dist/npcs/agent-common/runtime.js +149 -0
  114. package/dist/npcs/agent-common/session.d.ts +184 -0
  115. package/dist/npcs/agent-common/session.js +368 -0
  116. package/dist/npcs/agent-common/shim.d.ts +4 -0
  117. package/dist/npcs/agent-common/shim.js +90 -0
  118. package/dist/npcs/agent-common/watchdog.d.ts +16 -0
  119. package/dist/npcs/agent-common/watchdog.js +144 -0
  120. package/dist/npcs/claude-code/defaults.d.ts +15 -0
  121. package/dist/npcs/claude-code/defaults.js +17 -0
  122. package/dist/npcs/claude-code/hook-handler.d.ts +40 -0
  123. package/dist/npcs/claude-code/hook-handler.js +131 -0
  124. package/dist/npcs/claude-code/index.d.ts +36 -0
  125. package/dist/npcs/claude-code/index.js +92 -0
  126. package/dist/npcs/claude-code/setup.d.ts +58 -0
  127. package/dist/npcs/claude-code/setup.js +121 -0
  128. package/dist/npcs/claude-code/state-machine.d.ts +28 -0
  129. package/dist/npcs/claude-code/state-machine.js +125 -0
  130. package/dist/npcs/codex/defaults.d.ts +24 -0
  131. package/dist/npcs/codex/defaults.js +28 -0
  132. package/dist/npcs/codex/hook-handler.d.ts +24 -0
  133. package/dist/npcs/codex/hook-handler.js +110 -0
  134. package/dist/npcs/codex/index.d.ts +21 -0
  135. package/dist/npcs/codex/index.js +76 -0
  136. package/dist/npcs/codex/rollout.d.ts +39 -0
  137. package/dist/npcs/codex/rollout.js +242 -0
  138. package/dist/npcs/codex/setup.d.ts +37 -0
  139. package/dist/npcs/codex/setup.js +96 -0
  140. package/dist/npcs/codex/watcher.d.ts +62 -0
  141. package/dist/npcs/codex/watcher.js +341 -0
  142. package/dist/npcs/cursor/defaults.d.ts +9 -0
  143. package/dist/npcs/cursor/defaults.js +11 -0
  144. package/dist/npcs/cursor/hook-handler.d.ts +8 -0
  145. package/dist/npcs/cursor/hook-handler.js +60 -0
  146. package/dist/npcs/cursor/index.d.ts +18 -0
  147. package/dist/npcs/cursor/index.js +57 -0
  148. package/dist/npcs/cursor/normalize.d.ts +6 -0
  149. package/dist/npcs/cursor/normalize.js +49 -0
  150. package/dist/npcs/cursor/setup.d.ts +29 -0
  151. package/dist/npcs/cursor/setup.js +90 -0
  152. package/dist/npcs/gemini/defaults.d.ts +9 -0
  153. package/dist/npcs/gemini/defaults.js +11 -0
  154. package/dist/npcs/gemini/hook-handler.d.ts +10 -0
  155. package/dist/npcs/gemini/hook-handler.js +87 -0
  156. package/dist/npcs/gemini/index.d.ts +17 -0
  157. package/dist/npcs/gemini/index.js +56 -0
  158. package/dist/npcs/gemini/setup.d.ts +29 -0
  159. package/dist/npcs/gemini/setup.js +90 -0
  160. package/dist/npcs/github-watcher/defaults.d.ts +8 -0
  161. package/dist/npcs/github-watcher/defaults.js +8 -0
  162. package/dist/npcs/github-watcher/format.d.ts +3 -0
  163. package/dist/npcs/github-watcher/format.js +18 -0
  164. package/dist/npcs/github-watcher/github-api.d.ts +55 -0
  165. package/dist/npcs/github-watcher/github-api.js +120 -0
  166. package/dist/npcs/github-watcher/index.d.ts +35 -0
  167. package/dist/npcs/github-watcher/index.js +144 -0
  168. package/dist/npcs/internet-connectivity/checks.d.ts +24 -0
  169. package/dist/npcs/internet-connectivity/checks.js +84 -0
  170. package/dist/npcs/internet-connectivity/defaults.d.ts +20 -0
  171. package/dist/npcs/internet-connectivity/defaults.js +19 -0
  172. package/dist/npcs/internet-connectivity/index.d.ts +55 -0
  173. package/dist/npcs/internet-connectivity/index.js +246 -0
  174. package/dist/npcs/log-watcher/buffer.d.ts +18 -0
  175. package/dist/npcs/log-watcher/buffer.js +34 -0
  176. package/dist/npcs/log-watcher/defaults.d.ts +9 -0
  177. package/dist/npcs/log-watcher/defaults.js +9 -0
  178. package/dist/npcs/log-watcher/index.d.ts +45 -0
  179. package/dist/npcs/log-watcher/index.js +157 -0
  180. package/dist/npcs/log-watcher/tail.d.ts +9 -0
  181. package/dist/npcs/log-watcher/tail.js +78 -0
  182. package/dist/npcs/process-watcher/defaults.d.ts +6 -0
  183. package/dist/npcs/process-watcher/defaults.js +6 -0
  184. package/dist/npcs/process-watcher/index.d.ts +26 -0
  185. package/dist/npcs/process-watcher/index.js +136 -0
  186. package/dist/npcs/process-watcher/process-scanner.d.ts +19 -0
  187. package/dist/npcs/process-watcher/process-scanner.js +110 -0
  188. package/dist/npcs/receptionist/defaults.d.ts +6 -0
  189. package/dist/npcs/receptionist/defaults.js +6 -0
  190. package/dist/npcs/receptionist/index.d.ts +29 -0
  191. package/dist/npcs/receptionist/index.js +116 -0
  192. package/dist/npcs/receptionist/weather.d.ts +37 -0
  193. package/dist/npcs/receptionist/weather.js +121 -0
  194. package/dist/npcs/system-monitor/collectors.d.ts +45 -0
  195. package/dist/npcs/system-monitor/collectors.js +60 -0
  196. package/dist/npcs/system-monitor/defaults.d.ts +7 -0
  197. package/dist/npcs/system-monitor/defaults.js +10 -0
  198. package/dist/npcs/system-monitor/index.d.ts +27 -0
  199. package/dist/npcs/system-monitor/index.js +101 -0
  200. package/dist/npcs/web-page-watcher/basic-mode.d.ts +16 -0
  201. package/dist/npcs/web-page-watcher/basic-mode.js +52 -0
  202. package/dist/npcs/web-page-watcher/capture.d.ts +49 -0
  203. package/dist/npcs/web-page-watcher/capture.js +75 -0
  204. package/dist/npcs/web-page-watcher/defaults.d.ts +19 -0
  205. package/dist/npcs/web-page-watcher/defaults.js +27 -0
  206. package/dist/npcs/web-page-watcher/index.d.ts +42 -0
  207. package/dist/npcs/web-page-watcher/index.js +230 -0
  208. package/dist/npcs/web-page-watcher/pixelate.d.ts +36 -0
  209. package/dist/npcs/web-page-watcher/pixelate.js +172 -0
  210. package/package.json +73 -3
@@ -0,0 +1,265 @@
1
+ import { copyFileSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { SHIM_PATH } from './shim.js';
4
+ /**
5
+ * Matcher-group hook config shared by Claude Code (`~/.claude/settings.json`)
6
+ * and Codex (`~/.codex/hooks.json`) — both use:
7
+ *
8
+ * { "hooks": { "<Event>": [ { "matcher": "", "hooks": [ { "type": "command", "command": "…" } ] } ] } }
9
+ *
10
+ * Install/uninstall only ever touch entries whose command carries the
11
+ * `nocville-npc` marker; the user's own hooks are preserved verbatim.
12
+ */
13
+ /** Marker used to identify nocville-npc hook commands (flat or nested). */
14
+ export const HOOK_MARKER = 'nocville-npc';
15
+ /**
16
+ * Trailing shell comment appended to hook commands. `/bin/sh` ignores it, but it
17
+ * guarantees the marker is present for idempotency regardless of install path.
18
+ */
19
+ export const HOOK_MARKER_COMMENT = '# nocville-npc-hook';
20
+ function isCommandHookWithMarker(hook) {
21
+ if (!hook ||
22
+ typeof hook !== 'object' ||
23
+ typeof hook.command !== 'string')
24
+ return false;
25
+ const command = hook.command;
26
+ if (command.includes(HOOK_MARKER) ||
27
+ /nocville-hook\.cmd"?\s+(?:claude-code|codex|gemini|cursor)\s+hook\b/.test(command))
28
+ return true;
29
+ // Recognize the legacy encoded Windows form so setup replaces it and uninstall removes it.
30
+ const encoded = /^powershell\.exe -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/=]+)$/.exec(command);
31
+ if (!encoded)
32
+ return false;
33
+ const decoded = Buffer.from(encoded[1], 'base64').toString('utf16le');
34
+ return (decoded.startsWith(`& cmd.exe /d /s /c '`) &&
35
+ decoded.endsWith(` # ${HOOK_MARKER}`) &&
36
+ /nocville-hook\.cmd" (?:claude-code|codex|gemini|cursor) hook/.test(decoded));
37
+ }
38
+ function isGroup(entry) {
39
+ return (typeof entry === 'object' &&
40
+ entry !== null &&
41
+ Array.isArray(entry.hooks));
42
+ }
43
+ /** Old flat format: a bare command hook (no nested `hooks` array). */
44
+ function isFlatNocvilleEntry(entry) {
45
+ return !isGroup(entry) && isCommandHookWithMarker(entry);
46
+ }
47
+ /** Strip nocville hooks from one event's entry list. Returns kept entries + removed count. */
48
+ function stripNocville(entries) {
49
+ const kept = [];
50
+ let removed = 0;
51
+ let flat = 0;
52
+ for (const entry of entries) {
53
+ if (isFlatNocvilleEntry(entry)) {
54
+ removed++;
55
+ flat++;
56
+ continue;
57
+ }
58
+ if (isGroup(entry)) {
59
+ const keptHooks = entry.hooks.filter((h) => !isCommandHookWithMarker(h));
60
+ const diff = entry.hooks.length - keptHooks.length;
61
+ if (diff === 0)
62
+ kept.push(entry);
63
+ else {
64
+ removed += diff;
65
+ if (keptHooks.length > 0)
66
+ kept.push({ ...entry, hooks: keptHooks });
67
+ }
68
+ continue;
69
+ }
70
+ kept.push(entry);
71
+ }
72
+ return { kept, removed, flat };
73
+ }
74
+ /**
75
+ * Install one nocville hook group into each of `events`. Idempotent: existing
76
+ * nocville entries (flat legacy or nested) are removed first, preserving the
77
+ * user's own hooks, then a single group per event is appended. Mutates `file`.
78
+ */
79
+ export function installHookGroups(file, command, events, extra = {}) {
80
+ if (!file.hooks || typeof file.hooks !== 'object' || Array.isArray(file.hooks))
81
+ file.hooks = {};
82
+ const root = file.hooks;
83
+ let migrated = 0;
84
+ let installed = 0;
85
+ // Drop nocville entries from events we no longer install, too.
86
+ for (const ev of Object.keys(root)) {
87
+ if (events.includes(ev) || !Array.isArray(root[ev]))
88
+ continue;
89
+ const { kept } = stripNocville(root[ev]);
90
+ if (kept.length === 0)
91
+ delete root[ev];
92
+ else
93
+ root[ev] = kept;
94
+ }
95
+ for (const ev of events) {
96
+ const existing = Array.isArray(root[ev]) ? root[ev] : [];
97
+ const { kept, flat } = stripNocville(existing);
98
+ migrated += flat;
99
+ const hook = { type: 'command', command };
100
+ if (extra.timeout !== undefined)
101
+ hook.timeout = extra.timeout;
102
+ Object.assign(hook, extra.perEvent?.[ev]);
103
+ kept.push({ matcher: '', hooks: [hook] });
104
+ installed++;
105
+ root[ev] = kept;
106
+ }
107
+ return { migrated, installed };
108
+ }
109
+ /** Remove every nocville hook entry. Mutates `file`; returns the count removed. */
110
+ export function removeHookGroups(file) {
111
+ if (!file.hooks || typeof file.hooks !== 'object')
112
+ return 0;
113
+ const root = file.hooks;
114
+ let removed = 0;
115
+ for (const ev of Object.keys(root)) {
116
+ const arr = root[ev];
117
+ if (!Array.isArray(arr))
118
+ continue;
119
+ const res = stripNocville(arr);
120
+ removed += res.removed;
121
+ if (res.kept.length === 0)
122
+ delete root[ev];
123
+ else
124
+ root[ev] = res.kept;
125
+ }
126
+ if (Object.keys(root).length === 0)
127
+ delete file.hooks;
128
+ return removed;
129
+ }
130
+ /** First nocville hook command found in any event (any format). */
131
+ export function findNocvilleCommand(file) {
132
+ const root = file.hooks;
133
+ if (!root || typeof root !== 'object')
134
+ return undefined;
135
+ for (const ev of Object.keys(root)) {
136
+ const arr = root[ev];
137
+ if (!Array.isArray(arr))
138
+ continue;
139
+ for (const entry of arr) {
140
+ if (isFlatNocvilleEntry(entry))
141
+ return entry.command;
142
+ if (isGroup(entry)) {
143
+ const hook = entry.hooks.find(isCommandHookWithMarker);
144
+ if (hook)
145
+ return hook.command;
146
+ }
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+ /** Extract a `--flag` value from a command string; supports `=`, quotes, or the space form. */
152
+ export function extractFlag(command, flag) {
153
+ const re = new RegExp(`--${flag}(?:=|\\s+)('([^']*)'|"([^"]*)"|([^\\s]+))`);
154
+ const m = command.match(re);
155
+ if (!m)
156
+ return undefined;
157
+ return m[2] ?? m[3] ?? m[4];
158
+ }
159
+ /** POSIX single-quote a string so /bin/sh treats it as one literal token. */
160
+ export function shellQuote(s) {
161
+ return `'${s.replace(/'/g, `'\\''`)}'`;
162
+ }
163
+ /**
164
+ * Absolute hook command: `'<node>' '<cli.js>' <agent> hook # nocville-npc-hook`.
165
+ *
166
+ * Agents run hooks via `/bin/sh` without the user's interactive PATH (nvm /
167
+ * homebrew shims are absent), so a bare `nocville-npc` would fail; absolute
168
+ * paths fix that. No API key on the command line — the hook reads the saved
169
+ * user config (~/.config/nocville/config.json).
170
+ */
171
+ export function buildAgentHookCommand(cliPath, agent, extraArgs = [], nodePath = stableNodePath()) {
172
+ const args = extraArgs.length ? ' ' + extraArgs.join(' ') : '';
173
+ return `${shellQuote(nodePath)} ${shellQuote(cliPath)} ${agent} hook${args} ${HOOK_MARKER_COMMENT}`;
174
+ }
175
+ /**
176
+ * The node binary to bake into hook commands. `process.execPath` is often a
177
+ * versioned path (e.g. Homebrew's /opt/homebrew/Cellar/node/24.4.1/bin/node)
178
+ * that disappears on upgrade, so prefer an unversioned PATH entry (e.g.
179
+ * /opt/homebrew/bin/node) that resolves to the same binary.
180
+ */
181
+ export function stableNodePath(execPath = process.execPath, pathEnv = process.env['PATH'] ?? '') {
182
+ let target;
183
+ try {
184
+ target = realpathSync(execPath);
185
+ }
186
+ catch {
187
+ return execPath;
188
+ }
189
+ const exe = process.platform === 'win32' ? 'node.exe' : 'node';
190
+ for (const dir of pathEnv.split(delimiter)) {
191
+ if (!dir || /[\\/]Cellar[\\/]/.test(dir))
192
+ continue;
193
+ const candidate = join(dir, exe);
194
+ try {
195
+ if (candidate !== execPath && realpathSync(candidate) === target)
196
+ return candidate;
197
+ }
198
+ catch {
199
+ // not here
200
+ }
201
+ }
202
+ return execPath;
203
+ }
204
+ /** Realpath the running CLI (follows npm bin symlinks). */
205
+ export function resolveCliPath(explicit) {
206
+ const candidate = explicit ?? process.argv[1];
207
+ if (candidate) {
208
+ try {
209
+ return realpathSync(candidate);
210
+ }
211
+ catch {
212
+ return candidate;
213
+ }
214
+ }
215
+ return 'nocville-npc';
216
+ }
217
+ /** Read a JSON hooks/settings file; missing → {}; unparsable → null (refuse to overwrite). */
218
+ export function readJsonFile(path) {
219
+ if (!existsSync(path))
220
+ return {};
221
+ try {
222
+ const raw = readFileSync(path, 'utf-8');
223
+ if (!raw.trim())
224
+ return {};
225
+ const parsed = JSON.parse(raw);
226
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
227
+ return null;
228
+ return parsed;
229
+ }
230
+ catch {
231
+ return null;
232
+ }
233
+ }
234
+ /** Write JSON, keeping a one-time `.nocville-backup` of the original file. */
235
+ export function writeJsonFile(path, data) {
236
+ const backup = `${path}.nocville-backup`;
237
+ if (existsSync(path) && !existsSync(backup)) {
238
+ try {
239
+ copyFileSync(path, backup);
240
+ }
241
+ catch {
242
+ // best effort
243
+ }
244
+ }
245
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8');
246
+ }
247
+ export function buildShimHookCommand(agent, platform = process.platform) {
248
+ if (platform === 'win32') {
249
+ const shim = `${SHIM_PATH()}.cmd`;
250
+ if (agent === 'claude-code') {
251
+ // installHooksInto sets shell: powershell, bypassing Git Bash detection.
252
+ return `& '${shim.replace(/['‘’‚‛]/g, '$&$&')}' ${agent} hook ${HOOK_MARKER_COMMENT}`;
253
+ }
254
+ const payload = `""${shim}" ${agent} hook"`;
255
+ if (agent === 'gemini') {
256
+ // Gemini uses PowerShell (7 or 5.1). Stop native-argument parsing so cmd
257
+ // receives its quotes intact. The newline ends --% before Gemini appends
258
+ // its own PowerShell exit-code check. No encoded command or policy bypass.
259
+ return `cmd.exe /d /s /c --% ${payload}\n`;
260
+ }
261
+ return `cmd.exe /d /s /c ${payload}`;
262
+ }
263
+ return `${shellQuote(SHIM_PATH())} ${agent} hook ${HOOK_MARKER_COMMENT}`;
264
+ }
265
+ //# sourceMappingURL=hook-settings.js.map
@@ -0,0 +1,12 @@
1
+ import type { AgentAdapter, AgentSessionState } from './session.js';
2
+ export interface AgentProc {
3
+ pid: number;
4
+ startedAt?: number;
5
+ }
6
+ export declare function isAgentAlive(proc: AgentProc): boolean;
7
+ export declare function claudePidFromStore(sessionId: string, dir?: string): AgentProc | null;
8
+ export declare function ancestorPid(names: readonly string[], start?: number, nodeCommandPattern?: RegExp): AgentProc | null;
9
+ export declare function resolveAgentPid(adapter: AgentAdapter, sessionId: string): AgentProc | null;
10
+ /** Reserve under the state lock so simultaneous hooks cannot duplicate PID probes. */
11
+ export declare function reserveAgentPidAttempt(state: AgentSessionState, now: number): boolean;
12
+ //# sourceMappingURL=liveness.d.ts.map
@@ -0,0 +1,105 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ function startTime(pid, timeout = 1000) {
6
+ if (process.platform === 'win32')
7
+ return undefined;
8
+ const out = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
9
+ encoding: 'utf8',
10
+ timeout,
11
+ });
12
+ const value = Date.parse(out.stdout?.trim() ?? '');
13
+ return Number.isFinite(value) ? value : undefined;
14
+ }
15
+ export function isAgentAlive(proc) {
16
+ if (!Number.isSafeInteger(proc.pid) || proc.pid <= 0)
17
+ return false;
18
+ try {
19
+ process.kill(proc.pid, 0);
20
+ }
21
+ catch (error) {
22
+ return error.code === 'EPERM';
23
+ }
24
+ if (!proc.startedAt || process.platform === 'win32')
25
+ return true;
26
+ const actual = startTime(proc.pid);
27
+ return actual === undefined || Math.abs(actual - proc.startedAt) < 120_000;
28
+ }
29
+ export function claudePidFromStore(sessionId, dir = join(homedir(), '.claude', 'sessions')) {
30
+ try {
31
+ for (const file of readdirSync(dir)) {
32
+ if (!/^\d+\.json$/.test(file))
33
+ continue;
34
+ try {
35
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
36
+ if (data.sessionId !== sessionId || !Number.isSafeInteger(data.pid) || data.pid <= 0)
37
+ continue;
38
+ const proc = {
39
+ pid: data.pid,
40
+ startedAt: typeof data.startedAt === 'number' ? data.startedAt : undefined,
41
+ };
42
+ if (isAgentAlive(proc))
43
+ return proc;
44
+ }
45
+ catch {
46
+ /* incomplete or old sidecar */
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ /* no session store */
52
+ }
53
+ return null;
54
+ }
55
+ export function ancestorPid(names, start = process.ppid, nodeCommandPattern) {
56
+ if (process.platform === 'win32')
57
+ return null;
58
+ const deadline = Date.now() + 750;
59
+ let pid = start;
60
+ for (let hop = 0; hop < 5 && pid > 1 && Date.now() < deadline; hop++) {
61
+ const out = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], {
62
+ encoding: 'utf8',
63
+ timeout: Math.max(1, deadline - Date.now()),
64
+ });
65
+ const match = out.stdout?.trim().match(/^(\d+)\s+(.+)$/);
66
+ if (!match)
67
+ return null;
68
+ const command = basename(match[2]);
69
+ if (names.includes(command)) {
70
+ if (command !== 'node')
71
+ return { pid, startedAt: startTime(pid, Math.max(1, deadline - Date.now())) };
72
+ if (nodeCommandPattern) {
73
+ const argv = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
74
+ encoding: 'utf8',
75
+ timeout: Math.max(1, deadline - Date.now()),
76
+ }).stdout?.trim();
77
+ if (argv && nodeCommandPattern.test(argv))
78
+ return { pid, startedAt: startTime(pid, Math.max(1, deadline - Date.now())) };
79
+ }
80
+ }
81
+ const parent = Number(match[1]);
82
+ if (parent === pid)
83
+ return null;
84
+ pid = parent;
85
+ }
86
+ return null;
87
+ }
88
+ export function resolveAgentPid(adapter, sessionId) {
89
+ if (adapter.processNames?.length === 0)
90
+ return null;
91
+ return ((adapter.family === 'claude' ? claudePidFromStore(sessionId) : null) ??
92
+ ancestorPid(adapter.processNames ?? [adapter.command], process.ppid, adapter.nodeCommandPattern));
93
+ }
94
+ /** Reserve under the state lock so simultaneous hooks cannot duplicate PID probes. */
95
+ export function reserveAgentPidAttempt(state, now) {
96
+ if (state.agentPid ||
97
+ state.offline ||
98
+ (state.agentPidResolveAttempts ?? 0) >= 5 ||
99
+ (state.agentPidLastResolveAt !== undefined && now - state.agentPidLastResolveAt < 60_000))
100
+ return false;
101
+ state.agentPidResolveAttempts = (state.agentPidResolveAttempts ?? 0) + 1;
102
+ state.agentPidLastResolveAt = now;
103
+ return true;
104
+ }
105
+ //# sourceMappingURL=liveness.js.map
@@ -0,0 +1,6 @@
1
+ export declare const LOCK_STALE_MS = 5000;
2
+ export declare const LOCK_WAIT_MS = 2000;
3
+ export declare const LOCK_HARD_STALE_MS = 30000;
4
+ /** Exclusive cross-process lock. A timeout skips the hook; it never permits an unlocked write. */
5
+ export declare function withStateLock<T>(path: string, fn: () => T | Promise<T>, waitMs?: number): Promise<T>;
6
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ export const LOCK_STALE_MS = 5000;
5
+ export const LOCK_WAIT_MS = 2000;
6
+ export const LOCK_HARD_STALE_MS = 30_000;
7
+ function alive(pid) {
8
+ if (!Number.isSafeInteger(pid) || pid <= 0)
9
+ return false;
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ }
14
+ catch (error) {
15
+ return error.code === 'EPERM';
16
+ }
17
+ }
18
+ /** Exclusive cross-process lock. A timeout skips the hook; it never permits an unlocked write. */
19
+ export async function withStateLock(path, fn, waitMs = LOCK_WAIT_MS) {
20
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
21
+ const lock = `${path}.lock`;
22
+ const token = `${process.pid}:${randomUUID()}`;
23
+ const deadline = Date.now() + waitMs;
24
+ for (;;) {
25
+ try {
26
+ const fd = openSync(lock, 'wx', 0o600);
27
+ try {
28
+ writeSync(fd, token);
29
+ }
30
+ finally {
31
+ closeSync(fd);
32
+ }
33
+ break;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'EEXIST')
37
+ throw error;
38
+ // Serialize stale reclamation so two waiters cannot unlink each other's new lock.
39
+ let reaper;
40
+ try {
41
+ const reapPath = `${lock}.reap`;
42
+ try {
43
+ const oldToken = readFileSync(reapPath, 'utf8');
44
+ if (Date.now() - statSync(reapPath).mtimeMs > LOCK_STALE_MS &&
45
+ readFileSync(reapPath, 'utf8') === oldToken)
46
+ unlinkSync(reapPath);
47
+ }
48
+ catch {
49
+ /* no stale reaper */
50
+ }
51
+ reaper = openSync(reapPath, 'wx', 0o600);
52
+ writeSync(reaper, token);
53
+ const owner = readFileSync(lock, 'utf8');
54
+ const age = Date.now() - statSync(lock).mtimeMs;
55
+ // PID reuse cannot block forever. State mutations are synchronous; the separate
56
+ // delivery lock bounds its HTTP request well below this hard ceiling.
57
+ if (age > LOCK_HARD_STALE_MS ||
58
+ (age > LOCK_STALE_MS && !alive(Number(owner.split(':')[0]))))
59
+ unlinkSync(lock);
60
+ }
61
+ catch {
62
+ /* owner released, or another waiter is reclaiming it */
63
+ }
64
+ finally {
65
+ if (reaper !== undefined) {
66
+ closeSync(reaper);
67
+ try {
68
+ if (readFileSync(`${lock}.reap`, 'utf8') === token)
69
+ unlinkSync(`${lock}.reap`);
70
+ }
71
+ catch {
72
+ /* stale reaper already removed */
73
+ }
74
+ }
75
+ }
76
+ if (Date.now() >= deadline)
77
+ throw new Error('Session state lock timed out');
78
+ await new Promise((resolve) => setTimeout(resolve, 10 + Math.random() * 15));
79
+ }
80
+ }
81
+ try {
82
+ return await fn();
83
+ }
84
+ finally {
85
+ // Never unlink a replacement lock owned by another writer.
86
+ try {
87
+ if (readFileSync(lock, 'utf8') === token)
88
+ unlinkSync(lock);
89
+ }
90
+ catch {
91
+ /* already gone */
92
+ }
93
+ }
94
+ }
95
+ //# sourceMappingURL=lock.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Hooks must point at a cli.js that will still exist tomorrow. When setup runs
3
+ * from an ephemeral location — `npx @nocville/npc …` executes out of the npm
4
+ * cache (`~/.npm/_npx/<hash>/…`), which npm may prune at any time — we copy
5
+ * the package (dist + its `@nocville/node` dependency) into
6
+ * `~/.config/nocville/runtime/<version>/` and point hooks there instead.
7
+ */
8
+ /** True when the CLI is running from npm's npx cache (or another temp dir). */
9
+ export declare function isEphemeralCliPath(cliPath: string): boolean;
10
+ /**
11
+ * Always copy a valid installed runtime, including global installs removed by nvm uninstall.
12
+ */
13
+ export declare function ensureStableCliPath(cliPath: string, _force?: boolean): string;
14
+ /** Prune only old snapshots, never the active pointer target or unknown directories. */
15
+ export declare function pruneOldRuntimes(runtimeDir: string, now?: number): number;
16
+ //# sourceMappingURL=runtime.d.ts.map
@@ -0,0 +1,149 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join, sep, resolve } from 'node:path';
5
+ import { userConfigPath } from '../../core/user-config.js';
6
+ /**
7
+ * Hooks must point at a cli.js that will still exist tomorrow. When setup runs
8
+ * from an ephemeral location — `npx @nocville/npc …` executes out of the npm
9
+ * cache (`~/.npm/_npx/<hash>/…`), which npm may prune at any time — we copy
10
+ * the package (dist + its `@nocville/node` dependency) into
11
+ * `~/.config/nocville/runtime/<version>/` and point hooks there instead.
12
+ */
13
+ /** True when the CLI is running from npm's npx cache (or another temp dir). */
14
+ export function isEphemeralCliPath(cliPath) {
15
+ const p = cliPath.split(sep).join('/');
16
+ return p.includes('/_npx/') || p.includes('/.pnpm-store/') || p.includes('/dlx-');
17
+ }
18
+ function findPackageRoot(start, name) {
19
+ let dir = start;
20
+ for (let i = 0; i < 8; i++) {
21
+ const pkg = join(dir, 'package.json');
22
+ if (existsSync(pkg)) {
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(pkg, 'utf-8'));
25
+ if (parsed.name === name)
26
+ return dir;
27
+ }
28
+ catch {
29
+ // keep walking
30
+ }
31
+ }
32
+ const parent = dirname(dir);
33
+ if (parent === dir)
34
+ break;
35
+ dir = parent;
36
+ }
37
+ return null;
38
+ }
39
+ function readVersion(root) {
40
+ try {
41
+ const parsed = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
42
+ return parsed.version ?? '0.0.0';
43
+ }
44
+ catch {
45
+ return '0.0.0';
46
+ }
47
+ }
48
+ /**
49
+ * Always copy a valid installed runtime, including global installs removed by nvm uninstall.
50
+ */
51
+ export function ensureStableCliPath(cliPath, _force = true) {
52
+ const pkgRoot = findPackageRoot(dirname(cliPath), '@nocville/npc');
53
+ if (!pkgRoot)
54
+ return cliPath;
55
+ let sdkRoot = null;
56
+ try {
57
+ const req = createRequire(cliPath);
58
+ sdkRoot = findPackageRoot(dirname(req.resolve('@nocville/node')), '@nocville/node');
59
+ }
60
+ catch {
61
+ sdkRoot = null;
62
+ }
63
+ if (!sdkRoot)
64
+ return cliPath;
65
+ const req = createRequire(cliPath);
66
+ const pngRoot = findPackageRoot(dirname(req.resolve('pngjs')), 'pngjs');
67
+ if (!pngRoot)
68
+ throw new Error('Cannot install hook runtime: pngjs is missing');
69
+ const version = readVersion(pkgRoot);
70
+ const hash = createHash('sha256');
71
+ const hashTree = (root) => {
72
+ for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
73
+ hash.update(entry.name);
74
+ const path = join(root, entry.name);
75
+ if (entry.isDirectory())
76
+ hashTree(path);
77
+ else if (entry.isFile())
78
+ hash.update(readFileSync(path));
79
+ }
80
+ };
81
+ for (const root of [pkgRoot, sdkRoot]) {
82
+ hash.update(readFileSync(join(root, 'package.json')));
83
+ hashTree(join(root, 'dist'));
84
+ }
85
+ hash.update(readFileSync(join(pngRoot, 'package.json')));
86
+ hashTree(join(pngRoot, 'lib'));
87
+ // Immutable snapshots also support multiple development builds of one version.
88
+ // Never remove files a running watchdog or the current pointer may still use.
89
+ const dest = join(dirname(userConfigPath()), 'runtime', `${version}-${hash.digest('hex').slice(0, 16)}`);
90
+ if (existsSync(join(dest, 'dist', 'cli.js')))
91
+ return join(dest, 'dist', 'cli.js');
92
+ const stage = `${dest}.${randomUUID()}.tmp`;
93
+ try {
94
+ mkdirSync(stage, { recursive: true, mode: 0o700 });
95
+ cpSync(join(pkgRoot, 'package.json'), join(stage, 'package.json'));
96
+ cpSync(join(pkgRoot, 'dist'), join(stage, 'dist'), { recursive: true });
97
+ const sdkDest = join(stage, 'node_modules', '@nocville', 'node');
98
+ mkdirSync(sdkDest, { recursive: true });
99
+ cpSync(join(sdkRoot, 'package.json'), join(sdkDest, 'package.json'));
100
+ cpSync(join(sdkRoot, 'dist'), join(sdkDest, 'dist'), { recursive: true });
101
+ const pngDest = join(stage, 'node_modules', 'pngjs');
102
+ mkdirSync(pngDest, { recursive: true });
103
+ cpSync(join(pngRoot, 'package.json'), join(pngDest, 'package.json'));
104
+ cpSync(join(pngRoot, 'lib'), join(pngDest, 'lib'), { recursive: true });
105
+ try {
106
+ renameSync(stage, dest);
107
+ }
108
+ catch (error) {
109
+ if (!existsSync(join(dest, 'dist', 'cli.js')))
110
+ throw error;
111
+ }
112
+ return join(dest, 'dist', 'cli.js');
113
+ }
114
+ finally {
115
+ rmSync(stage, { recursive: true, force: true });
116
+ }
117
+ }
118
+ /** Prune only old snapshots, never the active pointer target or unknown directories. */
119
+ export function pruneOldRuntimes(runtimeDir, now = Date.now()) {
120
+ let active;
121
+ try {
122
+ const pointer = JSON.parse(readFileSync(join(runtimeDir, 'current.json'), 'utf8'));
123
+ if (typeof pointer.cli !== 'string')
124
+ return 0;
125
+ active = resolve(pointer.cli);
126
+ }
127
+ catch {
128
+ return 0;
129
+ }
130
+ let removed = 0;
131
+ for (const entry of readdirSync(runtimeDir, { withFileTypes: true })) {
132
+ if (!entry.isDirectory() || !/^\d[^/]*-[a-f0-9]{16}$/.test(entry.name))
133
+ continue;
134
+ const path = resolve(runtimeDir, entry.name);
135
+ if (active === path || active.startsWith(path + sep))
136
+ continue;
137
+ try {
138
+ if (now - statSync(path).mtimeMs <= 7 * 86_400_000)
139
+ continue;
140
+ rmSync(path, { recursive: true, force: true });
141
+ removed++;
142
+ }
143
+ catch {
144
+ /* best-effort maintenance must not break setup */
145
+ }
146
+ }
147
+ return removed;
148
+ }
149
+ //# sourceMappingURL=runtime.js.map