jobarbiter 0.3.12 → 0.4.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.
@@ -0,0 +1,171 @@
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
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { execSync } from "node:child_process";
11
+ // ── Constants ──────────────────────────────────────────────────────────
12
+ const LABEL = "ai.jobarbiter.observer";
13
+ const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents");
14
+ const PLIST_PATH = join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`);
15
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
16
+ const LOG_PATH = join(OBSERVER_DIR, "poll.log");
17
+ // ── Helpers ────────────────────────────────────────────────────────────
18
+ function resolveJobarbiterPath() {
19
+ // Try to find the jobarbiter binary
20
+ try {
21
+ const path = execSync("command -v jobarbiter", { encoding: "utf-8", timeout: 3000 }).trim();
22
+ if (path)
23
+ return path;
24
+ }
25
+ catch {
26
+ // Fall through
27
+ }
28
+ // Fallback: use process.execPath with the dist path
29
+ // This covers the case where jobarbiter is run via node directly
30
+ return process.execPath;
31
+ }
32
+ function generatePlist(intervalSeconds) {
33
+ const binaryPath = resolveJobarbiterPath();
34
+ // If the binary is node itself, we need to figure out the script path
35
+ const isNodeBinary = binaryPath.includes("node") || binaryPath.includes("bun");
36
+ let programArgs;
37
+ if (isNodeBinary) {
38
+ // Find the actual CLI entry point
39
+ const cliPath = join(__dirname, "..", "index.js");
40
+ programArgs = ` <string>${binaryPath}</string>
41
+ <string>${cliPath}</string>
42
+ <string>observe</string>
43
+ <string>poll</string>`;
44
+ }
45
+ else {
46
+ programArgs = ` <string>${binaryPath}</string>
47
+ <string>observe</string>
48
+ <string>poll</string>`;
49
+ }
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0">
53
+ <dict>
54
+ <key>Label</key>
55
+ <string>${LABEL}</string>
56
+ <key>ProgramArguments</key>
57
+ <array>
58
+ ${programArgs}
59
+ </array>
60
+ <key>StartInterval</key>
61
+ <integer>${intervalSeconds}</integer>
62
+ <key>StandardOutPath</key>
63
+ <string>${LOG_PATH}</string>
64
+ <key>StandardErrorPath</key>
65
+ <string>${LOG_PATH}</string>
66
+ <key>EnvironmentVariables</key>
67
+ <dict>
68
+ <key>PATH</key>
69
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
70
+ </dict>
71
+ <key>RunAtLoad</key>
72
+ <true/>
73
+ <key>Nice</key>
74
+ <integer>10</integer>
75
+ </dict>
76
+ </plist>
77
+ `;
78
+ }
79
+ function isLoaded() {
80
+ try {
81
+ const output = execSync(`launchctl list 2>/dev/null`, { encoding: "utf-8", timeout: 5000 });
82
+ return output.includes(LABEL);
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ // ── Public API ─────────────────────────────────────────────────────────
89
+ export function installDaemon(intervalSeconds = 7200) {
90
+ mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
91
+ mkdirSync(OBSERVER_DIR, { recursive: true });
92
+ // Unload existing if present
93
+ if (isLoaded()) {
94
+ try {
95
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
96
+ }
97
+ catch {
98
+ // May not be loaded
99
+ }
100
+ }
101
+ // Write plist
102
+ const plist = generatePlist(intervalSeconds);
103
+ writeFileSync(PLIST_PATH, plist);
104
+ // Load
105
+ try {
106
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
107
+ }
108
+ catch (err) {
109
+ throw new Error(`Failed to load LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
110
+ }
111
+ }
112
+ export function uninstallDaemon() {
113
+ // Unload
114
+ if (isLoaded()) {
115
+ try {
116
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
117
+ }
118
+ catch {
119
+ // Best effort
120
+ }
121
+ }
122
+ // Delete plist
123
+ if (existsSync(PLIST_PATH)) {
124
+ try {
125
+ unlinkSync(PLIST_PATH);
126
+ }
127
+ catch {
128
+ // Best effort
129
+ }
130
+ }
131
+ }
132
+ export function getDaemonStatus() {
133
+ const installed = existsSync(PLIST_PATH);
134
+ const loaded = isLoaded();
135
+ let interval = null;
136
+ if (installed) {
137
+ try {
138
+ const content = readFileSync(PLIST_PATH, "utf-8");
139
+ const match = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
140
+ if (match) {
141
+ interval = parseInt(match[1], 10);
142
+ }
143
+ }
144
+ catch {
145
+ // Best effort
146
+ }
147
+ }
148
+ return {
149
+ installed,
150
+ loaded,
151
+ interval,
152
+ plistPath: PLIST_PATH,
153
+ };
154
+ }
155
+ export function reloadDaemon() {
156
+ if (!existsSync(PLIST_PATH)) {
157
+ throw new Error("Daemon not installed. Run 'jobarbiter observe daemon install' first.");
158
+ }
159
+ try {
160
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { timeout: 5000 });
161
+ }
162
+ catch {
163
+ // May not be loaded
164
+ }
165
+ try {
166
+ execSync(`launchctl load "${PLIST_PATH}"`, { timeout: 5000 });
167
+ }
168
+ catch (err) {
169
+ throw new Error(`Failed to reload daemon: ${err instanceof Error ? err.message : String(err)}`);
170
+ }
171
+ }
@@ -91,9 +91,29 @@ const fs = require("fs");
91
91
  const path = require("path");
92
92
  const os = require("os");
93
93
 
94
- const OBSERVATIONS_FILE = path.join(
95
- os.homedir(), ".config", "jobarbiter", "observer", "observations.json"
96
- );
94
+ const OBSERVER_DIR = path.join(os.homedir(), ".config", "jobarbiter", "observer");
95
+ const OBSERVATIONS_FILE = path.join(OBSERVER_DIR, "observations.json");
96
+ const PAUSED_FILE = path.join(OBSERVER_DIR, "PAUSED");
97
+
98
+ // Check PAUSED sentinel FIRST — if paused, exit immediately
99
+ try {
100
+ if (fs.existsSync(PAUSED_FILE)) {
101
+ const raw = fs.readFileSync(PAUSED_FILE, "utf-8");
102
+ const pauseData = JSON.parse(raw);
103
+ if (pauseData.expiresAt) {
104
+ const expiresAt = new Date(pauseData.expiresAt).getTime();
105
+ if (Date.now() < expiresAt) {
106
+ process.exit(0); // Still paused
107
+ }
108
+ // Expired — clean up and continue
109
+ fs.unlinkSync(PAUSED_FILE);
110
+ } else {
111
+ process.exit(0); // Paused indefinitely
112
+ }
113
+ }
114
+ } catch {
115
+ // If PAUSED file is corrupted, continue
116
+ }
97
117
 
98
118
  // Read stdin
99
119
  let input = "";
@@ -249,8 +269,14 @@ function appendObservation(obs) {
249
269
  // Fire-and-forget: trigger analysis pipeline via CLI
250
270
  // Spawns detached process so the AI tool is never blocked
251
271
  try {
252
- const { spawn } = require("child_process");
253
- // Try npx jobarbiter first, fall back to direct node invocation
272
+ const { spawn, execSync } = require("child_process");
273
+ // Self-healing: verify jobarbiter binary exists before spawning
274
+ try {
275
+ execSync("command -v jobarbiter", { stdio: "ignore", timeout: 3000 });
276
+ } catch {
277
+ // jobarbiter CLI not found — exit silently
278
+ return;
279
+ }
254
280
  const child = spawn("jobarbiter", ["analyze", "--auto"], {
255
281
  detached: true,
256
282
  stdio: "ignore",
@@ -13,6 +13,7 @@ import { loadConfig, saveConfig, getConfigPath } from "./config.js";
13
13
  import { apiUnauthenticated, api, ApiError } from "./api.js";
14
14
  import { installObservers } from "./observe.js";
15
15
  import { detectAllTools, formatToolDisplay, } from "./detect-tools.js";
16
+ import { installDaemon } from "./launchd.js";
16
17
  import { loadProviderKeys, saveProviderKey, validateProviderKey, } from "./providers.js";
17
18
  // ── ANSI Colors ────────────────────────────────────────────────────────
18
19
  const colors = {
@@ -160,19 +161,68 @@ export async function runOnboardWizard(opts) {
160
161
  const userType = await selectUserType(prompt);
161
162
  state.userType = userType;
162
163
  // Step 2: Email & Verification
163
- const { email, apiKey, userId } = await handleEmailVerification(prompt, baseUrl, userType);
164
- state.email = email;
165
- state.apiKey = apiKey;
166
- state.userId = userId;
164
+ const verificationResult = await handleEmailVerification(prompt, baseUrl, userType);
165
+ state.email = verificationResult.email;
166
+ state.apiKey = verificationResult.apiKey;
167
+ state.userId = verificationResult.userId;
168
+ // If returning user, override userType from backend
169
+ const effectiveUserType = verificationResult.isReturningUser && verificationResult.userType
170
+ ? verificationResult.userType
171
+ : userType;
172
+ state.userType = effectiveUserType;
167
173
  // Save config immediately after verification (with step progress)
168
174
  saveConfig({
169
- apiKey,
175
+ apiKey: verificationResult.apiKey,
170
176
  baseUrl,
171
- userType,
177
+ userType: effectiveUserType,
172
178
  onboardingStep: 1,
173
179
  onboardingComplete: false,
174
180
  });
175
- if (userType === "worker") {
181
+ // Returning user: show progress and resume from first incomplete step
182
+ if (verificationResult.isReturningUser) {
183
+ const config = {
184
+ apiKey: verificationResult.apiKey,
185
+ baseUrl,
186
+ userType: effectiveUserType,
187
+ };
188
+ const progress = await fetchOnboardingProgress(config);
189
+ const { firstIncomplete } = showReturningUserProgress(progress);
190
+ if (firstIncomplete > (effectiveUserType === "worker" ? 6 : 5)) {
191
+ // Everything done except completion step
192
+ const continueAnyway = await prompt.confirm(`All steps look complete. Re-run onboarding anyway?`, false);
193
+ if (!continueAnyway) {
194
+ saveConfig({ ...config, onboardingComplete: true, onboardingStep: effectiveUserType === "worker" ? 7 : 6 });
195
+ console.log(`\n${sym.check} ${c.success("You're all set!")} Run ${c.highlight("jobarbiter status")} to check your account.\n`);
196
+ prompt.close();
197
+ return;
198
+ }
199
+ }
200
+ else {
201
+ const continueFromStep = await prompt.confirm(`Continue from step ${firstIncomplete}?`);
202
+ if (!continueFromStep) {
203
+ // Let them start from step 2
204
+ console.log(c.dim("Starting from the beginning...\n"));
205
+ if (effectiveUserType === "worker") {
206
+ await runWorkerFlow(prompt, state, 2);
207
+ }
208
+ else {
209
+ await runEmployerFlow(prompt, state);
210
+ }
211
+ prompt.close();
212
+ return;
213
+ }
214
+ }
215
+ // Resume from firstIncomplete
216
+ if (effectiveUserType === "worker") {
217
+ await runWorkerFlow(prompt, state, firstIncomplete);
218
+ }
219
+ else {
220
+ await runEmployerFlow(prompt, state);
221
+ }
222
+ prompt.close();
223
+ return;
224
+ }
225
+ if (effectiveUserType === "worker") {
176
226
  await runWorkerFlow(prompt, state);
177
227
  }
178
228
  else {
@@ -233,6 +283,7 @@ async function handleEmailVerification(prompt, baseUrl, userType) {
233
283
  }
234
284
  // Call register API
235
285
  console.log(c.dim("\nSending verification code..."));
286
+ let isReturningUser = false;
236
287
  try {
237
288
  await apiUnauthenticated(baseUrl, "POST", "/v1/auth/register", {
238
289
  email,
@@ -241,18 +292,34 @@ async function handleEmailVerification(prompt, baseUrl, userType) {
241
292
  }
242
293
  catch (err) {
243
294
  if (err instanceof ApiError && err.status === 409) {
244
- // Email already registered and verified
245
- throw new Error(`This email is already registered. Run 'jobarbiter verify-email --email ${email}' if you need to re-verify, or use a different email.`);
295
+ // Email already registered switch to login flow
296
+ isReturningUser = true;
297
+ console.log(`\n${sym.rocket} ${c.bold("Welcome back!")} Sending verification code...`);
298
+ try {
299
+ await apiUnauthenticated(baseUrl, "POST", "/v1/auth/login", { email });
300
+ }
301
+ catch (loginErr) {
302
+ throw new Error("Could not send login verification code. Please try again later.");
303
+ }
304
+ }
305
+ else {
306
+ throw err;
246
307
  }
247
- throw err;
248
308
  }
249
- console.log(`\n${sym.check} Verification code sent to ${c.highlight(email)}`);
309
+ if (!isReturningUser) {
310
+ console.log(`\n${sym.check} Verification code sent to ${c.highlight(email)}`);
311
+ }
312
+ else {
313
+ console.log(`${sym.check} Verification code sent to ${c.highlight(email)}`);
314
+ }
250
315
  console.log(c.dim(" (Check your inbox and spam folder. Code expires in 15 minutes.)\n"));
251
316
  // Get verification code
252
317
  let apiKey;
253
318
  let userId;
319
+ let returnedUserType;
254
320
  let attempts = 0;
255
321
  const maxAttempts = 5;
322
+ const verifyEndpoint = isReturningUser ? "/v1/auth/verify-login" : "/v1/auth/verify";
256
323
  while (attempts < maxAttempts) {
257
324
  const code = await prompt.question(`Enter 6-digit code: `);
258
325
  if (!code || code.length !== 6) {
@@ -260,12 +327,15 @@ async function handleEmailVerification(prompt, baseUrl, userType) {
260
327
  continue;
261
328
  }
262
329
  try {
263
- const result = await apiUnauthenticated(baseUrl, "POST", "/v1/auth/verify", {
330
+ const result = await apiUnauthenticated(baseUrl, "POST", verifyEndpoint, {
264
331
  email,
265
332
  code: code.trim(),
266
333
  });
267
334
  apiKey = result.apiKey;
268
335
  userId = result.id;
336
+ if (isReturningUser) {
337
+ returnedUserType = result.userType;
338
+ }
269
339
  break;
270
340
  }
271
341
  catch (err) {
@@ -287,8 +357,105 @@ async function handleEmailVerification(prompt, baseUrl, userType) {
287
357
  if (!apiKey || !userId) {
288
358
  throw new Error("Verification failed. Please try again.");
289
359
  }
290
- console.log(`\n${sym.check} ${c.success("Email verified! Account created.")}\n`);
291
- return { email, apiKey, userId };
360
+ if (isReturningUser) {
361
+ console.log(`\n${sym.check} ${c.success("Welcome back! Logged in successfully.")}\n`);
362
+ }
363
+ else {
364
+ console.log(`\n${sym.check} ${c.success("Email verified! Account created.")}\n`);
365
+ }
366
+ return { email, apiKey, userId, isReturningUser, userType: returnedUserType };
367
+ }
368
+ async function fetchOnboardingProgress(config) {
369
+ const progress = {
370
+ accountCreated: true,
371
+ userType: config.userType,
372
+ toolsDetected: [],
373
+ aiAccountsConnected: false,
374
+ domainsSet: [],
375
+ githubConnected: false,
376
+ linkedinConnected: false,
377
+ };
378
+ // Fetch profile
379
+ try {
380
+ const profile = await api(config, "GET", "/v1/profile");
381
+ if (profile.domains && Array.isArray(profile.domains) && profile.domains.length > 0) {
382
+ progress.domainsSet = profile.domains;
383
+ }
384
+ if (profile.tools && typeof profile.tools === "object") {
385
+ const tools = profile.tools;
386
+ if (tools.primary && Array.isArray(tools.primary) && tools.primary.length > 0) {
387
+ progress.toolsDetected = tools.primary;
388
+ }
389
+ }
390
+ if (profile.bio) {
391
+ progress.bio = profile.bio;
392
+ }
393
+ if (profile.githubUsername) {
394
+ progress.githubConnected = true;
395
+ progress.githubUsername = profile.githubUsername;
396
+ }
397
+ }
398
+ catch {
399
+ // Profile doesn't exist yet — that's fine
400
+ }
401
+ // Check AI accounts
402
+ const existingProviders = loadProviderKeys();
403
+ progress.aiAccountsConnected = existingProviders.length > 0;
404
+ // Check verification status (LinkedIn etc.)
405
+ try {
406
+ const verificationStatus = await api(config, "GET", "/v1/verification/status");
407
+ if (verificationStatus.linkedin) {
408
+ progress.linkedinConnected = true;
409
+ }
410
+ }
411
+ catch {
412
+ // Endpoint may not exist — gracefully ignore
413
+ }
414
+ return progress;
415
+ }
416
+ function showReturningUserProgress(progress) {
417
+ const isWorker = progress.userType === "worker";
418
+ const totalSteps = isWorker ? 7 : 6;
419
+ console.log(`${c.bold("Here's your onboarding progress:")}\n`);
420
+ if (isWorker) {
421
+ const steps = [
422
+ { done: true, label: `Account created (${progress.userType})` },
423
+ { done: progress.toolsDetected.length > 0, label: progress.toolsDetected.length > 0
424
+ ? `AI Tools detected (${progress.toolsDetected.join(", ")})`
425
+ : "AI Tools not detected" },
426
+ { done: progress.aiAccountsConnected, label: progress.aiAccountsConnected
427
+ ? "AI Accounts connected"
428
+ : "AI Accounts not connected" },
429
+ { done: progress.domainsSet.length > 0, label: progress.domainsSet.length > 0
430
+ ? `Domains set (${progress.domainsSet.join(", ")})`
431
+ : "Domains not set" },
432
+ { done: progress.githubConnected, label: progress.githubConnected
433
+ ? `GitHub connected (${progress.githubUsername})`
434
+ : "GitHub not connected" },
435
+ { done: progress.linkedinConnected, label: progress.linkedinConnected
436
+ ? "LinkedIn connected"
437
+ : "LinkedIn not connected" },
438
+ { done: false, label: "Completion" },
439
+ ];
440
+ let firstIncomplete = totalSteps; // default to last
441
+ for (let i = 0; i < steps.length; i++) {
442
+ const step = steps[i];
443
+ const icon = step.done ? sym.check : sym.cross;
444
+ console.log(` ${icon} Step ${i + 1}/${totalSteps} — ${step.label}`);
445
+ if (!step.done && firstIncomplete === totalSteps) {
446
+ firstIncomplete = i + 1;
447
+ }
448
+ }
449
+ console.log();
450
+ return { firstIncomplete };
451
+ }
452
+ else {
453
+ // Employer — simpler progress
454
+ console.log(` ${sym.check} Step 1/6 — Account created (employer)`);
455
+ console.log(` ${sym.cross} Step 2/6 — Remaining setup`);
456
+ console.log();
457
+ return { firstIncomplete: 2 };
458
+ }
292
459
  }
293
460
  // ── Worker Flow ────────────────────────────────────────────────────────
294
461
  async function runWorkerFlow(prompt, state, startStep = 2) {
@@ -479,6 +646,22 @@ async function runToolDetectionStep(prompt, config) {
479
646
  }
480
647
  if (result.installed.length > 0) {
481
648
  console.log(`\n ${sym.check} ${c.success(`${result.installed.length} observer${result.installed.length > 1 ? "s" : ""} installed!`)}`);
649
+ // Install background polling daemon
650
+ try {
651
+ installDaemon(7200);
652
+ console.log(` ${sym.check} ${c.success("Background poller installed (every 2 hours)")}`);
653
+ }
654
+ catch {
655
+ console.log(` ${c.dim("Background poller skipped (install later: jobarbiter observe daemon install)")}`);
656
+ }
657
+ // Show poller-observable tools
658
+ const pollerTools = allTools.filter((t) => t.installed && t.observationMethod === "poller");
659
+ if (pollerTools.length > 0) {
660
+ console.log(`\n ${c.bold("Poller will also scan these tools:")}`);
661
+ for (const t of pollerTools) {
662
+ console.log(` ${sym.bullet} ${formatToolDisplay(t)}`);
663
+ }
664
+ }
482
665
  console.log(`\n ${c.bold("What happens now?")}`);
483
666
  console.log(` Observers activate each time you use your AI tools.`);
484
667
  console.log(` Just work normally — every session builds your profile.\n`);
@@ -487,6 +670,7 @@ async function runToolDetectionStep(prompt, config) {
487
670
  console.log(` a work report to JobArbiter. Our agents interpret these reports`);
488
671
  console.log(` to build and evolve your narrative profile over time.`);
489
672
  console.log(` Raw session data never leaves your machine.\n`);
673
+ console.log(` ${c.dim("Manage observers: jobarbiter observe --help")}`);
490
674
  }
491
675
  }
492
676
  else {
@@ -0,0 +1,28 @@
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
+ export interface PauseWindow {
9
+ start: string;
10
+ end: string;
11
+ }
12
+ export interface PollState {
13
+ version: number;
14
+ lastPoll: Record<string, string>;
15
+ knownSessionIds: string[];
16
+ pauseWindows: PauseWindow[];
17
+ disabledSources: string[];
18
+ interval: number;
19
+ }
20
+ export declare function loadPollState(): PollState;
21
+ export declare function savePollState(state: PollState): void;
22
+ export declare function addPauseWindow(start: string, end: string): void;
23
+ export declare function isInPauseWindow(timestamp: string): boolean;
24
+ export declare function addKnownSession(sessionId: string): void;
25
+ export declare function isKnownSession(sessionId: string): boolean;
26
+ export declare function getDisabledSources(): string[];
27
+ export declare function setDisabledSource(source: string): void;
28
+ export declare function removeDisabledSource(source: string): void;
@@ -0,0 +1,91 @@
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
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ // ── Paths ──────────────────────────────────────────────────────────────
12
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
13
+ const POLL_STATE_FILE = join(OBSERVER_DIR, "poll-state.json");
14
+ function defaultPollState() {
15
+ return {
16
+ version: 1,
17
+ lastPoll: {},
18
+ knownSessionIds: [],
19
+ pauseWindows: [],
20
+ disabledSources: [],
21
+ interval: 7200,
22
+ };
23
+ }
24
+ // ── Core Functions ─────────────────────────────────────────────────────
25
+ export function loadPollState() {
26
+ try {
27
+ if (!existsSync(POLL_STATE_FILE))
28
+ return defaultPollState();
29
+ const raw = readFileSync(POLL_STATE_FILE, "utf-8");
30
+ const data = JSON.parse(raw);
31
+ return {
32
+ ...defaultPollState(),
33
+ ...data,
34
+ };
35
+ }
36
+ catch {
37
+ return defaultPollState();
38
+ }
39
+ }
40
+ export function savePollState(state) {
41
+ mkdirSync(OBSERVER_DIR, { recursive: true });
42
+ writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
43
+ }
44
+ export function addPauseWindow(start, end) {
45
+ const state = loadPollState();
46
+ state.pauseWindows.push({ start, end });
47
+ // Keep last 50 windows
48
+ if (state.pauseWindows.length > 50) {
49
+ state.pauseWindows = state.pauseWindows.slice(-50);
50
+ }
51
+ savePollState(state);
52
+ }
53
+ export function isInPauseWindow(timestamp) {
54
+ const state = loadPollState();
55
+ const ts = new Date(timestamp).getTime();
56
+ return state.pauseWindows.some((w) => {
57
+ const start = new Date(w.start).getTime();
58
+ const end = new Date(w.end).getTime();
59
+ return ts >= start && ts <= end;
60
+ });
61
+ }
62
+ export function addKnownSession(sessionId) {
63
+ const state = loadPollState();
64
+ if (!state.knownSessionIds.includes(sessionId)) {
65
+ state.knownSessionIds.push(sessionId);
66
+ // Rolling window of 1000
67
+ if (state.knownSessionIds.length > 1000) {
68
+ state.knownSessionIds = state.knownSessionIds.slice(-1000);
69
+ }
70
+ savePollState(state);
71
+ }
72
+ }
73
+ export function isKnownSession(sessionId) {
74
+ const state = loadPollState();
75
+ return state.knownSessionIds.includes(sessionId);
76
+ }
77
+ export function getDisabledSources() {
78
+ return loadPollState().disabledSources;
79
+ }
80
+ export function setDisabledSource(source) {
81
+ const state = loadPollState();
82
+ if (!state.disabledSources.includes(source)) {
83
+ state.disabledSources.push(source);
84
+ savePollState(state);
85
+ }
86
+ }
87
+ export function removeDisabledSource(source) {
88
+ const state = loadPollState();
89
+ state.disabledSources = state.disabledSources.filter((s) => s !== source);
90
+ savePollState(state);
91
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Privacy Pause Management
3
+ *
4
+ * Manages the PAUSED sentinel file at ~/.config/jobarbiter/observer/PAUSED
5
+ * When paused, all observation hooks and pollers exit immediately.
6
+ */
7
+ import { type PauseWindow } from "./poll-state.js";
8
+ export interface PauseStatus {
9
+ paused: boolean;
10
+ pausedAt: string | null;
11
+ expiresAt: string | null;
12
+ pauseWindows: PauseWindow[];
13
+ }
14
+ export declare function pauseObservation(expiresAt?: Date): void;
15
+ export declare function resumeObservation(): void;
16
+ export declare function isPaused(): boolean;
17
+ export declare function getPauseStatus(): PauseStatus;