@nocville/npc 0.0.0-stage → 0.2.1

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 (213) hide show
  1. package/README.md +268 -2
  2. package/dist/cjs/cli.js +584 -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/session-filter.js +150 -0
  11. package/dist/cjs/core/text.js +15 -0
  12. package/dist/cjs/core/thresholds.js +48 -0
  13. package/dist/cjs/core/types.js +10 -0
  14. package/dist/cjs/core/user-config.js +92 -0
  15. package/dist/cjs/doctor.js +130 -0
  16. package/dist/cjs/index.js +95 -0
  17. package/dist/cjs/npcs/agent-common/activity.js +291 -0
  18. package/dist/cjs/npcs/agent-common/git-stats.js +71 -0
  19. package/dist/cjs/npcs/agent-common/hook-runner.js +252 -0
  20. package/dist/cjs/npcs/agent-common/hook-settings.js +279 -0
  21. package/dist/cjs/npcs/agent-common/liveness.js +112 -0
  22. package/dist/cjs/npcs/agent-common/lock.js +99 -0
  23. package/dist/cjs/npcs/agent-common/runtime.js +154 -0
  24. package/dist/cjs/npcs/agent-common/session.js +412 -0
  25. package/dist/cjs/npcs/agent-common/shim.js +96 -0
  26. package/dist/cjs/npcs/agent-common/watchdog.js +149 -0
  27. package/dist/cjs/npcs/claude-code/defaults.js +20 -0
  28. package/dist/cjs/npcs/claude-code/hook-handler.js +139 -0
  29. package/dist/cjs/npcs/claude-code/index.js +104 -0
  30. package/dist/cjs/npcs/claude-code/setup.js +133 -0
  31. package/dist/cjs/npcs/claude-code/state-machine.js +130 -0
  32. package/dist/cjs/npcs/codex/defaults.js +32 -0
  33. package/dist/cjs/npcs/codex/hook-handler.js +116 -0
  34. package/dist/cjs/npcs/codex/index.js +92 -0
  35. package/dist/cjs/npcs/codex/rollout.js +246 -0
  36. package/dist/cjs/npcs/codex/setup.js +104 -0
  37. package/dist/cjs/npcs/codex/watcher.js +361 -0
  38. package/dist/cjs/npcs/cursor/defaults.js +15 -0
  39. package/dist/cjs/npcs/cursor/hook-handler.js +65 -0
  40. package/dist/cjs/npcs/cursor/index.js +72 -0
  41. package/dist/cjs/npcs/cursor/normalize.js +54 -0
  42. package/dist/cjs/npcs/cursor/setup.js +98 -0
  43. package/dist/cjs/npcs/gemini/defaults.js +15 -0
  44. package/dist/cjs/npcs/gemini/hook-handler.js +93 -0
  45. package/dist/cjs/npcs/gemini/index.js +69 -0
  46. package/dist/cjs/npcs/gemini/setup.js +98 -0
  47. package/dist/cjs/npcs/github-watcher/defaults.js +11 -0
  48. package/dist/cjs/npcs/github-watcher/format.js +21 -0
  49. package/dist/cjs/npcs/github-watcher/github-api.js +126 -0
  50. package/dist/cjs/npcs/github-watcher/index.js +148 -0
  51. package/dist/cjs/npcs/internet-connectivity/checks.js +92 -0
  52. package/dist/cjs/npcs/internet-connectivity/defaults.js +22 -0
  53. package/dist/cjs/npcs/internet-connectivity/index.js +251 -0
  54. package/dist/cjs/npcs/log-watcher/buffer.js +40 -0
  55. package/dist/cjs/npcs/log-watcher/defaults.js +12 -0
  56. package/dist/cjs/npcs/log-watcher/index.js +161 -0
  57. package/dist/cjs/npcs/log-watcher/tail.js +81 -0
  58. package/dist/cjs/npcs/process-watcher/defaults.js +9 -0
  59. package/dist/cjs/npcs/process-watcher/index.js +140 -0
  60. package/dist/cjs/npcs/process-watcher/process-scanner.js +114 -0
  61. package/dist/cjs/npcs/receptionist/defaults.js +9 -0
  62. package/dist/cjs/npcs/receptionist/index.js +120 -0
  63. package/dist/cjs/npcs/receptionist/weather.js +129 -0
  64. package/dist/cjs/npcs/system-monitor/collectors.js +68 -0
  65. package/dist/cjs/npcs/system-monitor/defaults.js +13 -0
  66. package/dist/cjs/npcs/system-monitor/index.js +105 -0
  67. package/dist/cjs/npcs/web-page-watcher/basic-mode.js +58 -0
  68. package/dist/cjs/npcs/web-page-watcher/capture.js +80 -0
  69. package/dist/cjs/npcs/web-page-watcher/defaults.js +31 -0
  70. package/dist/cjs/npcs/web-page-watcher/index.js +234 -0
  71. package/dist/cjs/npcs/web-page-watcher/pixelate.js +181 -0
  72. package/dist/cjs/package.json +1 -0
  73. package/dist/cli.d.ts +3 -0
  74. package/dist/cli.js +549 -0
  75. package/dist/core/api-key.d.ts +34 -0
  76. package/dist/core/api-key.js +64 -0
  77. package/dist/core/base-npc.d.ts +41 -0
  78. package/dist/core/base-npc.js +156 -0
  79. package/dist/core/config.d.ts +22 -0
  80. package/dist/core/config.js +101 -0
  81. package/dist/core/event-npc.d.ts +8 -0
  82. package/dist/core/event-npc.js +10 -0
  83. package/dist/core/logger.d.ts +11 -0
  84. package/dist/core/logger.js +35 -0
  85. package/dist/core/polling-npc.d.ts +18 -0
  86. package/dist/core/polling-npc.js +61 -0
  87. package/dist/core/screen.d.ts +11 -0
  88. package/dist/core/screen.js +24 -0
  89. package/dist/core/session-filter.d.ts +59 -0
  90. package/dist/core/session-filter.js +143 -0
  91. package/dist/core/text.d.ts +6 -0
  92. package/dist/core/text.js +12 -0
  93. package/dist/core/thresholds.d.ts +10 -0
  94. package/dist/core/thresholds.js +44 -0
  95. package/dist/core/types.d.ts +60 -0
  96. package/dist/core/types.js +7 -0
  97. package/dist/core/user-config.d.ts +43 -0
  98. package/dist/core/user-config.js +85 -0
  99. package/dist/doctor.d.ts +8 -0
  100. package/dist/doctor.js +126 -0
  101. package/dist/index.d.ts +41 -0
  102. package/dist/index.js +26 -0
  103. package/dist/npcs/agent-common/activity.d.ts +42 -0
  104. package/dist/npcs/agent-common/activity.js +280 -0
  105. package/dist/npcs/agent-common/git-stats.d.ts +11 -0
  106. package/dist/npcs/agent-common/git-stats.js +67 -0
  107. package/dist/npcs/agent-common/hook-runner.d.ts +62 -0
  108. package/dist/npcs/agent-common/hook-runner.js +245 -0
  109. package/dist/npcs/agent-common/hook-settings.d.ts +75 -0
  110. package/dist/npcs/agent-common/hook-settings.js +265 -0
  111. package/dist/npcs/agent-common/liveness.d.ts +12 -0
  112. package/dist/npcs/agent-common/liveness.js +105 -0
  113. package/dist/npcs/agent-common/lock.d.ts +6 -0
  114. package/dist/npcs/agent-common/lock.js +95 -0
  115. package/dist/npcs/agent-common/runtime.d.ts +16 -0
  116. package/dist/npcs/agent-common/runtime.js +149 -0
  117. package/dist/npcs/agent-common/session.d.ts +192 -0
  118. package/dist/npcs/agent-common/session.js +390 -0
  119. package/dist/npcs/agent-common/shim.d.ts +4 -0
  120. package/dist/npcs/agent-common/shim.js +90 -0
  121. package/dist/npcs/agent-common/watchdog.d.ts +16 -0
  122. package/dist/npcs/agent-common/watchdog.js +144 -0
  123. package/dist/npcs/claude-code/defaults.d.ts +15 -0
  124. package/dist/npcs/claude-code/defaults.js +17 -0
  125. package/dist/npcs/claude-code/hook-handler.d.ts +40 -0
  126. package/dist/npcs/claude-code/hook-handler.js +131 -0
  127. package/dist/npcs/claude-code/index.d.ts +36 -0
  128. package/dist/npcs/claude-code/index.js +92 -0
  129. package/dist/npcs/claude-code/setup.d.ts +58 -0
  130. package/dist/npcs/claude-code/setup.js +121 -0
  131. package/dist/npcs/claude-code/state-machine.d.ts +28 -0
  132. package/dist/npcs/claude-code/state-machine.js +125 -0
  133. package/dist/npcs/codex/defaults.d.ts +24 -0
  134. package/dist/npcs/codex/defaults.js +28 -0
  135. package/dist/npcs/codex/hook-handler.d.ts +24 -0
  136. package/dist/npcs/codex/hook-handler.js +110 -0
  137. package/dist/npcs/codex/index.d.ts +21 -0
  138. package/dist/npcs/codex/index.js +76 -0
  139. package/dist/npcs/codex/rollout.d.ts +39 -0
  140. package/dist/npcs/codex/rollout.js +242 -0
  141. package/dist/npcs/codex/setup.d.ts +37 -0
  142. package/dist/npcs/codex/setup.js +96 -0
  143. package/dist/npcs/codex/watcher.d.ts +65 -0
  144. package/dist/npcs/codex/watcher.js +356 -0
  145. package/dist/npcs/cursor/defaults.d.ts +9 -0
  146. package/dist/npcs/cursor/defaults.js +11 -0
  147. package/dist/npcs/cursor/hook-handler.d.ts +8 -0
  148. package/dist/npcs/cursor/hook-handler.js +60 -0
  149. package/dist/npcs/cursor/index.d.ts +18 -0
  150. package/dist/npcs/cursor/index.js +57 -0
  151. package/dist/npcs/cursor/normalize.d.ts +6 -0
  152. package/dist/npcs/cursor/normalize.js +49 -0
  153. package/dist/npcs/cursor/setup.d.ts +29 -0
  154. package/dist/npcs/cursor/setup.js +90 -0
  155. package/dist/npcs/gemini/defaults.d.ts +9 -0
  156. package/dist/npcs/gemini/defaults.js +11 -0
  157. package/dist/npcs/gemini/hook-handler.d.ts +10 -0
  158. package/dist/npcs/gemini/hook-handler.js +87 -0
  159. package/dist/npcs/gemini/index.d.ts +17 -0
  160. package/dist/npcs/gemini/index.js +56 -0
  161. package/dist/npcs/gemini/setup.d.ts +29 -0
  162. package/dist/npcs/gemini/setup.js +90 -0
  163. package/dist/npcs/github-watcher/defaults.d.ts +8 -0
  164. package/dist/npcs/github-watcher/defaults.js +8 -0
  165. package/dist/npcs/github-watcher/format.d.ts +3 -0
  166. package/dist/npcs/github-watcher/format.js +18 -0
  167. package/dist/npcs/github-watcher/github-api.d.ts +55 -0
  168. package/dist/npcs/github-watcher/github-api.js +120 -0
  169. package/dist/npcs/github-watcher/index.d.ts +35 -0
  170. package/dist/npcs/github-watcher/index.js +144 -0
  171. package/dist/npcs/internet-connectivity/checks.d.ts +24 -0
  172. package/dist/npcs/internet-connectivity/checks.js +84 -0
  173. package/dist/npcs/internet-connectivity/defaults.d.ts +20 -0
  174. package/dist/npcs/internet-connectivity/defaults.js +19 -0
  175. package/dist/npcs/internet-connectivity/index.d.ts +55 -0
  176. package/dist/npcs/internet-connectivity/index.js +246 -0
  177. package/dist/npcs/log-watcher/buffer.d.ts +18 -0
  178. package/dist/npcs/log-watcher/buffer.js +34 -0
  179. package/dist/npcs/log-watcher/defaults.d.ts +9 -0
  180. package/dist/npcs/log-watcher/defaults.js +9 -0
  181. package/dist/npcs/log-watcher/index.d.ts +45 -0
  182. package/dist/npcs/log-watcher/index.js +157 -0
  183. package/dist/npcs/log-watcher/tail.d.ts +9 -0
  184. package/dist/npcs/log-watcher/tail.js +78 -0
  185. package/dist/npcs/process-watcher/defaults.d.ts +6 -0
  186. package/dist/npcs/process-watcher/defaults.js +6 -0
  187. package/dist/npcs/process-watcher/index.d.ts +26 -0
  188. package/dist/npcs/process-watcher/index.js +136 -0
  189. package/dist/npcs/process-watcher/process-scanner.d.ts +19 -0
  190. package/dist/npcs/process-watcher/process-scanner.js +110 -0
  191. package/dist/npcs/receptionist/defaults.d.ts +6 -0
  192. package/dist/npcs/receptionist/defaults.js +6 -0
  193. package/dist/npcs/receptionist/index.d.ts +29 -0
  194. package/dist/npcs/receptionist/index.js +116 -0
  195. package/dist/npcs/receptionist/weather.d.ts +37 -0
  196. package/dist/npcs/receptionist/weather.js +121 -0
  197. package/dist/npcs/system-monitor/collectors.d.ts +45 -0
  198. package/dist/npcs/system-monitor/collectors.js +60 -0
  199. package/dist/npcs/system-monitor/defaults.d.ts +7 -0
  200. package/dist/npcs/system-monitor/defaults.js +10 -0
  201. package/dist/npcs/system-monitor/index.d.ts +27 -0
  202. package/dist/npcs/system-monitor/index.js +101 -0
  203. package/dist/npcs/web-page-watcher/basic-mode.d.ts +16 -0
  204. package/dist/npcs/web-page-watcher/basic-mode.js +52 -0
  205. package/dist/npcs/web-page-watcher/capture.d.ts +49 -0
  206. package/dist/npcs/web-page-watcher/capture.js +75 -0
  207. package/dist/npcs/web-page-watcher/defaults.d.ts +19 -0
  208. package/dist/npcs/web-page-watcher/defaults.js +27 -0
  209. package/dist/npcs/web-page-watcher/index.d.ts +42 -0
  210. package/dist/npcs/web-page-watcher/index.js +230 -0
  211. package/dist/npcs/web-page-watcher/pixelate.d.ts +36 -0
  212. package/dist/npcs/web-page-watcher/pixelate.js +172 -0
  213. package/package.json +73 -3
@@ -0,0 +1,390 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { NocvilleClient } from '@nocville/node';
5
+ import { loadSessionFilter, sessionPublishDecision, } from '../../core/session-filter.js';
6
+ import { truncate } from '../../core/text.js';
7
+ import { withStateLock } from './lock.js';
8
+ /** Behavior hints + subStatus for a phase (shared by every agent NPC). */
9
+ export function phasePresentation(state, offline) {
10
+ if (offline)
11
+ return { subStatus: 'offline' };
12
+ switch (state) {
13
+ case 'WORKING':
14
+ // Head to a desk and sit while actively working.
15
+ return { subStatus: 'working', behavior: { activity: 'busy', busy: 'seek-desk' } };
16
+ case 'WAITING':
17
+ // Freeze and raise a hand until the human responds.
18
+ return {
19
+ subStatus: 'waiting',
20
+ behavior: { activity: 'waiting', waitingEmote: 'raised-hand' },
21
+ };
22
+ case 'IDLE':
23
+ default:
24
+ // Settle at the desk and doze off.
25
+ return {
26
+ subStatus: 'idle',
27
+ behavior: { activity: 'idle', idle: 'stay', idlePose: 'sleep' },
28
+ };
29
+ }
30
+ }
31
+ export const ACTIVITY_HISTORY = 8;
32
+ export const DEFAULT_MIN_PUSH_GAP_MS = 1500;
33
+ const STATE_PREFIX = 'session-';
34
+ // ---------------------------------------------------------------- naming
35
+ export function projectNameOf(cwd) {
36
+ const parts = cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/');
37
+ return parts[parts.length - 1] || 'unknown';
38
+ }
39
+ /** "Claude · nocville", clamped to the API's 32-char banner limit. */
40
+ export function agentDisplayName(label, subject) {
41
+ return truncate(`${label} · ${subject}`, 32);
42
+ }
43
+ /** Service ids are ≤100 chars of [A-Za-z0-9_.-]. */
44
+ export function sanitizeServiceId(raw) {
45
+ return raw
46
+ .replace(/[^A-Za-z0-9_.-]+/g, '-')
47
+ .replace(/-+/g, '-')
48
+ .slice(0, 100);
49
+ }
50
+ // ---------------------------------------------------------------- state file
51
+ export function stateFilePath(stateDir, sessionId) {
52
+ const safe = sessionId.replace(/[^A-Za-z0-9_.-]/g, '_');
53
+ return join(stateDir, `${STATE_PREFIX}${safe}.json`);
54
+ }
55
+ export function readSessionState(stateDir, sessionId) {
56
+ try {
57
+ const raw = JSON.parse(readFileSync(stateFilePath(stateDir, sessionId), 'utf-8'));
58
+ if (typeof raw['sessionId'] !== 'string')
59
+ return null;
60
+ return raw;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** Atomic write (temp file + rename) so concurrent readers never see half a file. */
67
+ export function writeSessionState(stateDir, state) {
68
+ mkdirSync(stateDir, { recursive: true });
69
+ const path = stateFilePath(stateDir, state.sessionId);
70
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
71
+ writeFileSync(tmp, JSON.stringify(state), 'utf-8');
72
+ renameSync(tmp, path);
73
+ }
74
+ export function deleteSessionState(stateDir, sessionId) {
75
+ const path = stateFilePath(stateDir, sessionId);
76
+ if (existsSync(path)) {
77
+ try {
78
+ rmSync(path);
79
+ }
80
+ catch {
81
+ // best effort
82
+ }
83
+ }
84
+ }
85
+ /** Remove state files older than `maxAgeMs`. Returns the number removed. */
86
+ export async function cleanupSessionStates(stateDir, maxAgeMs, now = Date.now()) {
87
+ if (!existsSync(stateDir))
88
+ return 0;
89
+ let removed = 0;
90
+ for (const file of readdirSync(stateDir)) {
91
+ if (!file.startsWith(STATE_PREFIX) || !file.endsWith('.json'))
92
+ continue;
93
+ const path = join(stateDir, file);
94
+ try {
95
+ if (now - statSync(path).mtimeMs <= maxAgeMs)
96
+ continue;
97
+ // Recheck under the same lock as hooks, so a SessionStart cannot be deleted mid-write.
98
+ await withStateLock(path, () => {
99
+ if (now - statSync(path).mtimeMs > maxAgeMs) {
100
+ unlinkSync(path);
101
+ removed++;
102
+ }
103
+ }, 0);
104
+ }
105
+ catch {
106
+ /* active writer, missing file or filesystem error: leave it alone */
107
+ }
108
+ }
109
+ return removed;
110
+ }
111
+ /** Cross-process hourly maintenance, invoked only on SessionStart. */
112
+ export async function maybeCleanupSessionStates(stateDir, now = Date.now()) {
113
+ const marker = join(stateDir, '.last-cleanup');
114
+ try {
115
+ return await withStateLock(marker, async () => {
116
+ let last = NaN;
117
+ try {
118
+ last = Number(readFileSync(marker, 'utf8'));
119
+ }
120
+ catch {
121
+ /* first cleanup */
122
+ }
123
+ if (Number.isFinite(last) && now - last < 3_600_000)
124
+ return 0;
125
+ const removed = await cleanupSessionStates(stateDir, 86_400_000, now);
126
+ writeFileSync(marker, String(now), { mode: 0o600 });
127
+ return removed;
128
+ }, 0);
129
+ }
130
+ catch {
131
+ return 0;
132
+ }
133
+ }
134
+ /** Prepend `line` to the history (deduping an identical head), capped. */
135
+ export function pushHistory(prev, line) {
136
+ const list = [...(prev ?? [])];
137
+ if (line && list[0] !== line)
138
+ list.unshift(line);
139
+ return list.slice(0, ACTIVITY_HISTORY);
140
+ }
141
+ /**
142
+ * Apply a transition to the previous state and decide when to push.
143
+ * Pure — exposed for testing.
144
+ */
145
+ export function applyTransition(prev, input, minGapMs = DEFAULT_MIN_PUSH_GAP_MS) {
146
+ const t = input.transition;
147
+ const history = pushHistory(prev?.activity, t.activity);
148
+ const prevHead = prev?.activity?.[0];
149
+ const subagents = pruneSubagents(prev?.subagents, input.now);
150
+ const changed = !prev ||
151
+ prev.state !== t.state ||
152
+ prev.status !== t.status ||
153
+ Object.keys(subagents).length !== Object.keys(prev.subagents ?? {}).length ||
154
+ history[0] !== prevHead ||
155
+ !!t.offline !== !!prev.offline ||
156
+ (input.name !== undefined && input.name !== prev.name);
157
+ const rev = (prev?.rev ?? 0) + (changed ? 1 : 0);
158
+ const next = {
159
+ ...prev,
160
+ sessionId: input.sessionId,
161
+ startedAt: prev?.startedAt ?? input.now,
162
+ serviceId: prev?.serviceId ?? input.serviceId,
163
+ state: t.state,
164
+ projectDir: input.projectDir,
165
+ lastEvent: input.eventName,
166
+ lastUpdate: input.now,
167
+ activity: history,
168
+ subagents,
169
+ name: input.name ?? prev?.name,
170
+ status: t.status,
171
+ mood: t.mood,
172
+ offline: t.offline || undefined,
173
+ rev,
174
+ pushedRev: prev?.pushedRev ?? 0,
175
+ lastPushAt: prev?.lastPushAt ?? 0,
176
+ flushAt: prev?.flushAt,
177
+ };
178
+ if (t.offline)
179
+ return { next, decision: { kind: 'now' } };
180
+ if (t.quiet || !changed)
181
+ return { next, decision: { kind: 'none' } };
182
+ const phaseChanged = !prev || prev.state !== t.state || !prev.lastPushAt;
183
+ const sinceLast = input.now - (next.lastPushAt ?? 0);
184
+ if (phaseChanged || sinceLast >= minGapMs) {
185
+ // Stamp the push time now: the push itself runs asynchronously (detached),
186
+ // and the next hook must already see it to coalesce.
187
+ next.lastPushAt = input.now;
188
+ return { next, decision: { kind: 'now' } };
189
+ }
190
+ // Coalesce: a flush is already pending (and not stale) → it will pick up this rev.
191
+ if (next.flushAt && next.flushAt > input.now - 2000)
192
+ return { next, decision: { kind: 'none' } };
193
+ const delayMs = Math.max(50, minGapMs - sinceLast);
194
+ next.flushAt = input.now + delayMs;
195
+ return { next, decision: { kind: 'defer', delayMs } };
196
+ }
197
+ // ---------------------------------------------------------------- push
198
+ /** Build the status push for a session (offline sentinel when the session ended). */
199
+ export function buildAgentPayload(adapter, state, config) {
200
+ const subject = state.name ?? projectNameOf(state.projectDir);
201
+ const name = config.name ? truncate(config.name, 32) : agentDisplayName(adapter.label, subject);
202
+ const current = state.activity?.[0] ?? '';
203
+ const { subStatus, behavior } = phasePresentation(state.state, state.offline);
204
+ if (state.offline) {
205
+ return {
206
+ status: 'unknown',
207
+ mood: 'idle',
208
+ name,
209
+ message: 'Session ended',
210
+ subStatus,
211
+ offline: true,
212
+ map: config.map,
213
+ };
214
+ }
215
+ const screenData = {
216
+ layout: 'ticker',
217
+ values: tickerLines({ ...state, subagents: pruneSubagents(state.subagents, Date.now()) }).map((a) => ({ label: '', value: a })),
218
+ };
219
+ return {
220
+ status: state.status ?? 'healthy',
221
+ mood: state.mood ?? 'happy',
222
+ name,
223
+ message: current ? truncate(current, 120) : undefined,
224
+ archetype: 'ai-agent',
225
+ metrics: { sessionStartedAt: state.startedAt ?? state.lastUpdate },
226
+ appearance: { family: adapter.family },
227
+ agentStats: state.agentStats,
228
+ subStatus,
229
+ behavior,
230
+ alertConfig: { heartbeatInterval: state.watchdogPid ? adapter.heartbeatSeconds : 3600 },
231
+ screenData,
232
+ companion: {
233
+ screenType: 'desk-sign',
234
+ size: { w: 3, h: 1 },
235
+ placement: 'on-desk',
236
+ label: truncate(projectNameOf(state.projectDir), 40),
237
+ },
238
+ map: config.map,
239
+ };
240
+ }
241
+ async function defaultPush(config, serviceId, payload, timeoutMs) {
242
+ const client = new NocvilleClient({
243
+ apiUrl: config.apiUrl,
244
+ apiKey: config.apiKey,
245
+ serviceId,
246
+ timeout: timeoutMs,
247
+ retries: 0,
248
+ debug: config.debug,
249
+ });
250
+ return client.pushStatus(payload);
251
+ }
252
+ /**
253
+ * Push the session's current state if it has unpushed changes (or is offline),
254
+ * then record the push. Re-reads the state right before writing so a hook that
255
+ * ran concurrently isn't clobbered. Never throws.
256
+ */
257
+ export async function flushSession(adapter, config, sessionId, deps = {}) {
258
+ const path = stateFilePath(adapter.stateDir, sessionId);
259
+ try {
260
+ // Serialize HTTP delivery separately: hooks can update state throughout a request,
261
+ // but an older delivery can never arrive after a newer/offline one.
262
+ return await withStateLock(`${path}.push`, async () => {
263
+ const state = readSessionState(adapter.stateDir, sessionId);
264
+ if (!state || !config.apiKey || state.offlineDeliveryStopped)
265
+ return false;
266
+ const rev = state.rev ?? 0;
267
+ const verdict = sessionPublishDecision(deps.sessionFilter ?? loadSessionFilter(), {
268
+ projectDir: state.projectDir,
269
+ name: state.name,
270
+ });
271
+ // Filtered out (sessions.json). Never shown: consume the revision so nothing retries.
272
+ // Already shown (e.g. renamed into the denylist): despawn it once, then go quiet.
273
+ // Checked on every push, so later edits to the file take effect without reinstalling.
274
+ if (!verdict.publish && !state.published) {
275
+ await withStateLock(path, () => {
276
+ const fresh = readSessionState(adapter.stateDir, sessionId);
277
+ if (!fresh)
278
+ return;
279
+ fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, fresh.rev ?? 0);
280
+ fresh.flushAt = undefined;
281
+ writeSessionState(adapter.stateDir, fresh);
282
+ }, deps.lockWaitMs);
283
+ return true;
284
+ }
285
+ const despawnFiltered = !verdict.publish;
286
+ if (!deps.force && rev <= (state.pushedRev ?? 0)) {
287
+ await withStateLock(path, () => {
288
+ const fresh = readSessionState(adapter.stateDir, sessionId);
289
+ if (fresh && (fresh.rev ?? 0) === rev) {
290
+ fresh.flushAt = undefined;
291
+ writeSessionState(adapter.stateDir, fresh);
292
+ }
293
+ }, deps.lockWaitMs);
294
+ return false;
295
+ }
296
+ const push = deps.push ??
297
+ ((sid, payload) => defaultPush(config, sid, payload, deps.timeoutMs ?? 4000));
298
+ let ok = false;
299
+ try {
300
+ const shown = despawnFiltered ? { ...state, offline: true } : state;
301
+ const result = await push(state.serviceId, buildAgentPayload(adapter, shown, config));
302
+ ok = result.success;
303
+ deps.onResult?.(result);
304
+ }
305
+ catch {
306
+ /* retry on next hook/watchdog tick */
307
+ }
308
+ await withStateLock(path, () => {
309
+ const fresh = readSessionState(adapter.stateDir, sessionId);
310
+ if (!fresh)
311
+ return;
312
+ // Retain terminal state until cleanup so late hooks/watchers cannot revive it.
313
+ if (ok) {
314
+ fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, despawnFiltered ? (fresh.rev ?? 0) : rev);
315
+ fresh.lastPushAt = Date.now();
316
+ fresh.published = !despawnFiltered && !state.offline;
317
+ }
318
+ if (!fresh.flushAt || fresh.flushAt <= Date.now())
319
+ fresh.flushAt = undefined;
320
+ writeSessionState(adapter.stateDir, fresh);
321
+ }, deps.lockWaitMs);
322
+ return ok;
323
+ }, deps.lockWaitMs ?? 6000);
324
+ }
325
+ catch {
326
+ return false;
327
+ }
328
+ }
329
+ /** Current parent activity, up to three subagents, then recent parent history. */
330
+ export function tickerLines(state) {
331
+ const history = state.activity ?? [];
332
+ const agents = Object.values(state.subagents ?? {})
333
+ .sort((a, b) => b.at - a.at)
334
+ .slice(0, 3)
335
+ .map((entry) => `↳ ${entry.type}: ${entry.activity}`);
336
+ return [...history.slice(0, 1), ...agents, ...history.slice(1)].slice(0, ACTIVITY_HISTORY);
337
+ }
338
+ export function applySubagentEvent(prev, agentId, type, event, activity, now) {
339
+ const subagents = pruneSubagents(prev.subagents, now);
340
+ if (event === 'Stop' || event === 'SubagentStop')
341
+ delete subagents[agentId];
342
+ else if (event === 'PreToolUse')
343
+ subagents[agentId] = { type, activity, at: now };
344
+ const next = { ...prev, subagents, lastUpdate: now, lastEvent: event };
345
+ if (JSON.stringify(tickerLines(prev)) !== JSON.stringify(tickerLines(next)))
346
+ next.rev = (prev.rev ?? 0) + 1;
347
+ return next;
348
+ }
349
+ /**
350
+ * Hand a flush to a detached background process so the hook can exit at once.
351
+ * The child inherits the resolved config via env (not argv, so the key never
352
+ * shows up in `ps`).
353
+ */
354
+ export function spawnDetachedFlush(adapter, config, sessionId, delayMs, cliPath = process.argv[1], stats = false) {
355
+ spawnDetachedAgent(adapter, config, ['flush', sessionId, `--delay=${Math.round(delayMs)}`, ...(stats ? ['--stats'] : [])], cliPath);
356
+ }
357
+ export function spawnDetachedAgent(adapter, config, args, cliPath = process.argv[1]) {
358
+ if (!cliPath)
359
+ return undefined;
360
+ const env = { ...process.env };
361
+ env['NOCVILLE_API_URL'] = config.apiUrl;
362
+ env['NOCVILLE_API_KEY'] = config.apiKey;
363
+ if (config.map)
364
+ env['NOCVILLE_MAP'] = config.map;
365
+ if (config.name)
366
+ env['NOCVILLE_NAME'] = config.name;
367
+ try {
368
+ const child = spawn(process.execPath, [cliPath, adapter.command, ...args], {
369
+ detached: true,
370
+ stdio: 'ignore',
371
+ env,
372
+ windowsHide: true,
373
+ });
374
+ child.on('error', () => {
375
+ /* swallow — never fail the agent */
376
+ });
377
+ child.unref();
378
+ return child.pid;
379
+ }
380
+ catch {
381
+ // swallow
382
+ }
383
+ }
384
+ export function sleep(ms) {
385
+ return new Promise((resolve) => setTimeout(resolve, ms));
386
+ }
387
+ function pruneSubagents(subagents, now) {
388
+ return Object.fromEntries(Object.entries(subagents ?? {}).filter(([, value]) => now - value.at < 600_000));
389
+ }
390
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,4 @@
1
+ export declare const SHIM_PATH: () => string;
2
+ export declare const RUNTIME_POINTER: () => string;
3
+ export declare function installHookShim(nodePath: string, cliPath: string): string;
4
+ //# sourceMappingURL=shim.d.ts.map
@@ -0,0 +1,90 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { userConfigPath } from '../../core/user-config.js';
4
+ import { pruneOldRuntimes } from './runtime.js';
5
+ export const SHIM_PATH = () => join(dirname(userConfigPath()), 'bin', 'nocville-hook');
6
+ export const RUNTIME_POINTER = () => join(dirname(userConfigPath()), 'runtime', 'current.json');
7
+ function writeChanged(path, content, mode) {
8
+ try {
9
+ if (readFileSync(path, 'utf8') === content)
10
+ return;
11
+ }
12
+ catch {
13
+ /* first install */
14
+ }
15
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
16
+ const tmp = `${path}.${process.pid}.tmp`;
17
+ writeFileSync(tmp, content, { mode });
18
+ renameSync(tmp, path);
19
+ }
20
+ // Resolve relative to the shim itself, including custom XDG/NOCVILLE_CONFIG installs.
21
+ // JSON is parsed by Node, never interpolated into shell code or decoded with sed.
22
+ const SHIM = `#!/bin/sh
23
+ # nocville-npc-hook: stable one-way hook launcher
24
+ DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) || exit 0
25
+ IFS= read -r NODE < "$DIR/../runtime/node-path" 2>/dev/null || NODE=''
26
+ if [ ! -x "$NODE" ]; then NODE=$(command -v node 2>/dev/null); fi
27
+ for candidate in "$NODE" /opt/homebrew/bin/node /usr/local/bin/node "$HOME/.volta/bin/node"; do
28
+ if [ -x "$candidate" ]; then NODE="$candidate"; break; fi
29
+ done
30
+ if [ ! -x "$NODE" ]; then cat >/dev/null; if { [ "$1" = gemini ] || [ "$1" = cursor ] || [ "$1" = claude-code ]; }; then printf '{}\\n'; fi; exit 0; fi
31
+ exec "$NODE" "$DIR/launch.cjs" "$DIR/../runtime/current.json" "$@"
32
+ `;
33
+ // Without Node, return a neutral empty object for both Claude and imported Cursor.
34
+ // No payload is evaluated by either shell. Native Codex remains silent.
35
+ const WINDOWS_SHIM = String.raw `@echo off
36
+ setlocal DisableDelayedExpansion
37
+ set "NOCVILLE_NODE="
38
+ set /p "NOCVILLE_NODE="<"%~dp0..\runtime\node-path" 2>nul
39
+ if exist "%NOCVILLE_NODE%" goto launch
40
+ set "NOCVILLE_NODE="
41
+ for /f "delims=" %%N in ('where node.exe 2^>nul') do if not defined NOCVILLE_NODE set "NOCVILLE_NODE=%%N"
42
+ if not defined NOCVILLE_NODE goto no_node
43
+ :launch
44
+ "%NOCVILLE_NODE%" "%~dp0launch.cjs" "%~dp0..\runtime\current.json" %*
45
+ exit /b 0
46
+ :no_node
47
+ more >nul
48
+ if "%~1"=="cursor" echo {}
49
+ if "%~1"=="gemini" echo {}
50
+ if "%~1"=="claude-code" echo {}
51
+ exit /b 0
52
+ `;
53
+ const LAUNCHER = `// Stable bootstrap. Hooks never return decisions or emit launcher errors.
54
+ const fs = require('node:fs');
55
+ const { spawnSync } = require('node:child_process');
56
+ const agent = process.argv[3];
57
+ const hook = !process.argv[4] || process.argv[4] === 'hook';
58
+ let neutral = hook && (agent === 'gemini' || agent === 'cursor');
59
+ let input = Buffer.alloc(0);
60
+ try {
61
+ input = fs.readFileSync(0);
62
+ if (hook && agent === 'claude-code') {
63
+ try {
64
+ const event = JSON.parse(input.toString('utf8'));
65
+ const transcript = typeof event?.transcript_path === 'string' ? event.transcript_path.replace(/\\\\/g, '/') : '';
66
+ neutral = typeof event?.cursor_version === 'string' ||
67
+ (typeof event?.conversation_id === 'string' && !/(?:^|\\/)\\.claude(?:\\/|$)/.test(transcript));
68
+ } catch {}
69
+ }
70
+ const pointer = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
71
+ if (typeof pointer.cli !== 'string' || !fs.existsSync(pointer.cli)) throw new Error('missing runtime');
72
+ const node = typeof pointer.node === 'string' && fs.existsSync(pointer.node) ? pointer.node : process.execPath;
73
+ // Buffer stdin once for payload-based imported-Cursor detection, then replay it unchanged.
74
+ // Never forward child stdout, even in debug mode or on an unexpected failure.
75
+ spawnSync(node, [pointer.cli, ...process.argv.slice(3)], { input, stdio: ['pipe', 'ignore', 'inherit'], windowsHide: true });
76
+ } catch {}
77
+ if (neutral) process.stdout.write('{}\\n');
78
+ `;
79
+ export function installHookShim(nodePath, cliPath) {
80
+ writeChanged(RUNTIME_POINTER(), JSON.stringify({ node: nodePath, cli: cliPath }, null, 2) + '\n', 0o600);
81
+ writeChanged(join(dirname(RUNTIME_POINTER()), 'node-path'), nodePath + '\n', 0o600);
82
+ writeChanged(join(dirname(SHIM_PATH()), 'launch.cjs'), LAUNCHER, 0o600);
83
+ writeChanged(SHIM_PATH(), SHIM, 0o755);
84
+ chmodSync(SHIM_PATH(), 0o755);
85
+ // Both launchers use the same bootstrap, including imported-Cursor detection.
86
+ writeChanged(SHIM_PATH() + '.cmd', WINDOWS_SHIM.replace(/\n/g, '\r\n'), 0o755);
87
+ pruneOldRuntimes(dirname(RUNTIME_POINTER()));
88
+ return SHIM_PATH();
89
+ }
90
+ //# sourceMappingURL=shim.js.map
@@ -0,0 +1,16 @@
1
+ import type { NpcConfig } from '../../core/types.js';
2
+ import { isAgentAlive } from './liveness.js';
3
+ import type { AgentAdapter, PushDeps } from './session.js';
4
+ export declare const WATCHDOG_TICK_MS = 15000;
5
+ export declare const WATCHDOG_HEARTBEAT_MS = 60000;
6
+ export declare const WATCHDOG_OFFLINE_RETRY_MS: number;
7
+ export declare function ensureWatchdog(adapter: AgentAdapter, config: NpcConfig, sessionId: string, cliPath?: string, lockWaitMs?: number): Promise<void>;
8
+ export interface WatchdogDeps extends PushDeps {
9
+ isAlive?: typeof isAgentAlive;
10
+ selfPid?: number;
11
+ now?: () => number;
12
+ wait?: (ms: number) => Promise<void>;
13
+ }
14
+ /** Silent, read-only observer of the agent process; never sends input to it. */
15
+ export declare function runWatchdog(adapter: AgentAdapter, config: NpcConfig, sessionId: string | undefined, deps?: WatchdogDeps): Promise<void>;
16
+ //# sourceMappingURL=watchdog.d.ts.map
@@ -0,0 +1,144 @@
1
+ import { isAgentAlive } from './liveness.js';
2
+ import { withStateLock } from './lock.js';
3
+ import { flushSession, readSessionState, sleep, spawnDetachedAgent, stateFilePath, writeSessionState, } from './session.js';
4
+ export const WATCHDOG_TICK_MS = 15_000;
5
+ export const WATCHDOG_HEARTBEAT_MS = 60_000;
6
+ export const WATCHDOG_OFFLINE_RETRY_MS = 15 * 60_000;
7
+ export async function ensureWatchdog(adapter, config, sessionId, cliPath, lockWaitMs) {
8
+ if (adapter.processNames?.length === 0 ||
9
+ !config.apiKey ||
10
+ process.env['NOCVILLE_NO_WATCHDOG'] === '1')
11
+ return;
12
+ await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
13
+ const state = readSessionState(adapter.stateDir, sessionId);
14
+ // No heartbeat without proven liveness. Keep the long fallback on unsupported hosts.
15
+ if (!state || state.offline || !state.agentPid)
16
+ return;
17
+ if (state.watchdogPid && isAgentAlive({ pid: state.watchdogPid }))
18
+ return;
19
+ const pid = spawnDetachedAgent(adapter, config, ['watchdog', sessionId], cliPath);
20
+ if (pid) {
21
+ state.watchdogPid = pid;
22
+ writeSessionState(adapter.stateDir, state);
23
+ }
24
+ }, lockWaitMs);
25
+ }
26
+ /** Silent, read-only observer of the agent process; never sends input to it. */
27
+ export async function runWatchdog(adapter, config, sessionId, deps = {}) {
28
+ if (!sessionId || adapter.processNames?.length === 0)
29
+ return;
30
+ const isAlive = deps.isAlive ?? isAgentAlive;
31
+ const now = deps.now ?? Date.now;
32
+ const wait = deps.wait ?? sleep;
33
+ const selfPid = deps.selfPid ?? process.pid;
34
+ let unknownTicks = 0;
35
+ let rejected = false;
36
+ let heartbeatRejected = false;
37
+ const delivery = {
38
+ ...deps,
39
+ onResult: (result) => {
40
+ if (!result.success && [400, 401, 403, 404].includes(result.statusCode ?? 0))
41
+ rejected = true;
42
+ deps.onResult?.(result);
43
+ },
44
+ };
45
+ // Do not clear a replacement observer's ownership. A failed write retries on the next tick.
46
+ const finish = async (terminal = false, abandoned = false) => {
47
+ await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
48
+ const fresh = readSessionState(adapter.stateDir, sessionId);
49
+ if (!fresh || (fresh.watchdogPid && fresh.watchdogPid !== selfPid))
50
+ return;
51
+ if (terminal && !fresh.offline) {
52
+ fresh.offline = true;
53
+ fresh.rev = (fresh.rev ?? 0) + 1;
54
+ }
55
+ if (abandoned)
56
+ fresh.offlineDeliveryStopped = true;
57
+ fresh.watchdogPid = undefined;
58
+ writeSessionState(adapter.stateDir, fresh);
59
+ });
60
+ };
61
+ for (;;) {
62
+ await wait(WATCHDOG_TICK_MS);
63
+ try {
64
+ const state = readSessionState(adapter.stateDir, sessionId);
65
+ if (!state)
66
+ return;
67
+ if (state.watchdogPid && state.watchdogPid !== selfPid)
68
+ return;
69
+ if (rejected || state.offlineDeliveryStopped) {
70
+ await finish(true, true);
71
+ return;
72
+ }
73
+ if (heartbeatRejected) {
74
+ await finish();
75
+ return;
76
+ }
77
+ if (!state.offline && !state.agentPid) {
78
+ if (++unknownTicks >= 3) {
79
+ await finish();
80
+ return;
81
+ }
82
+ continue;
83
+ }
84
+ const terminal = state.offline || !isAlive({ pid: state.agentPid, startedAt: state.agentStartedAt });
85
+ if (terminal) {
86
+ if (state.offline && (state.pushedRev ?? 0) >= (state.rev ?? 0)) {
87
+ await finish();
88
+ return;
89
+ }
90
+ const startedAt = await withStateLock(stateFilePath(adapter.stateDir, sessionId), () => {
91
+ const fresh = readSessionState(adapter.stateDir, sessionId);
92
+ if (!fresh ||
93
+ fresh.agentPid !== state.agentPid ||
94
+ (fresh.watchdogPid && fresh.watchdogPid !== selfPid))
95
+ return undefined;
96
+ if (!fresh.offline) {
97
+ fresh.offline = true;
98
+ fresh.rev = (fresh.rev ?? 0) + 1;
99
+ }
100
+ fresh.offlineRetryStartedAt ??= now();
101
+ writeSessionState(adapter.stateDir, fresh);
102
+ return fresh.offlineRetryStartedAt;
103
+ });
104
+ if (startedAt === undefined)
105
+ continue;
106
+ if (now() - startedAt >= WATCHDOG_OFFLINE_RETRY_MS) {
107
+ await finish(true, true);
108
+ return;
109
+ }
110
+ if (await flushSession(adapter, config, sessionId, delivery)) {
111
+ await finish();
112
+ return;
113
+ }
114
+ }
115
+ else if ((state.rev ?? 0) > (state.pushedRev ?? 0) ||
116
+ now() - (state.lastPushAt ?? 0) >= WATCHDOG_HEARTBEAT_MS) {
117
+ await flushSession(adapter, config, sessionId, {
118
+ ...deps,
119
+ force: true,
120
+ onResult: (result) => {
121
+ if (!result.success && [400, 401, 403, 404].includes(result.statusCode ?? 0))
122
+ heartbeatRejected = true;
123
+ deps.onResult?.(result);
124
+ },
125
+ });
126
+ if (heartbeatRejected) {
127
+ // A rejected heartbeat is not evidence the agent died. A later hook
128
+ // can restart observation after credentials/routing recover.
129
+ await finish();
130
+ return;
131
+ }
132
+ }
133
+ if (rejected) {
134
+ await finish(true, true);
135
+ return;
136
+ }
137
+ }
138
+ catch {
139
+ // A transient state-lock/filesystem error must not kill an otherwise live observer.
140
+ continue;
141
+ }
142
+ }
143
+ }
144
+ //# sourceMappingURL=watchdog.js.map
@@ -0,0 +1,15 @@
1
+ export declare const DEFAULTS: {
2
+ stateDir: string;
3
+ stateFilePrefix: string;
4
+ stateFileMaxAge: number;
5
+ httpTimeout: number;
6
+ /** Watchdog-backed sessions become unknown after three minutes without a heartbeat. */
7
+ heartbeatSeconds: number;
8
+ /** Number of recent activity lines kept in the desk-sign ticker. */
9
+ activityBufferSize: number;
10
+ /** Coalesce same-phase activity pushes closer together than this. */
11
+ minPushGapMs: number;
12
+ /** Per-hook timeout written into settings.json (seconds) — a safety net only. */
13
+ hookTimeoutSeconds: number;
14
+ };
15
+ //# sourceMappingURL=defaults.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export const DEFAULTS = {
4
+ stateDir: join(tmpdir(), 'nocville-claude-code'),
5
+ stateFilePrefix: 'session-',
6
+ stateFileMaxAge: 24 * 60 * 60 * 1000, // 24 hours
7
+ httpTimeout: 4000,
8
+ /** Watchdog-backed sessions become unknown after three minutes without a heartbeat. */
9
+ heartbeatSeconds: 90,
10
+ /** Number of recent activity lines kept in the desk-sign ticker. */
11
+ activityBufferSize: 8,
12
+ /** Coalesce same-phase activity pushes closer together than this. */
13
+ minPushGapMs: 1500,
14
+ /** Per-hook timeout written into settings.json (seconds) — a safety net only. */
15
+ hookTimeoutSeconds: 10,
16
+ };
17
+ //# sourceMappingURL=defaults.js.map