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