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.
- 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 +198 -14
- 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 +221 -16
- 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,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
|
+
}
|
package/src/lib/observe.ts
CHANGED
|
@@ -125,9 +125,29 @@ const fs = require("fs");
|
|
|
125
125
|
const path = require("path");
|
|
126
126
|
const os = require("os");
|
|
127
127
|
|
|
128
|
-
const
|
|
129
|
-
|
|
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
|
-
//
|
|
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",
|
package/src/lib/onboard.ts
CHANGED
|
@@ -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,
|
|
@@ -215,21 +216,71 @@ export async function runOnboardWizard(opts: { force?: boolean; baseUrl?: string
|
|
|
215
216
|
state.userType = userType;
|
|
216
217
|
|
|
217
218
|
// Step 2: Email & Verification
|
|
218
|
-
const
|
|
219
|
-
state.email = email;
|
|
220
|
-
state.apiKey = apiKey;
|
|
221
|
-
state.userId = userId;
|
|
219
|
+
const verificationResult = await handleEmailVerification(prompt, baseUrl, userType);
|
|
220
|
+
state.email = verificationResult.email;
|
|
221
|
+
state.apiKey = verificationResult.apiKey;
|
|
222
|
+
state.userId = verificationResult.userId;
|
|
223
|
+
|
|
224
|
+
// If returning user, override userType from backend
|
|
225
|
+
const effectiveUserType = verificationResult.isReturningUser && verificationResult.userType
|
|
226
|
+
? (verificationResult.userType as "worker" | "employer")
|
|
227
|
+
: userType;
|
|
228
|
+
state.userType = effectiveUserType;
|
|
222
229
|
|
|
223
230
|
// Save config immediately after verification (with step progress)
|
|
224
231
|
saveConfig({
|
|
225
|
-
apiKey,
|
|
232
|
+
apiKey: verificationResult.apiKey,
|
|
226
233
|
baseUrl,
|
|
227
|
-
userType,
|
|
234
|
+
userType: effectiveUserType,
|
|
228
235
|
onboardingStep: 1,
|
|
229
236
|
onboardingComplete: false,
|
|
230
237
|
});
|
|
231
238
|
|
|
232
|
-
|
|
239
|
+
// Returning user: show progress and resume from first incomplete step
|
|
240
|
+
if (verificationResult.isReturningUser) {
|
|
241
|
+
const config: Config = {
|
|
242
|
+
apiKey: verificationResult.apiKey,
|
|
243
|
+
baseUrl,
|
|
244
|
+
userType: effectiveUserType,
|
|
245
|
+
};
|
|
246
|
+
const progress = await fetchOnboardingProgress(config);
|
|
247
|
+
const { firstIncomplete } = showReturningUserProgress(progress);
|
|
248
|
+
|
|
249
|
+
if (firstIncomplete > (effectiveUserType === "worker" ? 6 : 5)) {
|
|
250
|
+
// Everything done except completion step
|
|
251
|
+
const continueAnyway = await prompt.confirm(`All steps look complete. Re-run onboarding anyway?`, false);
|
|
252
|
+
if (!continueAnyway) {
|
|
253
|
+
saveConfig({ ...config, onboardingComplete: true, onboardingStep: effectiveUserType === "worker" ? 7 : 6 });
|
|
254
|
+
console.log(`\n${sym.check} ${c.success("You're all set!")} Run ${c.highlight("jobarbiter status")} to check your account.\n`);
|
|
255
|
+
prompt.close();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
const continueFromStep = await prompt.confirm(`Continue from step ${firstIncomplete}?`);
|
|
260
|
+
if (!continueFromStep) {
|
|
261
|
+
// Let them start from step 2
|
|
262
|
+
console.log(c.dim("Starting from the beginning...\n"));
|
|
263
|
+
if (effectiveUserType === "worker") {
|
|
264
|
+
await runWorkerFlow(prompt, state as OnboardState, 2);
|
|
265
|
+
} else {
|
|
266
|
+
await runEmployerFlow(prompt, state as OnboardState);
|
|
267
|
+
}
|
|
268
|
+
prompt.close();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Resume from firstIncomplete
|
|
274
|
+
if (effectiveUserType === "worker") {
|
|
275
|
+
await runWorkerFlow(prompt, state as OnboardState, firstIncomplete);
|
|
276
|
+
} else {
|
|
277
|
+
await runEmployerFlow(prompt, state as OnboardState);
|
|
278
|
+
}
|
|
279
|
+
prompt.close();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (effectiveUserType === "worker") {
|
|
233
284
|
await runWorkerFlow(prompt, state as OnboardState);
|
|
234
285
|
} else {
|
|
235
286
|
await runEmployerFlow(prompt, state as OnboardState);
|
|
@@ -282,7 +333,7 @@ async function handleEmailVerification(
|
|
|
282
333
|
prompt: Prompt,
|
|
283
334
|
baseUrl: string,
|
|
284
335
|
userType: "worker" | "employer"
|
|
285
|
-
): Promise<{ email: string; apiKey: string; userId: string }> {
|
|
336
|
+
): Promise<{ email: string; apiKey: string; userId: string; isReturningUser: boolean; userType?: string }> {
|
|
286
337
|
// Workers: 1) Account, 2) Tool Detection, 3) AI Accounts, 4) Domains, 5) GitHub, 6) LinkedIn, 7) Done
|
|
287
338
|
// Employers: 1) Account, 2) (skip verification), 3) Company, 4) Domain, 5) What You Need, 6) Done (stays at 6)
|
|
288
339
|
const totalSteps = userType === "employer" ? 6 : 7;
|
|
@@ -300,6 +351,8 @@ async function handleEmailVerification(
|
|
|
300
351
|
// Call register API
|
|
301
352
|
console.log(c.dim("\nSending verification code..."));
|
|
302
353
|
|
|
354
|
+
let isReturningUser = false;
|
|
355
|
+
|
|
303
356
|
try {
|
|
304
357
|
await apiUnauthenticated(baseUrl, "POST", "/v1/auth/register", {
|
|
305
358
|
email,
|
|
@@ -307,37 +360,53 @@ async function handleEmailVerification(
|
|
|
307
360
|
});
|
|
308
361
|
} catch (err) {
|
|
309
362
|
if (err instanceof ApiError && err.status === 409) {
|
|
310
|
-
// Email already registered
|
|
311
|
-
|
|
363
|
+
// Email already registered — switch to login flow
|
|
364
|
+
isReturningUser = true;
|
|
365
|
+
console.log(`\n${sym.rocket} ${c.bold("Welcome back!")} Sending verification code...`);
|
|
366
|
+
try {
|
|
367
|
+
await apiUnauthenticated(baseUrl, "POST", "/v1/auth/login", { email });
|
|
368
|
+
} catch (loginErr) {
|
|
369
|
+
throw new Error("Could not send login verification code. Please try again later.");
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
throw err;
|
|
312
373
|
}
|
|
313
|
-
throw err;
|
|
314
374
|
}
|
|
315
375
|
|
|
316
|
-
|
|
376
|
+
if (!isReturningUser) {
|
|
377
|
+
console.log(`\n${sym.check} Verification code sent to ${c.highlight(email)}`);
|
|
378
|
+
} else {
|
|
379
|
+
console.log(`${sym.check} Verification code sent to ${c.highlight(email)}`);
|
|
380
|
+
}
|
|
317
381
|
console.log(c.dim(" (Check your inbox and spam folder. Code expires in 15 minutes.)\n"));
|
|
318
382
|
|
|
319
383
|
// Get verification code
|
|
320
384
|
let apiKey: string | undefined;
|
|
321
385
|
let userId: string | undefined;
|
|
386
|
+
let returnedUserType: string | undefined;
|
|
322
387
|
let attempts = 0;
|
|
323
388
|
const maxAttempts = 5;
|
|
389
|
+
const verifyEndpoint = isReturningUser ? "/v1/auth/verify-login" : "/v1/auth/verify";
|
|
324
390
|
|
|
325
391
|
while (attempts < maxAttempts) {
|
|
326
392
|
const code = await prompt.question(`Enter 6-digit code: `);
|
|
327
|
-
|
|
393
|
+
|
|
328
394
|
if (!code || code.length !== 6) {
|
|
329
395
|
console.log(c.error("Code must be 6 digits"));
|
|
330
396
|
continue;
|
|
331
397
|
}
|
|
332
398
|
|
|
333
399
|
try {
|
|
334
|
-
const result = await apiUnauthenticated(baseUrl, "POST",
|
|
400
|
+
const result = await apiUnauthenticated(baseUrl, "POST", verifyEndpoint, {
|
|
335
401
|
email,
|
|
336
402
|
code: code.trim(),
|
|
337
403
|
});
|
|
338
404
|
|
|
339
405
|
apiKey = result.apiKey as string;
|
|
340
406
|
userId = result.id as string;
|
|
407
|
+
if (isReturningUser) {
|
|
408
|
+
returnedUserType = result.userType as string;
|
|
409
|
+
}
|
|
341
410
|
break;
|
|
342
411
|
} catch (err) {
|
|
343
412
|
attempts++;
|
|
@@ -358,9 +427,125 @@ async function handleEmailVerification(
|
|
|
358
427
|
throw new Error("Verification failed. Please try again.");
|
|
359
428
|
}
|
|
360
429
|
|
|
361
|
-
|
|
430
|
+
if (isReturningUser) {
|
|
431
|
+
console.log(`\n${sym.check} ${c.success("Welcome back! Logged in successfully.")}\n`);
|
|
432
|
+
} else {
|
|
433
|
+
console.log(`\n${sym.check} ${c.success("Email verified! Account created.")}\n`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { email, apiKey, userId, isReturningUser, userType: returnedUserType };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Returning User Progress ─────────────────────────────────────────────
|
|
362
440
|
|
|
363
|
-
|
|
441
|
+
interface OnboardProgress {
|
|
442
|
+
accountCreated: boolean;
|
|
443
|
+
userType: "worker" | "employer";
|
|
444
|
+
toolsDetected: string[];
|
|
445
|
+
aiAccountsConnected: boolean;
|
|
446
|
+
domainsSet: string[];
|
|
447
|
+
githubConnected: boolean;
|
|
448
|
+
linkedinConnected: boolean;
|
|
449
|
+
bio?: string;
|
|
450
|
+
githubUsername?: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function fetchOnboardingProgress(config: Config): Promise<OnboardProgress> {
|
|
454
|
+
const progress: OnboardProgress = {
|
|
455
|
+
accountCreated: true,
|
|
456
|
+
userType: config.userType as "worker" | "employer",
|
|
457
|
+
toolsDetected: [],
|
|
458
|
+
aiAccountsConnected: false,
|
|
459
|
+
domainsSet: [],
|
|
460
|
+
githubConnected: false,
|
|
461
|
+
linkedinConnected: false,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// Fetch profile
|
|
465
|
+
try {
|
|
466
|
+
const profile = await api(config, "GET", "/v1/profile");
|
|
467
|
+
if (profile.domains && Array.isArray(profile.domains) && (profile.domains as string[]).length > 0) {
|
|
468
|
+
progress.domainsSet = profile.domains as string[];
|
|
469
|
+
}
|
|
470
|
+
if (profile.tools && typeof profile.tools === "object") {
|
|
471
|
+
const tools = profile.tools as Record<string, unknown>;
|
|
472
|
+
if (tools.primary && Array.isArray(tools.primary) && (tools.primary as string[]).length > 0) {
|
|
473
|
+
progress.toolsDetected = tools.primary as string[];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (profile.bio) {
|
|
477
|
+
progress.bio = profile.bio as string;
|
|
478
|
+
}
|
|
479
|
+
if (profile.githubUsername) {
|
|
480
|
+
progress.githubConnected = true;
|
|
481
|
+
progress.githubUsername = profile.githubUsername as string;
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
// Profile doesn't exist yet — that's fine
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Check AI accounts
|
|
488
|
+
const existingProviders = loadProviderKeys();
|
|
489
|
+
progress.aiAccountsConnected = existingProviders.length > 0;
|
|
490
|
+
|
|
491
|
+
// Check verification status (LinkedIn etc.)
|
|
492
|
+
try {
|
|
493
|
+
const verificationStatus = await api(config, "GET", "/v1/verification/status");
|
|
494
|
+
if (verificationStatus.linkedin) {
|
|
495
|
+
progress.linkedinConnected = true;
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
// Endpoint may not exist — gracefully ignore
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return progress;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function showReturningUserProgress(progress: OnboardProgress): { firstIncomplete: number } {
|
|
505
|
+
const isWorker = progress.userType === "worker";
|
|
506
|
+
const totalSteps = isWorker ? 7 : 6;
|
|
507
|
+
|
|
508
|
+
console.log(`${c.bold("Here's your onboarding progress:")}\n`);
|
|
509
|
+
|
|
510
|
+
if (isWorker) {
|
|
511
|
+
const steps: Array<{ done: boolean; label: string }> = [
|
|
512
|
+
{ done: true, label: `Account created (${progress.userType})` },
|
|
513
|
+
{ done: progress.toolsDetected.length > 0, label: progress.toolsDetected.length > 0
|
|
514
|
+
? `AI Tools detected (${progress.toolsDetected.join(", ")})`
|
|
515
|
+
: "AI Tools not detected" },
|
|
516
|
+
{ done: progress.aiAccountsConnected, label: progress.aiAccountsConnected
|
|
517
|
+
? "AI Accounts connected"
|
|
518
|
+
: "AI Accounts not connected" },
|
|
519
|
+
{ done: progress.domainsSet.length > 0, label: progress.domainsSet.length > 0
|
|
520
|
+
? `Domains set (${progress.domainsSet.join(", ")})`
|
|
521
|
+
: "Domains not set" },
|
|
522
|
+
{ done: progress.githubConnected, label: progress.githubConnected
|
|
523
|
+
? `GitHub connected (${progress.githubUsername})`
|
|
524
|
+
: "GitHub not connected" },
|
|
525
|
+
{ done: progress.linkedinConnected, label: progress.linkedinConnected
|
|
526
|
+
? "LinkedIn connected"
|
|
527
|
+
: "LinkedIn not connected" },
|
|
528
|
+
{ done: false, label: "Completion" },
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
let firstIncomplete = totalSteps; // default to last
|
|
532
|
+
for (let i = 0; i < steps.length; i++) {
|
|
533
|
+
const step = steps[i];
|
|
534
|
+
const icon = step.done ? sym.check : sym.cross;
|
|
535
|
+
console.log(` ${icon} Step ${i + 1}/${totalSteps} — ${step.label}`);
|
|
536
|
+
if (!step.done && firstIncomplete === totalSteps) {
|
|
537
|
+
firstIncomplete = i + 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
console.log();
|
|
541
|
+
return { firstIncomplete };
|
|
542
|
+
} else {
|
|
543
|
+
// Employer — simpler progress
|
|
544
|
+
console.log(` ${sym.check} Step 1/6 — Account created (employer)`);
|
|
545
|
+
console.log(` ${sym.cross} Step 2/6 — Remaining setup`);
|
|
546
|
+
console.log();
|
|
547
|
+
return { firstIncomplete: 2 };
|
|
548
|
+
}
|
|
364
549
|
}
|
|
365
550
|
|
|
366
551
|
// ── Worker Flow ────────────────────────────────────────────────────────
|
|
@@ -580,6 +765,24 @@ async function runToolDetectionStep(
|
|
|
580
765
|
|
|
581
766
|
if (result.installed.length > 0) {
|
|
582
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
|
+
|
|
583
786
|
console.log(`\n ${c.bold("What happens now?")}`);
|
|
584
787
|
console.log(` Observers activate each time you use your AI tools.`);
|
|
585
788
|
console.log(` Just work normally — every session builds your profile.\n`);
|
|
@@ -588,6 +791,8 @@ async function runToolDetectionStep(
|
|
|
588
791
|
console.log(` a work report to JobArbiter. Our agents interpret these reports`);
|
|
589
792
|
console.log(` to build and evolve your narrative profile over time.`);
|
|
590
793
|
console.log(` Raw session data never leaves your machine.\n`);
|
|
794
|
+
|
|
795
|
+
console.log(` ${c.dim("Manage observers: jobarbiter observe --help")}`);
|
|
591
796
|
}
|
|
592
797
|
} else {
|
|
593
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
|
+
}
|