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.
- package/dist/index.js +347 -3
- package/dist/lib/detect-tools.d.ts +2 -0
- package/dist/lib/detect-tools.js +29 -0
- package/dist/lib/launchd.d.ts +16 -0
- package/dist/lib/launchd.js +171 -0
- package/dist/lib/observe.js +31 -5
- package/dist/lib/onboard.js +18 -0
- package/dist/lib/poll-state.d.ts +28 -0
- package/dist/lib/poll-state.js +91 -0
- package/dist/lib/privacy-pause.d.ts +17 -0
- package/dist/lib/privacy-pause.js +137 -0
- package/dist/lib/transcript-poller.d.ts +23 -0
- package/dist/lib/transcript-poller.js +218 -0
- package/dist/lib/uninstall.d.ts +20 -0
- package/dist/lib/uninstall.js +81 -0
- package/package.json +1 -1
- package/src/index.ts +369 -5
- package/src/lib/detect-tools.ts +33 -0
- package/src/lib/launchd.ts +193 -0
- package/src/lib/observe.ts +31 -5
- package/src/lib/onboard.ts +21 -0
- package/src/lib/poll-state.ts +119 -0
- package/src/lib/privacy-pause.ts +167 -0
- package/src/lib/transcript-poller.ts +274 -0
- package/src/lib/uninstall.ts +104 -0
|
@@ -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
|
+
}
|
package/dist/lib/observe.js
CHANGED
|
@@ -91,9 +91,29 @@ const fs = require("fs");
|
|
|
91
91
|
const path = require("path");
|
|
92
92
|
const os = require("os");
|
|
93
93
|
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|
-
//
|
|
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",
|
package/dist/lib/onboard.js
CHANGED
|
@@ -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 {};
|