jobarbiter 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,768 @@
1
+ /**
2
+ * JobArbiter Observer — Hook installer for coding agent CLIs
3
+ *
4
+ * Detects installed coding agents, installs observation hooks that
5
+ * extract proficiency signals from session transcripts.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ export interface DetectedAgent {
16
+ id: string;
17
+ name: string;
18
+ configDir: string;
19
+ hookFormat: "claude" | "cursor" | "opencode" | "codex" | "gemini";
20
+ installed: boolean;
21
+ hookInstalled: boolean;
22
+ }
23
+
24
+ interface HookConfig {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ // ── Agent Detection ────────────────────────────────────────────────────
29
+
30
+ const AGENT_DEFINITIONS = [
31
+ {
32
+ id: "claude-code",
33
+ name: "Claude Code",
34
+ configDir: join(homedir(), ".claude"),
35
+ hookFormat: "claude" as const,
36
+ detectBin: "claude",
37
+ },
38
+ {
39
+ id: "cursor",
40
+ name: "Cursor",
41
+ configDir: join(homedir(), ".cursor"),
42
+ hookFormat: "cursor" as const,
43
+ detectBin: null, // Cursor is an app, not a CLI
44
+ detectDir: join(homedir(), ".cursor"),
45
+ },
46
+ {
47
+ id: "opencode",
48
+ name: "OpenCode",
49
+ configDir: join(homedir(), ".config", "opencode"),
50
+ hookFormat: "opencode" as const,
51
+ detectBin: "opencode",
52
+ },
53
+ {
54
+ id: "codex",
55
+ name: "Codex CLI",
56
+ configDir: join(homedir(), ".codex"),
57
+ hookFormat: "codex" as const,
58
+ detectBin: "codex",
59
+ },
60
+ {
61
+ id: "gemini",
62
+ name: "Gemini CLI",
63
+ configDir: join(homedir(), ".gemini"),
64
+ hookFormat: "gemini" as const,
65
+ detectBin: "gemini",
66
+ },
67
+ ];
68
+
69
+ function binExists(name: string): boolean {
70
+ try {
71
+ execSync(`which ${name}`, { stdio: "ignore" });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export function detectAgents(): DetectedAgent[] {
79
+ return AGENT_DEFINITIONS.map((def) => {
80
+ const installed =
81
+ (def.detectBin && binExists(def.detectBin)) ||
82
+ existsSync(def.configDir);
83
+
84
+ return {
85
+ id: def.id,
86
+ name: def.name,
87
+ configDir: def.configDir,
88
+ hookFormat: def.hookFormat,
89
+ installed: !!installed,
90
+ hookInstalled: installed ? isHookInstalled(def.id, def.configDir, def.hookFormat) : false,
91
+ };
92
+ });
93
+ }
94
+
95
+ // ── Hook Detection ─────────────────────────────────────────────────────
96
+
97
+ function isHookInstalled(agentId: string, configDir: string, format: string): boolean {
98
+ try {
99
+ switch (format) {
100
+ case "claude":
101
+ case "cursor": {
102
+ const hookFile = join(configDir, "hooks.json");
103
+ if (!existsSync(hookFile)) return false;
104
+ const content = readFileSync(hookFile, "utf-8");
105
+ return content.includes("jobarbiter");
106
+ }
107
+ case "opencode": {
108
+ const pluginDir = join(configDir, "plugins");
109
+ return existsSync(join(pluginDir, "jobarbiter-observer.ts"));
110
+ }
111
+ case "codex": {
112
+ const configFile = join(configDir, "config.toml");
113
+ if (!existsSync(configFile)) return false;
114
+ const content = readFileSync(configFile, "utf-8");
115
+ return content.includes("jobarbiter");
116
+ }
117
+ case "gemini": {
118
+ const settingsFile = join(configDir, "settings.json");
119
+ if (!existsSync(settingsFile)) return false;
120
+ const content = readFileSync(settingsFile, "utf-8");
121
+ return content.includes("jobarbiter");
122
+ }
123
+ default:
124
+ return false;
125
+ }
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ // ── Observer Data Directory ────────────────────────────────────────────
132
+
133
+ const OBSERVER_DIR = join(homedir(), ".config", "jobarbiter", "observer");
134
+ const OBSERVATIONS_FILE = join(OBSERVER_DIR, "observations.json");
135
+ const HOOKS_DIR = join(OBSERVER_DIR, "hooks");
136
+
137
+ function ensureObserverDirs(): void {
138
+ mkdirSync(OBSERVER_DIR, { recursive: true });
139
+ mkdirSync(HOOKS_DIR, { recursive: true });
140
+
141
+ // Initialize observations file if missing
142
+ if (!existsSync(OBSERVATIONS_FILE)) {
143
+ writeFileSync(
144
+ OBSERVATIONS_FILE,
145
+ JSON.stringify(
146
+ {
147
+ version: 1,
148
+ installedAt: new Date().toISOString(),
149
+ agents: {},
150
+ sessions: [],
151
+ accumulated: {
152
+ totalSessions: 0,
153
+ totalTokens: 0,
154
+ toolCounts: {},
155
+ domainSignals: [],
156
+ lastSubmitted: null,
157
+ },
158
+ },
159
+ null,
160
+ 2,
161
+ ) + "\n",
162
+ );
163
+ }
164
+ }
165
+
166
+ // ── Core Observer Script ───────────────────────────────────────────────
167
+
168
+ /**
169
+ * The universal observer script. Runs as a hook in any coding agent.
170
+ * Reads session transcript data from stdin (JSON), extracts proficiency
171
+ * signals, and appends them to the local observations file.
172
+ *
173
+ * This is written as a standalone shell script so it has zero dependencies
174
+ * and works regardless of the user's Node.js setup.
175
+ */
176
+ function getObserverScript(): string {
177
+ return `#!/usr/bin/env node
178
+ /**
179
+ * JobArbiter Observer Hook
180
+ * Extracts proficiency signals from coding agent sessions.
181
+ *
182
+ * Reads JSON from stdin, writes observations to:
183
+ * ~/.config/jobarbiter/observer/observations.json
184
+ *
185
+ * Signals extracted:
186
+ * - Tool names and frequencies (tool fluency)
187
+ * - Session duration and message counts (output velocity)
188
+ * - File types worked on (domain application)
189
+ * - Token usage when available (token throughput)
190
+ */
191
+
192
+ const fs = require("fs");
193
+ const path = require("path");
194
+ const os = require("os");
195
+
196
+ const OBSERVATIONS_FILE = path.join(
197
+ os.homedir(), ".config", "jobarbiter", "observer", "observations.json"
198
+ );
199
+
200
+ // Read stdin
201
+ let input = "";
202
+ process.stdin.setEncoding("utf-8");
203
+ process.stdin.on("data", (chunk) => { input += chunk; });
204
+ process.stdin.on("end", () => {
205
+ try {
206
+ const data = JSON.parse(input);
207
+ const observation = extractSignals(data);
208
+ if (observation) appendObservation(observation);
209
+ } catch (err) {
210
+ // Silent failure — never block the coding agent
211
+ fs.appendFileSync(
212
+ path.join(os.homedir(), ".config", "jobarbiter", "observer", "errors.log"),
213
+ \`[\${new Date().toISOString()}] \${err.message}\\n\`
214
+ );
215
+ }
216
+ process.exit(0);
217
+ });
218
+
219
+ function extractSignals(data) {
220
+ const observation = {
221
+ timestamp: new Date().toISOString(),
222
+ agent: process.env.JOBARBITER_AGENT || detectAgent(),
223
+ sessionId: data.sessionId || data.session_id || data.thread_id || data.id || null,
224
+ signals: {
225
+ toolsUsed: [],
226
+ fileExtensions: [],
227
+ messageCount: 0,
228
+ thinkingBlocks: 0,
229
+ tokenUsage: null,
230
+ duration: null,
231
+ },
232
+ };
233
+
234
+ // Claude Code / Cursor: stop hook provides session info
235
+ if (data.transcript || data.messages) {
236
+ const messages = data.transcript || data.messages || [];
237
+ observation.signals.messageCount = messages.length;
238
+
239
+ for (const msg of messages) {
240
+ // Extract tool calls
241
+ if (msg.tool_name || msg.toolName) {
242
+ observation.signals.toolsUsed.push(msg.tool_name || msg.toolName);
243
+ }
244
+ if (msg.tool_calls) {
245
+ for (const tc of msg.tool_calls) {
246
+ observation.signals.toolsUsed.push(tc.name || tc.function?.name);
247
+ }
248
+ }
249
+
250
+ // Extract file extensions from tool args
251
+ const args = msg.tool_input || msg.args || msg.tool_calls?.[0]?.input || {};
252
+ const filePath = args.file_path || args.filePath || args.path || args.filename || "";
253
+ if (filePath) {
254
+ const ext = path.extname(filePath).toLowerCase();
255
+ if (ext) observation.signals.fileExtensions.push(ext);
256
+ }
257
+
258
+ // Count thinking blocks
259
+ if (msg.type === "thinking" || msg.role === "thinking") {
260
+ observation.signals.thinkingBlocks++;
261
+ }
262
+
263
+ // Token usage
264
+ if (msg.usage) {
265
+ if (!observation.signals.tokenUsage) {
266
+ observation.signals.tokenUsage = { input: 0, output: 0, total: 0 };
267
+ }
268
+ observation.signals.tokenUsage.input += msg.usage.input_tokens || msg.usage.input || 0;
269
+ observation.signals.tokenUsage.output += msg.usage.output_tokens || msg.usage.output || 0;
270
+ observation.signals.tokenUsage.total += msg.usage.total_tokens || msg.usage.totalTokens || 0;
271
+ }
272
+ }
273
+ }
274
+
275
+ // Codex: notification format
276
+ if (data.type === "agent-turn-complete") {
277
+ observation.signals.messageCount = (data["input-messages"] || []).length;
278
+ }
279
+
280
+ // Gemini: AfterAgent / SessionEnd
281
+ if (data.toolResults || data.toolCalls) {
282
+ const tools = data.toolCalls || data.toolResults || [];
283
+ for (const t of tools) {
284
+ observation.signals.toolsUsed.push(t.name || t.toolName);
285
+ }
286
+ }
287
+
288
+ // Deduplicate
289
+ observation.signals.toolsUsed = [...new Set(observation.signals.toolsUsed.filter(Boolean))];
290
+ observation.signals.fileExtensions = [...new Set(observation.signals.fileExtensions.filter(Boolean))];
291
+
292
+ // Skip empty observations
293
+ if (
294
+ observation.signals.toolsUsed.length === 0 &&
295
+ observation.signals.messageCount === 0 &&
296
+ !observation.signals.tokenUsage
297
+ ) {
298
+ return null;
299
+ }
300
+
301
+ return observation;
302
+ }
303
+
304
+ function detectAgent() {
305
+ // Detect from environment or parent process
306
+ if (process.env.CLAUDE_CODE) return "claude-code";
307
+ if (process.env.CURSOR_SESSION) return "cursor";
308
+ if (process.env.CODEX_HOME) return "codex";
309
+ if (process.env.GEMINI_PROJECT_DIR) return "gemini";
310
+ return "unknown";
311
+ }
312
+
313
+ function appendObservation(obs) {
314
+ try {
315
+ const raw = fs.readFileSync(OBSERVATIONS_FILE, "utf-8");
316
+ const data = JSON.parse(raw);
317
+ data.sessions.push(obs);
318
+ data.accumulated.totalSessions++;
319
+
320
+ // Update tool counts
321
+ for (const tool of obs.signals.toolsUsed) {
322
+ data.accumulated.toolCounts[tool] = (data.accumulated.toolCounts[tool] || 0) + 1;
323
+ }
324
+
325
+ // Update token totals
326
+ if (obs.signals.tokenUsage) {
327
+ data.accumulated.totalTokens += obs.signals.tokenUsage.total || 0;
328
+ }
329
+
330
+ // Keep only last 500 detailed sessions (rolling window)
331
+ if (data.sessions.length > 500) {
332
+ data.sessions = data.sessions.slice(-500);
333
+ }
334
+
335
+ fs.writeFileSync(OBSERVATIONS_FILE, JSON.stringify(data, null, 2) + "\\n");
336
+ } catch (err) {
337
+ // If file is corrupted, start fresh
338
+ fs.writeFileSync(OBSERVATIONS_FILE, JSON.stringify({
339
+ version: 1,
340
+ sessions: [obs],
341
+ accumulated: {
342
+ totalSessions: 1,
343
+ totalTokens: obs.signals.tokenUsage?.total || 0,
344
+ toolCounts: Object.fromEntries(obs.signals.toolsUsed.map(t => [t, 1])),
345
+ domainSignals: [],
346
+ lastSubmitted: null,
347
+ },
348
+ }, null, 2) + "\\n");
349
+ }
350
+ }
351
+ `;
352
+ }
353
+
354
+ // ── Hook Installers ────────────────────────────────────────────────────
355
+
356
+ function writeObserverScript(): string {
357
+ ensureObserverDirs();
358
+ const scriptPath = join(HOOKS_DIR, "observer.js");
359
+ writeFileSync(scriptPath, getObserverScript());
360
+ chmodSync(scriptPath, 0o755);
361
+ return scriptPath;
362
+ }
363
+
364
+ /**
365
+ * Install hook for Claude Code (~/.claude/hooks.json)
366
+ * Uses the Stop event to observe after each session turn.
367
+ */
368
+ function installClaudeCodeHook(configDir: string, scriptPath: string): void {
369
+ const hookFile = join(configDir, "hooks.json");
370
+ let config: HookConfig = {};
371
+
372
+ if (existsSync(hookFile)) {
373
+ try {
374
+ config = JSON.parse(readFileSync(hookFile, "utf-8"));
375
+ } catch {
376
+ config = {};
377
+ }
378
+ }
379
+
380
+ // Ensure hooks object exists
381
+ if (!config.hooks) config.hooks = {};
382
+ const hooks = config.hooks as Record<string, unknown[]>;
383
+
384
+ // Add to Stop event (don't duplicate)
385
+ if (!hooks.Stop) hooks.Stop = [];
386
+ const stopHooks = hooks.Stop as Array<{ command: string; timeout?: number }>;
387
+
388
+ if (!stopHooks.some((h) => h.command?.includes("jobarbiter"))) {
389
+ stopHooks.push({
390
+ command: `node ${scriptPath}`,
391
+ timeout: 10,
392
+ });
393
+ }
394
+
395
+ mkdirSync(configDir, { recursive: true });
396
+ writeFileSync(hookFile, JSON.stringify(config, null, 2) + "\n");
397
+ }
398
+
399
+ /**
400
+ * Install hook for Cursor (~/.cursor/hooks.json)
401
+ * Same JSON format as Claude Code — uses stop event.
402
+ */
403
+ function installCursorHook(configDir: string, scriptPath: string): void {
404
+ const hookFile = join(configDir, "hooks.json");
405
+ let config: HookConfig = {};
406
+
407
+ if (existsSync(hookFile)) {
408
+ try {
409
+ config = JSON.parse(readFileSync(hookFile, "utf-8"));
410
+ } catch {
411
+ config = {};
412
+ }
413
+ }
414
+
415
+ if (!config.version) config.version = 1;
416
+ if (!config.hooks) config.hooks = {};
417
+ const hooks = config.hooks as Record<string, unknown[]>;
418
+
419
+ // Use stop event
420
+ if (!hooks.stop) hooks.stop = [];
421
+ const stopHooks = hooks.stop as Array<{ command: string; timeout?: number }>;
422
+
423
+ if (!stopHooks.some((h) => h.command?.includes("jobarbiter"))) {
424
+ stopHooks.push({
425
+ command: `node ${scriptPath}`,
426
+ timeout: 10,
427
+ });
428
+ }
429
+
430
+ mkdirSync(configDir, { recursive: true });
431
+ writeFileSync(hookFile, JSON.stringify(config, null, 2) + "\n");
432
+ }
433
+
434
+ /**
435
+ * Install plugin for OpenCode (~/.config/opencode/plugins/)
436
+ * OpenCode uses JS/TS plugin modules, not JSON config.
437
+ */
438
+ function installOpenCodeHook(configDir: string, scriptPath: string): void {
439
+ const pluginDir = join(configDir, "plugins");
440
+ mkdirSync(pluginDir, { recursive: true });
441
+
442
+ const pluginCode = `// JobArbiter Observer Plugin for OpenCode
443
+ // Observes session activity and extracts proficiency signals.
444
+
445
+ const { execSync } = require("child_process");
446
+ const { readFileSync } = require("fs");
447
+
448
+ exports.JobArbiterObserver = async ({ project, client, $, directory }) => {
449
+ return {
450
+ event: async ({ event }) => {
451
+ if (event.type === "session.idle" || event.type === "session.updated") {
452
+ try {
453
+ const sessionData = JSON.stringify({
454
+ sessionId: event.sessionId || "unknown",
455
+ agent: "opencode",
456
+ messages: event.messages || [],
457
+ });
458
+ execSync(\`echo '\${sessionData.replace(/'/g, "'\\"'\\"\\'")}' | node ${scriptPath}\`, {
459
+ stdio: "ignore",
460
+ timeout: 10000,
461
+ env: { ...process.env, JOBARBITER_AGENT: "opencode" },
462
+ });
463
+ } catch {
464
+ // Silent failure
465
+ }
466
+ }
467
+ },
468
+ "tool.execute.after": async (input, output) => {
469
+ // Track individual tool executions for richer signal
470
+ try {
471
+ const toolData = JSON.stringify({
472
+ agent: "opencode",
473
+ messages: [{
474
+ tool_name: input.tool,
475
+ args: output.args || {},
476
+ }],
477
+ });
478
+ execSync(\`echo '\${toolData.replace(/'/g, "'\\"'\\"\\'")}' | node ${scriptPath}\`, {
479
+ stdio: "ignore",
480
+ timeout: 5000,
481
+ env: { ...process.env, JOBARBITER_AGENT: "opencode" },
482
+ });
483
+ } catch {
484
+ // Silent failure
485
+ }
486
+ },
487
+ };
488
+ };
489
+ `;
490
+
491
+ writeFileSync(join(pluginDir, "jobarbiter-observer.js"), pluginCode);
492
+ }
493
+
494
+ /**
495
+ * Install notification handler for Codex CLI (~/.codex/config.toml)
496
+ * Codex uses a `notify` config key that runs an external program.
497
+ */
498
+ function installCodexHook(configDir: string, scriptPath: string): void {
499
+ const configFile = join(configDir, "config.toml");
500
+
501
+ // Create a wrapper script that converts Codex's CLI arg format to stdin
502
+ const wrapperPath = join(HOOKS_DIR, "codex-wrapper.sh");
503
+ writeFileSync(
504
+ wrapperPath,
505
+ `#!/bin/bash
506
+ # JobArbiter Observer wrapper for Codex CLI
507
+ # Codex passes JSON as $1, our observer reads stdin
508
+ echo "$1" | JOBARBITER_AGENT=codex node ${scriptPath}
509
+ `,
510
+ );
511
+ chmodSync(wrapperPath, 0o755);
512
+
513
+ mkdirSync(configDir, { recursive: true });
514
+
515
+ let config = "";
516
+ if (existsSync(configFile)) {
517
+ config = readFileSync(configFile, "utf-8");
518
+ }
519
+
520
+ // Add notify line if not present
521
+ if (!config.includes("jobarbiter")) {
522
+ // Check if there's already a notify line
523
+ if (config.includes("notify")) {
524
+ // Don't overwrite existing notify — append comment
525
+ config += `\n# JobArbiter observer: add this to your notify script:\n# ${wrapperPath} "$1"\n`;
526
+ } else {
527
+ config += `\n# JobArbiter observer\nnotify = "${wrapperPath}"\n`;
528
+ }
529
+ writeFileSync(configFile, config);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Install hook for Gemini CLI (~/.gemini/settings.json)
535
+ * Uses SessionEnd event.
536
+ */
537
+ function installGeminiHook(configDir: string, scriptPath: string): void {
538
+ const settingsFile = join(configDir, "settings.json");
539
+ let settings: HookConfig = {};
540
+
541
+ if (existsSync(settingsFile)) {
542
+ try {
543
+ settings = JSON.parse(readFileSync(settingsFile, "utf-8"));
544
+ } catch {
545
+ settings = {};
546
+ }
547
+ }
548
+
549
+ if (!settings.hooks) settings.hooks = {};
550
+ const hooks = settings.hooks as Record<string, unknown[]>;
551
+
552
+ // Add SessionEnd hook
553
+ if (!hooks.SessionEnd) hooks.SessionEnd = [];
554
+ const sessionEndHooks = hooks.SessionEnd as Array<{
555
+ matcher: string;
556
+ hooks: Array<{ name: string; type: string; command: string; timeout: number }>;
557
+ }>;
558
+
559
+ if (!sessionEndHooks.some((h) => h.hooks?.some((hh) => hh.command?.includes("jobarbiter")))) {
560
+ sessionEndHooks.push({
561
+ matcher: "*",
562
+ hooks: [
563
+ {
564
+ name: "jobarbiter-observer",
565
+ type: "command",
566
+ command: `node ${scriptPath}`,
567
+ timeout: 10000,
568
+ },
569
+ ],
570
+ });
571
+ }
572
+
573
+ mkdirSync(configDir, { recursive: true });
574
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
575
+ }
576
+
577
+ // ── Public API ─────────────────────────────────────────────────────────
578
+
579
+ /**
580
+ * Install observer hooks for the specified agents.
581
+ * Returns a summary of what was installed.
582
+ */
583
+ export function installObservers(
584
+ agentIds: string[],
585
+ ): { installed: string[]; skipped: string[]; errors: Array<{ agent: string; error: string }> } {
586
+ const scriptPath = writeObserverScript();
587
+ const result = {
588
+ installed: [] as string[],
589
+ skipped: [] as string[],
590
+ errors: [] as Array<{ agent: string; error: string }>,
591
+ };
592
+
593
+ for (const agentId of agentIds) {
594
+ const def = AGENT_DEFINITIONS.find((d) => d.id === agentId);
595
+ if (!def) {
596
+ result.errors.push({ agent: agentId, error: "Unknown agent" });
597
+ continue;
598
+ }
599
+
600
+ // Check if already installed
601
+ if (isHookInstalled(def.id, def.configDir, def.hookFormat)) {
602
+ result.skipped.push(def.name);
603
+ continue;
604
+ }
605
+
606
+ try {
607
+ switch (def.hookFormat) {
608
+ case "claude":
609
+ installClaudeCodeHook(def.configDir, scriptPath);
610
+ break;
611
+ case "cursor":
612
+ installCursorHook(def.configDir, scriptPath);
613
+ break;
614
+ case "opencode":
615
+ installOpenCodeHook(def.configDir, scriptPath);
616
+ break;
617
+ case "codex":
618
+ installCodexHook(def.configDir, scriptPath);
619
+ break;
620
+ case "gemini":
621
+ installGeminiHook(def.configDir, scriptPath);
622
+ break;
623
+ }
624
+ result.installed.push(def.name);
625
+ } catch (err) {
626
+ result.errors.push({
627
+ agent: def.name,
628
+ error: err instanceof Error ? err.message : String(err),
629
+ });
630
+ }
631
+ }
632
+
633
+ return result;
634
+ }
635
+
636
+ /**
637
+ * Remove observer hooks for the specified agents.
638
+ */
639
+ export function removeObservers(agentIds: string[]): { removed: string[]; notFound: string[] } {
640
+ const result = { removed: [] as string[], notFound: [] as string[] };
641
+
642
+ for (const agentId of agentIds) {
643
+ const def = AGENT_DEFINITIONS.find((d) => d.id === agentId);
644
+ if (!def) {
645
+ result.notFound.push(agentId);
646
+ continue;
647
+ }
648
+
649
+ try {
650
+ switch (def.hookFormat) {
651
+ case "claude":
652
+ case "cursor": {
653
+ const hookFile = join(def.configDir, "hooks.json");
654
+ if (existsSync(hookFile)) {
655
+ const config = JSON.parse(readFileSync(hookFile, "utf-8"));
656
+ for (const [key, hooks] of Object.entries(config.hooks || {})) {
657
+ if (Array.isArray(hooks)) {
658
+ (config.hooks as Record<string, unknown[]>)[key] = hooks.filter(
659
+ (h: unknown) => !JSON.stringify(h).includes("jobarbiter"),
660
+ );
661
+ }
662
+ }
663
+ writeFileSync(hookFile, JSON.stringify(config, null, 2) + "\n");
664
+ result.removed.push(def.name);
665
+ } else {
666
+ result.notFound.push(def.name);
667
+ }
668
+ break;
669
+ }
670
+ case "opencode": {
671
+ const pluginFile = join(def.configDir, "plugins", "jobarbiter-observer.js");
672
+ if (existsSync(pluginFile)) {
673
+ unlinkSync(pluginFile);
674
+ result.removed.push(def.name);
675
+ } else {
676
+ result.notFound.push(def.name);
677
+ }
678
+ break;
679
+ }
680
+ case "codex": {
681
+ const configFile = join(def.configDir, "config.toml");
682
+ if (existsSync(configFile)) {
683
+ let content = readFileSync(configFile, "utf-8");
684
+ content = content
685
+ .split("\n")
686
+ .filter((line) => !line.includes("jobarbiter"))
687
+ .join("\n");
688
+ writeFileSync(configFile, content);
689
+ result.removed.push(def.name);
690
+ } else {
691
+ result.notFound.push(def.name);
692
+ }
693
+ break;
694
+ }
695
+ case "gemini": {
696
+ const settingsFile = join(def.configDir, "settings.json");
697
+ if (existsSync(settingsFile)) {
698
+ const settings = JSON.parse(readFileSync(settingsFile, "utf-8"));
699
+ for (const [key, hookGroups] of Object.entries(settings.hooks || {})) {
700
+ if (Array.isArray(hookGroups)) {
701
+ (settings.hooks as Record<string, unknown[]>)[key] = hookGroups.filter(
702
+ (g: unknown) => !JSON.stringify(g).includes("jobarbiter"),
703
+ );
704
+ }
705
+ }
706
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
707
+ result.removed.push(def.name);
708
+ } else {
709
+ result.notFound.push(def.name);
710
+ }
711
+ break;
712
+ }
713
+ }
714
+ } catch {
715
+ result.notFound.push(def.name);
716
+ }
717
+ }
718
+
719
+ return result;
720
+ }
721
+
722
+ /**
723
+ * Get observation status — what's been accumulated locally.
724
+ */
725
+ export function getObservationStatus(): {
726
+ hasData: boolean;
727
+ totalSessions: number;
728
+ totalTokens: number;
729
+ topTools: Array<{ tool: string; count: number }>;
730
+ agents: string[];
731
+ lastSubmitted: string | null;
732
+ } {
733
+ ensureObserverDirs();
734
+
735
+ try {
736
+ const raw = readFileSync(OBSERVATIONS_FILE, "utf-8");
737
+ const data = JSON.parse(raw);
738
+
739
+ const topTools = Object.entries(data.accumulated?.toolCounts || {})
740
+ .map(([tool, count]) => ({ tool, count: count as number }))
741
+ .sort((a, b) => b.count - a.count)
742
+ .slice(0, 10);
743
+
744
+ const agents = [
745
+ ...new Set(
746
+ (data.sessions || []).map((s: { agent: string }) => s.agent).filter(Boolean),
747
+ ),
748
+ ] as string[];
749
+
750
+ return {
751
+ hasData: (data.accumulated?.totalSessions || 0) > 0,
752
+ totalSessions: data.accumulated?.totalSessions || 0,
753
+ totalTokens: data.accumulated?.totalTokens || 0,
754
+ topTools,
755
+ agents,
756
+ lastSubmitted: data.accumulated?.lastSubmitted || null,
757
+ };
758
+ } catch {
759
+ return {
760
+ hasData: false,
761
+ totalSessions: 0,
762
+ totalTokens: 0,
763
+ topTools: [],
764
+ agents: [],
765
+ lastSubmitted: null,
766
+ };
767
+ }
768
+ }