@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,184 @@
1
+ import type { StatusUpdate, AgentStats } from '@nocville/node';
2
+ import type { NpcConfig, Status, Mood, BehaviorHints } from '../../core/types.js';
3
+ /**
4
+ * Shared session plumbing for hook-driven AI-agent NPCs (Claude Code, Codex).
5
+ *
6
+ * Each agent session is one NPC. A hook invocation reads the session's state
7
+ * file, applies the event, writes the state back, and decides whether to push:
8
+ *
9
+ * - phase changes (working ↔ waiting ↔ idle) and the first push go out at once;
10
+ * - rapid same-phase activity changes (PreToolUse bursts) are coalesced so the
11
+ * service stays well under the API rate limit, while the *latest* activity
12
+ * still lands — a deferred flush pushes whatever the state is when it fires.
13
+ *
14
+ * Pushes run in a detached background process (`<agent> flush <session>`) so a
15
+ * hook returns in tens of milliseconds and never blocks or fails the agent.
16
+ */
17
+ export type AgentPhase = 'IDLE' | 'WORKING' | 'WAITING';
18
+ export interface AgentSessionState {
19
+ hookDeliveries?: {
20
+ key: string;
21
+ source: string;
22
+ at: number;
23
+ }[];
24
+ agentStats?: AgentStats;
25
+ /** Stop revision awaiting collection by a detached flush (including coalesced flushes). */
26
+ statsPendingRev?: number;
27
+ sessionId: string;
28
+ serviceId: string;
29
+ startedAt?: number;
30
+ agentPid?: number;
31
+ agentStartedAt?: number;
32
+ agentPidResolveAttempts?: number;
33
+ agentPidLastResolveAt?: number;
34
+ watchdogPid?: number;
35
+ /** First terminal delivery attempt, persisted across watchdog restarts. */
36
+ offlineRetryStartedAt?: number;
37
+ /** Terminal delivery exhausted its budget or received a non-retryable response. */
38
+ offlineDeliveryStopped?: boolean;
39
+ subagents?: Record<string, {
40
+ type: string;
41
+ activity: string;
42
+ at: number;
43
+ }>;
44
+ /** Machine phase. */
45
+ state: AgentPhase;
46
+ projectDir: string;
47
+ lastEvent: string;
48
+ lastUpdate: number;
49
+ /** Recent activity lines, most recent FIRST (desk-sign shows values[0]). */
50
+ activity?: string[];
51
+ /** Cached user-facing session name (Claude Code session title), if any. */
52
+ name?: string;
53
+ status?: Status;
54
+ mood?: Mood;
55
+ /** Offline sentinel pending push (session ended). */
56
+ offline?: boolean;
57
+ /** Monotonic revision bumped on every applied change. */
58
+ rev?: number;
59
+ /** Revision last pushed successfully. */
60
+ pushedRev?: number;
61
+ /** Epoch ms of the last push. */
62
+ lastPushAt?: number;
63
+ /** Epoch ms a deferred flush is scheduled for. */
64
+ flushAt?: number;
65
+ }
66
+ /** Result of applying one agent event. */
67
+ export interface AgentTransition {
68
+ state: AgentPhase;
69
+ /** Current activity line; `undefined` keeps the previous one. */
70
+ activity?: string;
71
+ status: Status;
72
+ mood: Mood;
73
+ offline?: boolean;
74
+ /** Event carried nothing displayable — record it but never push for it. */
75
+ quiet?: boolean;
76
+ }
77
+ export interface StatusOutput {
78
+ status: Status;
79
+ mood: Mood;
80
+ message: string;
81
+ /** 'working' | 'waiting' | 'idle' | 'offline'. */
82
+ subStatus?: string;
83
+ behavior?: BehaviorHints;
84
+ offline?: boolean;
85
+ }
86
+ /** Behavior hints + subStatus for a phase (shared by every agent NPC). */
87
+ export declare function phasePresentation(state: AgentPhase, offline?: boolean): {
88
+ subStatus: string;
89
+ behavior?: BehaviorHints;
90
+ };
91
+ /** Static identity of an agent integration. */
92
+ export interface AgentAdapter {
93
+ /** CLI command name: 'claude-code' | 'codex'. */
94
+ command: string;
95
+ /** Banner prefix: 'Claude' | 'Codex'. */
96
+ label: string;
97
+ /** Appearance family sent as `appearance.family` (branded NPC tee + headgear). */
98
+ family: 'claude' | 'codex' | 'gemini' | 'cursor';
99
+ stateDir: string;
100
+ /** Heartbeat sent with each push — the server marks the NPC unknown at 2x. */
101
+ heartbeatSeconds: number;
102
+ processNames?: readonly string[];
103
+ /** A node ancestor must match this argv pattern; unrelated node processes never qualify. */
104
+ nodeCommandPattern?: RegExp;
105
+ }
106
+ export declare const ACTIVITY_HISTORY = 8;
107
+ export declare const DEFAULT_MIN_PUSH_GAP_MS = 1500;
108
+ export declare function projectNameOf(cwd: string): string;
109
+ /** "Claude · nocville", clamped to the API's 32-char banner limit. */
110
+ export declare function agentDisplayName(label: string, subject: string): string;
111
+ /** Service ids are ≤100 chars of [A-Za-z0-9_.-]. */
112
+ export declare function sanitizeServiceId(raw: string): string;
113
+ export declare function stateFilePath(stateDir: string, sessionId: string): string;
114
+ export declare function readSessionState(stateDir: string, sessionId: string): AgentSessionState | null;
115
+ /** Atomic write (temp file + rename) so concurrent readers never see half a file. */
116
+ export declare function writeSessionState(stateDir: string, state: AgentSessionState): void;
117
+ export declare function deleteSessionState(stateDir: string, sessionId: string): void;
118
+ /** Remove state files older than `maxAgeMs`. Returns the number removed. */
119
+ export declare function cleanupSessionStates(stateDir: string, maxAgeMs: number, now?: number): Promise<number>;
120
+ /** Cross-process hourly maintenance, invoked only on SessionStart. */
121
+ export declare function maybeCleanupSessionStates(stateDir: string, now?: number): Promise<number>;
122
+ /** Prepend `line` to the history (deduping an identical head), capped. */
123
+ export declare function pushHistory(prev: string[] | undefined, line: string | undefined): string[];
124
+ export interface ApplyInput {
125
+ sessionId: string;
126
+ serviceId: string;
127
+ projectDir: string;
128
+ eventName: string;
129
+ name?: string;
130
+ transition: AgentTransition;
131
+ now: number;
132
+ }
133
+ export type PushDecision = {
134
+ kind: 'none';
135
+ } | {
136
+ kind: 'now';
137
+ } | {
138
+ kind: 'defer';
139
+ delayMs: number;
140
+ };
141
+ /**
142
+ * Apply a transition to the previous state and decide when to push.
143
+ * Pure — exposed for testing.
144
+ */
145
+ export declare function applyTransition(prev: AgentSessionState | null, input: ApplyInput, minGapMs?: number): {
146
+ next: AgentSessionState;
147
+ decision: PushDecision;
148
+ };
149
+ /** Build the status push for a session (offline sentinel when the session ended). */
150
+ export declare function buildAgentPayload(adapter: AgentAdapter, state: AgentSessionState, config: Pick<NpcConfig, 'name' | 'map'>): StatusUpdate;
151
+ export interface PushDeps {
152
+ /** Override the HTTP push (tests). */
153
+ push?: (serviceId: string, payload: StatusUpdate) => Promise<{
154
+ success: boolean;
155
+ error?: string;
156
+ statusCode?: number;
157
+ }>;
158
+ onResult?: (result: {
159
+ success: boolean;
160
+ error?: string;
161
+ statusCode?: number;
162
+ }) => void;
163
+ timeoutMs?: number;
164
+ force?: boolean;
165
+ lockWaitMs?: number;
166
+ }
167
+ /**
168
+ * Push the session's current state if it has unpushed changes (or is offline),
169
+ * then record the push. Re-reads the state right before writing so a hook that
170
+ * ran concurrently isn't clobbered. Never throws.
171
+ */
172
+ export declare function flushSession(adapter: AgentAdapter, config: NpcConfig, sessionId: string, deps?: PushDeps): Promise<boolean>;
173
+ /** Current parent activity, up to three subagents, then recent parent history. */
174
+ export declare function tickerLines(state: AgentSessionState): string[];
175
+ export declare function applySubagentEvent(prev: AgentSessionState, agentId: string, type: string, event: string, activity: string, now: number): AgentSessionState;
176
+ /**
177
+ * Hand a flush to a detached background process so the hook can exit at once.
178
+ * The child inherits the resolved config via env (not argv, so the key never
179
+ * shows up in `ps`).
180
+ */
181
+ export declare function spawnDetachedFlush(adapter: AgentAdapter, config: NpcConfig, sessionId: string, delayMs: number, cliPath?: string | undefined, stats?: boolean): void;
182
+ export declare function spawnDetachedAgent(adapter: AgentAdapter, config: NpcConfig, args: string[], cliPath?: string | undefined): number | undefined;
183
+ export declare function sleep(ms: number): Promise<void>;
184
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,368 @@
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 { truncate } from '../../core/text.js';
6
+ import { withStateLock } from './lock.js';
7
+ /** Behavior hints + subStatus for a phase (shared by every agent NPC). */
8
+ export function phasePresentation(state, offline) {
9
+ if (offline)
10
+ return { subStatus: 'offline' };
11
+ switch (state) {
12
+ case 'WORKING':
13
+ // Head to a desk and sit while actively working.
14
+ return { subStatus: 'working', behavior: { activity: 'busy', busy: 'seek-desk' } };
15
+ case 'WAITING':
16
+ // Freeze and raise a hand until the human responds.
17
+ return {
18
+ subStatus: 'waiting',
19
+ behavior: { activity: 'waiting', waitingEmote: 'raised-hand' },
20
+ };
21
+ case 'IDLE':
22
+ default:
23
+ // Settle at the desk and doze off.
24
+ return {
25
+ subStatus: 'idle',
26
+ behavior: { activity: 'idle', idle: 'stay', idlePose: 'sleep' },
27
+ };
28
+ }
29
+ }
30
+ export const ACTIVITY_HISTORY = 8;
31
+ export const DEFAULT_MIN_PUSH_GAP_MS = 1500;
32
+ const STATE_PREFIX = 'session-';
33
+ // ---------------------------------------------------------------- naming
34
+ export function projectNameOf(cwd) {
35
+ const parts = cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/');
36
+ return parts[parts.length - 1] || 'unknown';
37
+ }
38
+ /** "Claude · nocville", clamped to the API's 32-char banner limit. */
39
+ export function agentDisplayName(label, subject) {
40
+ return truncate(`${label} · ${subject}`, 32);
41
+ }
42
+ /** Service ids are ≤100 chars of [A-Za-z0-9_.-]. */
43
+ export function sanitizeServiceId(raw) {
44
+ return raw
45
+ .replace(/[^A-Za-z0-9_.-]+/g, '-')
46
+ .replace(/-+/g, '-')
47
+ .slice(0, 100);
48
+ }
49
+ // ---------------------------------------------------------------- state file
50
+ export function stateFilePath(stateDir, sessionId) {
51
+ const safe = sessionId.replace(/[^A-Za-z0-9_.-]/g, '_');
52
+ return join(stateDir, `${STATE_PREFIX}${safe}.json`);
53
+ }
54
+ export function readSessionState(stateDir, sessionId) {
55
+ try {
56
+ const raw = JSON.parse(readFileSync(stateFilePath(stateDir, sessionId), 'utf-8'));
57
+ if (typeof raw['sessionId'] !== 'string')
58
+ return null;
59
+ return raw;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Atomic write (temp file + rename) so concurrent readers never see half a file. */
66
+ export function writeSessionState(stateDir, state) {
67
+ mkdirSync(stateDir, { recursive: true });
68
+ const path = stateFilePath(stateDir, state.sessionId);
69
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
70
+ writeFileSync(tmp, JSON.stringify(state), 'utf-8');
71
+ renameSync(tmp, path);
72
+ }
73
+ export function deleteSessionState(stateDir, sessionId) {
74
+ const path = stateFilePath(stateDir, sessionId);
75
+ if (existsSync(path)) {
76
+ try {
77
+ rmSync(path);
78
+ }
79
+ catch {
80
+ // best effort
81
+ }
82
+ }
83
+ }
84
+ /** Remove state files older than `maxAgeMs`. Returns the number removed. */
85
+ export async function cleanupSessionStates(stateDir, maxAgeMs, now = Date.now()) {
86
+ if (!existsSync(stateDir))
87
+ return 0;
88
+ let removed = 0;
89
+ for (const file of readdirSync(stateDir)) {
90
+ if (!file.startsWith(STATE_PREFIX) || !file.endsWith('.json'))
91
+ continue;
92
+ const path = join(stateDir, file);
93
+ try {
94
+ if (now - statSync(path).mtimeMs <= maxAgeMs)
95
+ continue;
96
+ // Recheck under the same lock as hooks, so a SessionStart cannot be deleted mid-write.
97
+ await withStateLock(path, () => {
98
+ if (now - statSync(path).mtimeMs > maxAgeMs) {
99
+ unlinkSync(path);
100
+ removed++;
101
+ }
102
+ }, 0);
103
+ }
104
+ catch {
105
+ /* active writer, missing file or filesystem error: leave it alone */
106
+ }
107
+ }
108
+ return removed;
109
+ }
110
+ /** Cross-process hourly maintenance, invoked only on SessionStart. */
111
+ export async function maybeCleanupSessionStates(stateDir, now = Date.now()) {
112
+ const marker = join(stateDir, '.last-cleanup');
113
+ try {
114
+ return await withStateLock(marker, async () => {
115
+ let last = NaN;
116
+ try {
117
+ last = Number(readFileSync(marker, 'utf8'));
118
+ }
119
+ catch {
120
+ /* first cleanup */
121
+ }
122
+ if (Number.isFinite(last) && now - last < 3_600_000)
123
+ return 0;
124
+ const removed = await cleanupSessionStates(stateDir, 86_400_000, now);
125
+ writeFileSync(marker, String(now), { mode: 0o600 });
126
+ return removed;
127
+ }, 0);
128
+ }
129
+ catch {
130
+ return 0;
131
+ }
132
+ }
133
+ /** Prepend `line` to the history (deduping an identical head), capped. */
134
+ export function pushHistory(prev, line) {
135
+ const list = [...(prev ?? [])];
136
+ if (line && list[0] !== line)
137
+ list.unshift(line);
138
+ return list.slice(0, ACTIVITY_HISTORY);
139
+ }
140
+ /**
141
+ * Apply a transition to the previous state and decide when to push.
142
+ * Pure — exposed for testing.
143
+ */
144
+ export function applyTransition(prev, input, minGapMs = DEFAULT_MIN_PUSH_GAP_MS) {
145
+ const t = input.transition;
146
+ const history = pushHistory(prev?.activity, t.activity);
147
+ const prevHead = prev?.activity?.[0];
148
+ const subagents = pruneSubagents(prev?.subagents, input.now);
149
+ const changed = !prev ||
150
+ prev.state !== t.state ||
151
+ prev.status !== t.status ||
152
+ Object.keys(subagents).length !== Object.keys(prev.subagents ?? {}).length ||
153
+ history[0] !== prevHead ||
154
+ !!t.offline !== !!prev.offline ||
155
+ (input.name !== undefined && input.name !== prev.name);
156
+ const rev = (prev?.rev ?? 0) + (changed ? 1 : 0);
157
+ const next = {
158
+ ...prev,
159
+ sessionId: input.sessionId,
160
+ startedAt: prev?.startedAt ?? input.now,
161
+ serviceId: prev?.serviceId ?? input.serviceId,
162
+ state: t.state,
163
+ projectDir: input.projectDir,
164
+ lastEvent: input.eventName,
165
+ lastUpdate: input.now,
166
+ activity: history,
167
+ subagents,
168
+ name: input.name ?? prev?.name,
169
+ status: t.status,
170
+ mood: t.mood,
171
+ offline: t.offline || undefined,
172
+ rev,
173
+ pushedRev: prev?.pushedRev ?? 0,
174
+ lastPushAt: prev?.lastPushAt ?? 0,
175
+ flushAt: prev?.flushAt,
176
+ };
177
+ if (t.offline)
178
+ return { next, decision: { kind: 'now' } };
179
+ if (t.quiet || !changed)
180
+ return { next, decision: { kind: 'none' } };
181
+ const phaseChanged = !prev || prev.state !== t.state || !prev.lastPushAt;
182
+ const sinceLast = input.now - (next.lastPushAt ?? 0);
183
+ if (phaseChanged || sinceLast >= minGapMs) {
184
+ // Stamp the push time now: the push itself runs asynchronously (detached),
185
+ // and the next hook must already see it to coalesce.
186
+ next.lastPushAt = input.now;
187
+ return { next, decision: { kind: 'now' } };
188
+ }
189
+ // Coalesce: a flush is already pending (and not stale) → it will pick up this rev.
190
+ if (next.flushAt && next.flushAt > input.now - 2000)
191
+ return { next, decision: { kind: 'none' } };
192
+ const delayMs = Math.max(50, minGapMs - sinceLast);
193
+ next.flushAt = input.now + delayMs;
194
+ return { next, decision: { kind: 'defer', delayMs } };
195
+ }
196
+ // ---------------------------------------------------------------- push
197
+ /** Build the status push for a session (offline sentinel when the session ended). */
198
+ export function buildAgentPayload(adapter, state, config) {
199
+ const subject = state.name ?? projectNameOf(state.projectDir);
200
+ const name = config.name ? truncate(config.name, 32) : agentDisplayName(adapter.label, subject);
201
+ const current = state.activity?.[0] ?? '';
202
+ const { subStatus, behavior } = phasePresentation(state.state, state.offline);
203
+ if (state.offline) {
204
+ return {
205
+ status: 'unknown',
206
+ mood: 'idle',
207
+ name,
208
+ message: 'Session ended',
209
+ subStatus,
210
+ offline: true,
211
+ map: config.map,
212
+ };
213
+ }
214
+ const screenData = {
215
+ layout: 'ticker',
216
+ values: tickerLines({ ...state, subagents: pruneSubagents(state.subagents, Date.now()) }).map((a) => ({ label: '', value: a })),
217
+ };
218
+ return {
219
+ status: state.status ?? 'healthy',
220
+ mood: state.mood ?? 'happy',
221
+ name,
222
+ message: current ? truncate(current, 120) : undefined,
223
+ archetype: 'ai-agent',
224
+ metrics: { sessionStartedAt: state.startedAt ?? state.lastUpdate },
225
+ appearance: { family: adapter.family },
226
+ agentStats: state.agentStats,
227
+ subStatus,
228
+ behavior,
229
+ alertConfig: { heartbeatInterval: state.watchdogPid ? adapter.heartbeatSeconds : 3600 },
230
+ screenData,
231
+ companion: {
232
+ screenType: 'desk-sign',
233
+ size: { w: 3, h: 1 },
234
+ placement: 'on-desk',
235
+ label: truncate(projectNameOf(state.projectDir), 40),
236
+ },
237
+ map: config.map,
238
+ };
239
+ }
240
+ async function defaultPush(config, serviceId, payload, timeoutMs) {
241
+ const client = new NocvilleClient({
242
+ apiUrl: config.apiUrl,
243
+ apiKey: config.apiKey,
244
+ serviceId,
245
+ timeout: timeoutMs,
246
+ retries: 0,
247
+ debug: config.debug,
248
+ });
249
+ return client.pushStatus(payload);
250
+ }
251
+ /**
252
+ * Push the session's current state if it has unpushed changes (or is offline),
253
+ * then record the push. Re-reads the state right before writing so a hook that
254
+ * ran concurrently isn't clobbered. Never throws.
255
+ */
256
+ export async function flushSession(adapter, config, sessionId, deps = {}) {
257
+ const path = stateFilePath(adapter.stateDir, sessionId);
258
+ try {
259
+ // Serialize HTTP delivery separately: hooks can update state throughout a request,
260
+ // but an older delivery can never arrive after a newer/offline one.
261
+ return await withStateLock(`${path}.push`, async () => {
262
+ const state = readSessionState(adapter.stateDir, sessionId);
263
+ if (!state || !config.apiKey || state.offlineDeliveryStopped)
264
+ return false;
265
+ const rev = state.rev ?? 0;
266
+ if (!deps.force && rev <= (state.pushedRev ?? 0)) {
267
+ await withStateLock(path, () => {
268
+ const fresh = readSessionState(adapter.stateDir, sessionId);
269
+ if (fresh && (fresh.rev ?? 0) === rev) {
270
+ fresh.flushAt = undefined;
271
+ writeSessionState(adapter.stateDir, fresh);
272
+ }
273
+ }, deps.lockWaitMs);
274
+ return false;
275
+ }
276
+ const push = deps.push ??
277
+ ((sid, payload) => defaultPush(config, sid, payload, deps.timeoutMs ?? 4000));
278
+ let ok = false;
279
+ try {
280
+ const result = await push(state.serviceId, buildAgentPayload(adapter, state, config));
281
+ ok = result.success;
282
+ deps.onResult?.(result);
283
+ }
284
+ catch {
285
+ /* retry on next hook/watchdog tick */
286
+ }
287
+ await withStateLock(path, () => {
288
+ const fresh = readSessionState(adapter.stateDir, sessionId);
289
+ if (!fresh)
290
+ return;
291
+ // Retain terminal state until cleanup so late hooks/watchers cannot revive it.
292
+ if (ok) {
293
+ fresh.pushedRev = Math.max(fresh.pushedRev ?? 0, rev);
294
+ fresh.lastPushAt = Date.now();
295
+ }
296
+ if (!fresh.flushAt || fresh.flushAt <= Date.now())
297
+ fresh.flushAt = undefined;
298
+ writeSessionState(adapter.stateDir, fresh);
299
+ }, deps.lockWaitMs);
300
+ return ok;
301
+ }, deps.lockWaitMs ?? 6000);
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
307
+ /** Current parent activity, up to three subagents, then recent parent history. */
308
+ export function tickerLines(state) {
309
+ const history = state.activity ?? [];
310
+ const agents = Object.values(state.subagents ?? {})
311
+ .sort((a, b) => b.at - a.at)
312
+ .slice(0, 3)
313
+ .map((entry) => `↳ ${entry.type}: ${entry.activity}`);
314
+ return [...history.slice(0, 1), ...agents, ...history.slice(1)].slice(0, ACTIVITY_HISTORY);
315
+ }
316
+ export function applySubagentEvent(prev, agentId, type, event, activity, now) {
317
+ const subagents = pruneSubagents(prev.subagents, now);
318
+ if (event === 'Stop' || event === 'SubagentStop')
319
+ delete subagents[agentId];
320
+ else if (event === 'PreToolUse')
321
+ subagents[agentId] = { type, activity, at: now };
322
+ const next = { ...prev, subagents, lastUpdate: now, lastEvent: event };
323
+ if (JSON.stringify(tickerLines(prev)) !== JSON.stringify(tickerLines(next)))
324
+ next.rev = (prev.rev ?? 0) + 1;
325
+ return next;
326
+ }
327
+ /**
328
+ * Hand a flush to a detached background process so the hook can exit at once.
329
+ * The child inherits the resolved config via env (not argv, so the key never
330
+ * shows up in `ps`).
331
+ */
332
+ export function spawnDetachedFlush(adapter, config, sessionId, delayMs, cliPath = process.argv[1], stats = false) {
333
+ spawnDetachedAgent(adapter, config, ['flush', sessionId, `--delay=${Math.round(delayMs)}`, ...(stats ? ['--stats'] : [])], cliPath);
334
+ }
335
+ export function spawnDetachedAgent(adapter, config, args, cliPath = process.argv[1]) {
336
+ if (!cliPath)
337
+ return undefined;
338
+ const env = { ...process.env };
339
+ env['NOCVILLE_API_URL'] = config.apiUrl;
340
+ env['NOCVILLE_API_KEY'] = config.apiKey;
341
+ if (config.map)
342
+ env['NOCVILLE_MAP'] = config.map;
343
+ if (config.name)
344
+ env['NOCVILLE_NAME'] = config.name;
345
+ try {
346
+ const child = spawn(process.execPath, [cliPath, adapter.command, ...args], {
347
+ detached: true,
348
+ stdio: 'ignore',
349
+ env,
350
+ windowsHide: true,
351
+ });
352
+ child.on('error', () => {
353
+ /* swallow — never fail the agent */
354
+ });
355
+ child.unref();
356
+ return child.pid;
357
+ }
358
+ catch {
359
+ // swallow
360
+ }
361
+ }
362
+ export function sleep(ms) {
363
+ return new Promise((resolve) => setTimeout(resolve, ms));
364
+ }
365
+ function pruneSubagents(subagents, now) {
366
+ return Object.fromEntries(Object.entries(subagents ?? {}).filter(([, value]) => now - value.at < 600_000));
367
+ }
368
+ //# 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