jobarbiter 0.3.13 → 0.4.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.
@@ -17,6 +17,8 @@ import { execSync } from "node:child_process";
17
17
 
18
18
  export type ToolCategory = "ai-agent" | "chat" | "orchestration" | "api-provider";
19
19
 
20
+ export type ObservationMethod = "hook" | "extension" | "poller" | "both" | "none";
21
+
20
22
  export interface DetectedTool {
21
23
  id: string;
22
24
  name: string;
@@ -26,6 +28,7 @@ export interface DetectedTool {
26
28
  configDir?: string;
27
29
  observerAvailable: boolean;
28
30
  observerActive: boolean;
31
+ observationMethod: ObservationMethod;
29
32
  }
30
33
 
31
34
  interface ToolDefinition {
@@ -41,6 +44,7 @@ interface ToolDefinition {
41
44
  npmPackage?: string;
42
45
  envVars?: string[];
43
46
  observerAvailable: boolean;
47
+ observationMethod: ObservationMethod;
44
48
  }
45
49
 
46
50
  // ── Tool Definitions ───────────────────────────────────────────────────
@@ -54,6 +58,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
54
58
  binary: "claude",
55
59
  configDir: join(homedir(), ".claude"),
56
60
  observerAvailable: true,
61
+ observationMethod: "hook",
57
62
  },
58
63
  {
59
64
  id: "cursor",
@@ -63,6 +68,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
63
68
  configDir: join(homedir(), ".cursor"),
64
69
  macApp: "/Applications/Cursor.app",
65
70
  observerAvailable: true,
71
+ observationMethod: "hook",
66
72
  },
67
73
  {
68
74
  id: "github-copilot",
@@ -72,6 +78,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
72
78
  vscodeExtension: "github.copilot",
73
79
  cursorExtension: "github.copilot",
74
80
  observerAvailable: false,
81
+ observationMethod: "extension",
75
82
  },
76
83
  {
77
84
  id: "codex",
@@ -80,6 +87,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
80
87
  binary: "codex",
81
88
  configDir: join(homedir(), ".codex"),
82
89
  observerAvailable: true,
90
+ observationMethod: "hook",
83
91
  },
84
92
  {
85
93
  id: "opencode",
@@ -88,6 +96,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
88
96
  binary: "opencode",
89
97
  configDir: join(homedir(), ".config", "opencode"),
90
98
  observerAvailable: true,
99
+ observationMethod: "hook",
91
100
  },
92
101
  {
93
102
  id: "aider",
@@ -97,6 +106,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
97
106
  configDir: join(homedir(), ".aider"),
98
107
  pipPackage: "aider-chat",
99
108
  observerAvailable: false,
109
+ observationMethod: "poller",
100
110
  },
101
111
  {
102
112
  id: "continue",
@@ -105,6 +115,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
105
115
  vscodeExtension: "continue.continue",
106
116
  cursorExtension: "continue.continue",
107
117
  observerAvailable: false,
118
+ observationMethod: "extension",
108
119
  },
109
120
  {
110
121
  id: "cline",
@@ -113,6 +124,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
113
124
  vscodeExtension: "saoudrizwan.claude-dev",
114
125
  cursorExtension: "saoudrizwan.claude-dev",
115
126
  observerAvailable: false,
127
+ observationMethod: "extension",
116
128
  },
117
129
  {
118
130
  id: "windsurf",
@@ -121,6 +133,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
121
133
  binary: "windsurf",
122
134
  macApp: "/Applications/Windsurf.app",
123
135
  observerAvailable: false,
136
+ observationMethod: "extension",
124
137
  },
125
138
  {
126
139
  id: "copilot-chat",
@@ -129,6 +142,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
129
142
  vscodeExtension: "github.copilot-chat",
130
143
  cursorExtension: "github.copilot-chat",
131
144
  observerAvailable: false,
145
+ observationMethod: "extension",
132
146
  },
133
147
  {
134
148
  id: "zed-ai",
@@ -137,6 +151,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
137
151
  macApp: "/Applications/Zed.app",
138
152
  configDir: join(homedir(), platform() === "darwin" ? "Library/Application Support/Zed" : ".config/zed"),
139
153
  observerAvailable: false,
154
+ observationMethod: "poller",
140
155
  },
141
156
  {
142
157
  id: "amazon-q",
@@ -145,6 +160,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
145
160
  binary: "q",
146
161
  configDir: join(homedir(), ".aws", "amazonq"),
147
162
  observerAvailable: false,
163
+ observationMethod: "poller",
148
164
  },
149
165
  {
150
166
  id: "warp-ai",
@@ -153,6 +169,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
153
169
  macApp: "/Applications/Warp.app",
154
170
  configDir: join(homedir(), "Library", "Application Support", "dev.warp.Warp-Stable"),
155
171
  observerAvailable: false,
172
+ observationMethod: "none",
156
173
  },
157
174
  {
158
175
  id: "letta",
@@ -162,6 +179,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
162
179
  configDir: join(homedir(), ".letta"),
163
180
  pipPackage: "letta",
164
181
  observerAvailable: false,
182
+ observationMethod: "poller",
165
183
  },
166
184
  {
167
185
  id: "goose",
@@ -170,6 +188,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
170
188
  binary: "goose",
171
189
  configDir: join(homedir(), ".config", "goose"),
172
190
  observerAvailable: false,
191
+ observationMethod: "poller",
173
192
  },
174
193
  {
175
194
  id: "idx",
@@ -178,6 +197,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
178
197
  // IDX is cloud-based; no local binary. Detect via config dir if any local cache exists.
179
198
  configDir: join(homedir(), ".idx"),
180
199
  observerAvailable: false,
200
+ observationMethod: "none",
181
201
  },
182
202
  {
183
203
  id: "gemini",
@@ -186,6 +206,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
186
206
  binary: "gemini",
187
207
  configDir: join(homedir(), ".gemini"),
188
208
  observerAvailable: true,
209
+ observationMethod: "hook",
189
210
  },
190
211
 
191
212
  // AI Chat/Desktop
@@ -195,6 +216,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
195
216
  category: "chat",
196
217
  macApp: "/Applications/ChatGPT.app",
197
218
  observerAvailable: false,
219
+ observationMethod: "none",
198
220
  },
199
221
  {
200
222
  id: "claude-desktop",
@@ -202,6 +224,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
202
224
  category: "chat",
203
225
  macApp: "/Applications/Claude.app",
204
226
  observerAvailable: false,
227
+ observationMethod: "none",
205
228
  },
206
229
  {
207
230
  id: "ollama",
@@ -210,6 +233,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
210
233
  binary: "ollama",
211
234
  configDir: join(homedir(), ".ollama"),
212
235
  observerAvailable: false,
236
+ observationMethod: "none",
213
237
  },
214
238
 
215
239
  // AI Orchestration
@@ -220,6 +244,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
220
244
  binary: "openclaw",
221
245
  configDir: join(homedir(), ".openclaw"),
222
246
  observerAvailable: false,
247
+ observationMethod: "none",
223
248
  },
224
249
  {
225
250
  id: "langchain",
@@ -227,6 +252,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
227
252
  category: "orchestration",
228
253
  pipPackage: "langchain",
229
254
  observerAvailable: false,
255
+ observationMethod: "none",
230
256
  },
231
257
  {
232
258
  id: "crewai",
@@ -234,6 +260,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
234
260
  category: "orchestration",
235
261
  pipPackage: "crewai",
236
262
  observerAvailable: false,
263
+ observationMethod: "none",
237
264
  },
238
265
 
239
266
  // API Providers (detected via env vars)
@@ -243,6 +270,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
243
270
  category: "api-provider",
244
271
  envVars: ["ANTHROPIC_API_KEY"],
245
272
  observerAvailable: false,
273
+ observationMethod: "none",
246
274
  },
247
275
  {
248
276
  id: "openai-api",
@@ -250,6 +278,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
250
278
  category: "api-provider",
251
279
  envVars: ["OPENAI_API_KEY"],
252
280
  observerAvailable: false,
281
+ observationMethod: "none",
253
282
  },
254
283
  {
255
284
  id: "google-api",
@@ -257,6 +286,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
257
286
  category: "api-provider",
258
287
  envVars: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
259
288
  observerAvailable: false,
289
+ observationMethod: "none",
260
290
  },
261
291
  {
262
292
  id: "groq-api",
@@ -264,6 +294,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
264
294
  category: "api-provider",
265
295
  envVars: ["GROQ_API_KEY"],
266
296
  observerAvailable: false,
297
+ observationMethod: "none",
267
298
  },
268
299
  {
269
300
  id: "mistral-api",
@@ -271,6 +302,7 @@ const TOOL_DEFINITIONS: ToolDefinition[] = [
271
302
  category: "api-provider",
272
303
  envVars: ["MISTRAL_API_KEY"],
273
304
  observerAvailable: false,
305
+ observationMethod: "none",
274
306
  },
275
307
  ];
276
308
 
@@ -526,6 +558,7 @@ export function detectAllTools(): DetectedTool[] {
526
558
  configDir: installed ? configDir : undefined,
527
559
  observerAvailable: def.observerAvailable,
528
560
  observerActive,
561
+ observationMethod: def.observationMethod,
529
562
  });
530
563
  }
531
564
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * LaunchAgent Management for macOS
3
+ *
4
+ * Manages the ai.jobarbiter.observer LaunchAgent for periodic transcript polling.
5
+ * Generates plist, writes to ~/Library/LaunchAgents/, manages via launchctl.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+
13
+ // ── Constants ──────────────────────────────────────────────────────────
14
+
15
+ const LABEL = "ai.jobarbiter.observer";
16
+ const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents");
17
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);
18
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
19
+ const LOG_PATH = join(OBSERVER_DIR, "poll.log");
20
+
21
+ // ── Types ──────────────────────────────────────────────────────────────
22
+
23
+ export interface DaemonStatus {
24
+ installed: boolean;
25
+ loaded: boolean;
26
+ interval: number | null;
27
+ plistPath: string;
28
+ }
29
+
30
+ // ── Helpers ────────────────────────────────────────────────────────────
31
+
32
+ function resolveJobarbiterPath(): string {
33
+ // Try to find the jobarbiter binary
34
+ try {
35
+ const path = execSync("command -v jobarbiter", { encoding: "utf-8", timeout: 3000 }).trim();
36
+ if (path) return path;
37
+ } catch {
38
+ // Fall through
39
+ }
40
+
41
+ // Fallback: use process.execPath with the dist path
42
+ // This covers the case where jobarbiter is run via node directly
43
+ return process.execPath;
44
+ }
45
+
46
+ function generatePlist(intervalSeconds: number): string {
47
+ const binaryPath = resolveJobarbiterPath();
48
+
49
+ // If the binary is node itself, we need to figure out the script path
50
+ const isNodeBinary = binaryPath.includes("node") || binaryPath.includes("bun");
51
+ let programArgs: string;
52
+
53
+ if (isNodeBinary) {
54
+ // Find the actual CLI entry point
55
+ const cliPath = join(__dirname, "..", "index.js");
56
+ programArgs = ` <string>${binaryPath}</string>
57
+ <string>${cliPath}</string>
58
+ <string>observe</string>
59
+ <string>poll</string>`;
60
+ } else {
61
+ programArgs = ` <string>${binaryPath}</string>
62
+ <string>observe</string>
63
+ <string>poll</string>`;
64
+ }
65
+
66
+ return `<?xml version="1.0" encoding="UTF-8"?>
67
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
+ <plist version="1.0">
69
+ <dict>
70
+ <key>Label</key>
71
+ <string>${LABEL}</string>
72
+ <key>ProgramArguments</key>
73
+ <array>
74
+ ${programArgs}
75
+ </array>
76
+ <key>StartInterval</key>
77
+ <integer>${intervalSeconds}</integer>
78
+ <key>StandardOutPath</key>
79
+ <string>${LOG_PATH}</string>
80
+ <key>StandardErrorPath</key>
81
+ <string>${LOG_PATH}</string>
82
+ <key>EnvironmentVariables</key>
83
+ <dict>
84
+ <key>PATH</key>
85
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
86
+ </dict>
87
+ <key>RunAtLoad</key>
88
+ <true/>
89
+ <key>Nice</key>
90
+ <integer>10</integer>
91
+ </dict>
92
+ </plist>
93
+ `;
94
+ }
95
+
96
+ function isLoaded(): boolean {
97
+ try {
98
+ const output = execSync(`launchctl list 2>/dev/null`, { encoding: "utf-8", timeout: 5000 });
99
+ return output.includes(LABEL);
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // ── Public API ─────────────────────────────────────────────────────────
106
+
107
+ export function installDaemon(intervalSeconds = 7200): void {
108
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
109
+ mkdirSync(OBSERVER_DIR, { recursive: true });
110
+
111
+ // Unload existing if present
112
+ if (isLoaded()) {
113
+ try {
114
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
115
+ } catch {
116
+ // May not be loaded
117
+ }
118
+ }
119
+
120
+ // Write plist
121
+ const plist = generatePlist(intervalSeconds);
122
+ writeFileSync(PLIST_PATH, plist);
123
+
124
+ // Load
125
+ try {
126
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
127
+ } catch (err) {
128
+ throw new Error(`Failed to load LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
129
+ }
130
+ }
131
+
132
+ export function uninstallDaemon(): void {
133
+ // Unload
134
+ if (isLoaded()) {
135
+ try {
136
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
137
+ } catch {
138
+ // Best effort
139
+ }
140
+ }
141
+
142
+ // Delete plist
143
+ if (existsSync(PLIST_PATH)) {
144
+ try {
145
+ unlinkSync(PLIST_PATH);
146
+ } catch {
147
+ // Best effort
148
+ }
149
+ }
150
+ }
151
+
152
+ export function getDaemonStatus(): DaemonStatus {
153
+ const installed = existsSync(PLIST_PATH);
154
+ const loaded = isLoaded();
155
+
156
+ let interval: number | null = null;
157
+ if (installed) {
158
+ try {
159
+ const content = readFileSync(PLIST_PATH, "utf-8");
160
+ const match = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
161
+ if (match) {
162
+ interval = parseInt(match[1], 10);
163
+ }
164
+ } catch {
165
+ // Best effort
166
+ }
167
+ }
168
+
169
+ return {
170
+ installed,
171
+ loaded,
172
+ interval,
173
+ plistPath: PLIST_PATH,
174
+ };
175
+ }
176
+
177
+ export function reloadDaemon(): void {
178
+ if (!existsSync(PLIST_PATH)) {
179
+ throw new Error("Daemon not installed. Run 'jobarbiter observe daemon install' first.");
180
+ }
181
+
182
+ try {
183
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
184
+ } catch {
185
+ // May not be loaded
186
+ }
187
+
188
+ try {
189
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
190
+ } catch (err) {
191
+ throw new Error(`Failed to reload daemon: ${err instanceof Error ? err.message : String(err)}`);
192
+ }
193
+ }
@@ -125,9 +125,29 @@ const fs = require("fs");
125
125
  const path = require("path");
126
126
  const os = require("os");
127
127
 
128
- const OBSERVATIONS_FILE = path.join(
129
- os.homedir(), ".config", "jobarbiter", "observer", "observations.json"
130
- );
128
+ const OBSERVER_DIR = path.join(os.homedir(), ".config", "jobarbiter", "observer");
129
+ const OBSERVATIONS_FILE = path.join(OBSERVER_DIR, "observations.json");
130
+ const PAUSED_FILE = path.join(OBSERVER_DIR, "PAUSED");
131
+
132
+ // Check PAUSED sentinel FIRST — if paused, exit immediately
133
+ try {
134
+ if (fs.existsSync(PAUSED_FILE)) {
135
+ const raw = fs.readFileSync(PAUSED_FILE, "utf-8");
136
+ const pauseData = JSON.parse(raw);
137
+ if (pauseData.expiresAt) {
138
+ const expiresAt = new Date(pauseData.expiresAt).getTime();
139
+ if (Date.now() < expiresAt) {
140
+ process.exit(0); // Still paused
141
+ }
142
+ // Expired — clean up and continue
143
+ fs.unlinkSync(PAUSED_FILE);
144
+ } else {
145
+ process.exit(0); // Paused indefinitely
146
+ }
147
+ }
148
+ } catch {
149
+ // If PAUSED file is corrupted, continue
150
+ }
131
151
 
132
152
  // Read stdin
133
153
  let input = "";
@@ -283,8 +303,14 @@ function appendObservation(obs) {
283
303
  // Fire-and-forget: trigger analysis pipeline via CLI
284
304
  // Spawns detached process so the AI tool is never blocked
285
305
  try {
286
- const { spawn } = require("child_process");
287
- // Try npx jobarbiter first, fall back to direct node invocation
306
+ const { spawn, execSync } = require("child_process");
307
+ // Self-healing: verify jobarbiter binary exists before spawning
308
+ try {
309
+ execSync("command -v jobarbiter", { stdio: "ignore", timeout: 3000 });
310
+ } catch {
311
+ // jobarbiter CLI not found — exit silently
312
+ return;
313
+ }
288
314
  const child = spawn("jobarbiter", ["analyze", "--auto"], {
289
315
  detached: true,
290
316
  stdio: "ignore",
@@ -21,6 +21,7 @@ import {
21
21
  type DetectedTool,
22
22
  type ToolCategory,
23
23
  } from "./detect-tools.js";
24
+ import { installDaemon } from "./launchd.js";
24
25
  import {
25
26
  loadProviderKeys,
26
27
  saveProviderKey,
@@ -764,6 +765,24 @@ async function runToolDetectionStep(
764
765
 
765
766
  if (result.installed.length > 0) {
766
767
  console.log(`\n ${sym.check} ${c.success(`${result.installed.length} observer${result.installed.length > 1 ? "s" : ""} installed!`)}`);
768
+
769
+ // Install background polling daemon
770
+ try {
771
+ installDaemon(7200);
772
+ console.log(` ${sym.check} ${c.success("Background poller installed (every 2 hours)")}`);
773
+ } catch {
774
+ console.log(` ${c.dim("Background poller skipped (install later: jobarbiter observe daemon install)")}`);
775
+ }
776
+
777
+ // Show poller-observable tools
778
+ const pollerTools = allTools.filter((t) => t.installed && t.observationMethod === "poller");
779
+ if (pollerTools.length > 0) {
780
+ console.log(`\n ${c.bold("Poller will also scan these tools:")}`);
781
+ for (const t of pollerTools) {
782
+ console.log(` ${sym.bullet} ${formatToolDisplay(t)}`);
783
+ }
784
+ }
785
+
767
786
  console.log(`\n ${c.bold("What happens now?")}`);
768
787
  console.log(` Observers activate each time you use your AI tools.`);
769
788
  console.log(` Just work normally — every session builds your profile.\n`);
@@ -772,6 +791,8 @@ async function runToolDetectionStep(
772
791
  console.log(` a work report to JobArbiter. Our agents interpret these reports`);
773
792
  console.log(` to build and evolve your narrative profile over time.`);
774
793
  console.log(` Raw session data never leaves your machine.\n`);
794
+
795
+ console.log(` ${c.dim("Manage observers: jobarbiter observe --help")}`);
775
796
  }
776
797
  } else {
777
798
  console.log(c.dim("\n Skipped — you can install observers later with 'jobarbiter observe install'.\n"));
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Poll State Management
3
+ *
4
+ * Read/write ~/.config/jobarbiter/observer/poll-state.json
5
+ * Tracks per-source poll times, known sessions, pause windows,
6
+ * disabled sources, and interval configuration.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ export interface PauseWindow {
16
+ start: string; // ISO timestamp
17
+ end: string; // ISO timestamp
18
+ }
19
+
20
+ export interface PollState {
21
+ version: number;
22
+ lastPoll: Record<string, string>; // source -> ISO timestamp
23
+ knownSessionIds: string[]; // rolling 1000
24
+ pauseWindows: PauseWindow[];
25
+ disabledSources: string[];
26
+ interval: number; // seconds
27
+ }
28
+
29
+ // ── Paths ──────────────────────────────────────────────────────────────
30
+
31
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
32
+ const POLL_STATE_FILE = join(OBSERVER_DIR, "poll-state.json");
33
+
34
+ function defaultPollState(): PollState {
35
+ return {
36
+ version: 1,
37
+ lastPoll: {},
38
+ knownSessionIds: [],
39
+ pauseWindows: [],
40
+ disabledSources: [],
41
+ interval: 7200,
42
+ };
43
+ }
44
+
45
+ // ── Core Functions ─────────────────────────────────────────────────────
46
+
47
+ export function loadPollState(): PollState {
48
+ try {
49
+ if (!existsSync(POLL_STATE_FILE)) return defaultPollState();
50
+ const raw = readFileSync(POLL_STATE_FILE, "utf-8");
51
+ const data = JSON.parse(raw) as Partial<PollState>;
52
+ return {
53
+ ...defaultPollState(),
54
+ ...data,
55
+ };
56
+ } catch {
57
+ return defaultPollState();
58
+ }
59
+ }
60
+
61
+ export function savePollState(state: PollState): void {
62
+ mkdirSync(OBSERVER_DIR, { recursive: true });
63
+ writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
64
+ }
65
+
66
+ export function addPauseWindow(start: string, end: string): void {
67
+ const state = loadPollState();
68
+ state.pauseWindows.push({ start, end });
69
+ // Keep last 50 windows
70
+ if (state.pauseWindows.length > 50) {
71
+ state.pauseWindows = state.pauseWindows.slice(-50);
72
+ }
73
+ savePollState(state);
74
+ }
75
+
76
+ export function isInPauseWindow(timestamp: string): boolean {
77
+ const state = loadPollState();
78
+ const ts = new Date(timestamp).getTime();
79
+ return state.pauseWindows.some((w) => {
80
+ const start = new Date(w.start).getTime();
81
+ const end = new Date(w.end).getTime();
82
+ return ts >= start && ts <= end;
83
+ });
84
+ }
85
+
86
+ export function addKnownSession(sessionId: string): void {
87
+ const state = loadPollState();
88
+ if (!state.knownSessionIds.includes(sessionId)) {
89
+ state.knownSessionIds.push(sessionId);
90
+ // Rolling window of 1000
91
+ if (state.knownSessionIds.length > 1000) {
92
+ state.knownSessionIds = state.knownSessionIds.slice(-1000);
93
+ }
94
+ savePollState(state);
95
+ }
96
+ }
97
+
98
+ export function isKnownSession(sessionId: string): boolean {
99
+ const state = loadPollState();
100
+ return state.knownSessionIds.includes(sessionId);
101
+ }
102
+
103
+ export function getDisabledSources(): string[] {
104
+ return loadPollState().disabledSources;
105
+ }
106
+
107
+ export function setDisabledSource(source: string): void {
108
+ const state = loadPollState();
109
+ if (!state.disabledSources.includes(source)) {
110
+ state.disabledSources.push(source);
111
+ savePollState(state);
112
+ }
113
+ }
114
+
115
+ export function removeDisabledSource(source: string): void {
116
+ const state = loadPollState();
117
+ state.disabledSources = state.disabledSources.filter((s) => s !== source);
118
+ savePollState(state);
119
+ }