jobarbiter 0.3.13 → 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 = {
@@ -645,6 +646,22 @@ async function runToolDetectionStep(prompt, config) {
645
646
  }
646
647
  if (result.installed.length > 0) {
647
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
+ }
648
665
  console.log(`\n ${c.bold("What happens now?")}`);
649
666
  console.log(` Observers activate each time you use your AI tools.`);
650
667
  console.log(` Just work normally — every session builds your profile.\n`);
@@ -653,6 +670,7 @@ async function runToolDetectionStep(prompt, config) {
653
670
  console.log(` a work report to JobArbiter. Our agents interpret these reports`);
654
671
  console.log(` to build and evolve your narrative profile over time.`);
655
672
  console.log(` Raw session data never leaves your machine.\n`);
673
+ console.log(` ${c.dim("Manage observers: jobarbiter observe --help")}`);
656
674
  }
657
675
  }
658
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;
@@ -0,0 +1,137 @@
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 { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import { addPauseWindow, loadPollState } from "./poll-state.js";
11
+ // ── Paths ──────────────────────────────────────────────────────────────
12
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
13
+ const PAUSED_FILE = join(OBSERVER_DIR, "PAUSED");
14
+ const OBSERVATIONS_FILE = join(OBSERVER_DIR, "observations.json");
15
+ // ── Functions ──────────────────────────────────────────────────────────
16
+ export function pauseObservation(expiresAt) {
17
+ mkdirSync(OBSERVER_DIR, { recursive: true });
18
+ const data = {
19
+ pausedAt: new Date().toISOString(),
20
+ expiresAt: expiresAt ? expiresAt.toISOString() : null,
21
+ };
22
+ writeFileSync(PAUSED_FILE, JSON.stringify(data, null, 2) + "\n");
23
+ }
24
+ export function resumeObservation() {
25
+ if (!existsSync(PAUSED_FILE))
26
+ return;
27
+ // Read pause data to record pause window
28
+ try {
29
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
30
+ const data = JSON.parse(raw);
31
+ const now = new Date().toISOString();
32
+ addPauseWindow(data.pausedAt, now);
33
+ }
34
+ catch {
35
+ // Best effort
36
+ }
37
+ // Remove PAUSED file
38
+ try {
39
+ unlinkSync(PAUSED_FILE);
40
+ }
41
+ catch {
42
+ // Already removed
43
+ }
44
+ // Sweep observations.json for entries that leaked during pause
45
+ sweepLeakedObservations();
46
+ }
47
+ export function isPaused() {
48
+ if (!existsSync(PAUSED_FILE))
49
+ return false;
50
+ try {
51
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
52
+ const data = JSON.parse(raw);
53
+ // Check if expired
54
+ if (data.expiresAt) {
55
+ const expiresAt = new Date(data.expiresAt).getTime();
56
+ if (Date.now() >= expiresAt) {
57
+ // Auto-resume: expired
58
+ autoResume(data);
59
+ return false;
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ export function getPauseStatus() {
69
+ const state = loadPollState();
70
+ const result = {
71
+ paused: false,
72
+ pausedAt: null,
73
+ expiresAt: null,
74
+ pauseWindows: state.pauseWindows,
75
+ };
76
+ if (!existsSync(PAUSED_FILE))
77
+ return result;
78
+ try {
79
+ const raw = readFileSync(PAUSED_FILE, "utf-8");
80
+ const data = JSON.parse(raw);
81
+ // Check for expiry
82
+ if (data.expiresAt) {
83
+ const expiresAt = new Date(data.expiresAt).getTime();
84
+ if (Date.now() >= expiresAt) {
85
+ autoResume(data);
86
+ return result;
87
+ }
88
+ }
89
+ result.paused = true;
90
+ result.pausedAt = data.pausedAt;
91
+ result.expiresAt = data.expiresAt;
92
+ }
93
+ catch {
94
+ // Corrupted PAUSED file
95
+ }
96
+ return result;
97
+ }
98
+ // ── Internal Helpers ───────────────────────────────────────────────────
99
+ function autoResume(data) {
100
+ const now = new Date().toISOString();
101
+ addPauseWindow(data.pausedAt, data.expiresAt || now);
102
+ try {
103
+ unlinkSync(PAUSED_FILE);
104
+ }
105
+ catch {
106
+ // Already removed
107
+ }
108
+ sweepLeakedObservations();
109
+ }
110
+ function sweepLeakedObservations() {
111
+ if (!existsSync(OBSERVATIONS_FILE))
112
+ return;
113
+ try {
114
+ const raw = readFileSync(OBSERVATIONS_FILE, "utf-8");
115
+ const data = JSON.parse(raw);
116
+ if (!Array.isArray(data.sessions))
117
+ return;
118
+ const state = loadPollState();
119
+ const originalCount = data.sessions.length;
120
+ data.sessions = data.sessions.filter((session) => {
121
+ if (!session.timestamp)
122
+ return true;
123
+ const ts = new Date(session.timestamp).getTime();
124
+ return !state.pauseWindows.some((w) => {
125
+ const start = new Date(w.start).getTime();
126
+ const end = new Date(w.end).getTime();
127
+ return ts >= start && ts <= end;
128
+ });
129
+ });
130
+ if (data.sessions.length !== originalCount) {
131
+ writeFileSync(OBSERVATIONS_FILE, JSON.stringify(data, null, 2) + "\n");
132
+ }
133
+ }
134
+ catch {
135
+ // Best effort
136
+ }
137
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Transcript Poller
3
+ *
4
+ * Core polling logic that discovers and processes new transcripts
5
+ * from AI tools that don't support hooks (aider, goose, letta, etc.).
6
+ * Uses existing discoverTranscripts() and parseTranscriptFile() from
7
+ * transcript-reader.ts.
8
+ */
9
+ export interface PollResult {
10
+ sourcesPolled: string[];
11
+ newSessions: number;
12
+ skippedPaused: number;
13
+ skippedDuplicate: number;
14
+ errors: string[];
15
+ }
16
+ interface PollOptions {
17
+ dryRun?: boolean;
18
+ source?: string;
19
+ verbose?: boolean;
20
+ since?: Date;
21
+ }
22
+ export declare function pollAllSources(options?: PollOptions): Promise<PollResult>;
23
+ export {};