@wibeco/bridge 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1074 @@
1
+ // src/events.ts
2
+ import { randomUUID } from "crypto";
3
+ import { z } from "zod";
4
+ var agentSourceSchema = z.enum(["cursor", "claude-code", "codex"]);
5
+ var hookEventKindSchema = z.enum([
6
+ "presence.heartbeat",
7
+ "session.started",
8
+ "session.ended",
9
+ "lifecycle.before",
10
+ "lifecycle.after",
11
+ "tool.started",
12
+ "tool.completed",
13
+ "file.changed",
14
+ "shell.started",
15
+ "shell.completed",
16
+ "mcp.started",
17
+ "mcp.completed",
18
+ "unknown"
19
+ ]);
20
+ var safeScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
21
+ var safeValueSchema = z.lazy(
22
+ () => z.union([safeScalarSchema, z.array(safeValueSchema), z.record(z.string(), safeValueSchema)])
23
+ );
24
+ var canonicalHookEventSchema = z.object({
25
+ id: z.string().min(1),
26
+ version: z.literal(1),
27
+ source: agentSourceSchema,
28
+ kind: hookEventKindSchema,
29
+ occurredAt: z.string().datetime(),
30
+ sessionId: z.string().min(1).optional(),
31
+ repo: z.object({
32
+ root: z.string().min(1),
33
+ remote: z.string().min(1).optional(),
34
+ branch: z.string().min(1).optional(),
35
+ commit: z.string().min(1).optional()
36
+ }).optional(),
37
+ outcome: z.enum(["success", "failure", "cancelled", "unknown"]).optional(),
38
+ durationMs: z.number().nonnegative().optional(),
39
+ metadata: z.record(z.string(), safeValueSchema).default({})
40
+ });
41
+ function createHookEvent(input) {
42
+ return canonicalHookEventSchema.parse({
43
+ ...input,
44
+ id: input.id ?? randomUUID(),
45
+ version: 1,
46
+ occurredAt: input.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString()
47
+ });
48
+ }
49
+
50
+ // src/redaction.ts
51
+ var SENSITIVE_KEY = /authorization|cookie|token|secret|password|passwd|api.?key|prompt|content|input|output|message|transcript/i;
52
+ var SECRET_VALUE = /\b(?:sk-[A-Za-z0-9_-]{16,}|gh[oprsu]_[A-Za-z0-9_]{20,}|(?:bearer|basic)\s+[A-Za-z0-9._~+/-]+=*)\b/gi;
53
+ var PRIVATE_KEY = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
54
+ function redact(value, options = {}) {
55
+ const replacement = options.replacement ?? "[REDACTED]";
56
+ const visit = (current, key) => {
57
+ if (key && SENSITIVE_KEY.test(key) && !options.allowContent) {
58
+ return replacement;
59
+ }
60
+ if (current === null || typeof current === "boolean" || typeof current === "number") {
61
+ return current;
62
+ }
63
+ if (typeof current === "string") {
64
+ let result = current.replace(PRIVATE_KEY, replacement).replace(SECRET_VALUE, replacement);
65
+ if (options.homeDirectory) {
66
+ result = result.replaceAll(options.homeDirectory, "~");
67
+ }
68
+ return result;
69
+ }
70
+ if (Array.isArray(current)) {
71
+ return current.map((item) => visit(item));
72
+ }
73
+ if (typeof current === "object") {
74
+ return Object.fromEntries(
75
+ Object.entries(current).map(([childKey, child]) => [
76
+ childKey,
77
+ visit(child, childKey)
78
+ ])
79
+ );
80
+ }
81
+ return String(current);
82
+ };
83
+ return visit(value);
84
+ }
85
+ function safeMetadata(input, allowedKeys) {
86
+ const selected = Object.fromEntries(
87
+ allowedKeys.filter((key) => isSafeMetadataScalar(input[key])).map((key) => [key, input[key]])
88
+ );
89
+ return redact(selected);
90
+ }
91
+ function isSafeMetadataScalar(value) {
92
+ return value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || typeof value === "string" && value.length <= 512;
93
+ }
94
+
95
+ // src/adapters/shared.ts
96
+ import { relative, resolve, sep } from "path";
97
+ var OUTCOME_KEYS = ["outcome", "status", "result"];
98
+ function mapHookPayload(source, rawEventName, payload, eventMap, metadataKeys) {
99
+ const outcomeValue = OUTCOME_KEYS.map((key) => payload[key]).find(
100
+ (value) => typeof value === "string"
101
+ );
102
+ const normalizedOutcome = outcomeValue === "success" || outcomeValue === "failure" || outcomeValue === "cancelled" ? outcomeValue : outcomeValue ? "unknown" : void 0;
103
+ const duration = payload.duration_ms ?? payload.durationMs;
104
+ return createHookEvent({
105
+ source,
106
+ kind: eventMap[normalizeName(rawEventName)] ?? "unknown",
107
+ ...typeof payload.session_id === "string" ? { sessionId: payload.session_id } : {},
108
+ ...normalizedOutcome ? { outcome: normalizedOutcome } : {},
109
+ ...typeof duration === "number" && duration >= 0 ? { durationMs: duration } : {},
110
+ metadata: {
111
+ hookEvent: normalizeName(rawEventName),
112
+ ...safeMetadata(payload, metadataKeys)
113
+ }
114
+ });
115
+ }
116
+ function normalizeName(name) {
117
+ return name.trim().replaceAll(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replaceAll(/[\s_.]+/g, "-");
118
+ }
119
+ function safeIdentifier(value) {
120
+ if (typeof value !== "string") return void 0;
121
+ const normalized = value.trim();
122
+ return normalized.length > 0 && normalized.length <= 160 && !/[\u0000-\u001f]/.test(normalized) ? normalized : void 0;
123
+ }
124
+ function safeModel(value) {
125
+ const normalized = safeIdentifier(value);
126
+ return normalized && /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(normalized) ? normalized : void 0;
127
+ }
128
+ function safeRelativePath(value, root = process.cwd()) {
129
+ if (typeof value !== "string" || value.includes("\0")) return void 0;
130
+ const candidate = value.trim();
131
+ if (!candidate) return void 0;
132
+ const resolvedRoot = resolve(root);
133
+ const resolvedPath = resolve(resolvedRoot, candidate);
134
+ const path = relative(resolvedRoot, resolvedPath);
135
+ if (path === ".." || path.startsWith(`..${sep}`)) return void 0;
136
+ return (path || ".").split(sep).join("/");
137
+ }
138
+ function safeDuration(value) {
139
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
140
+ }
141
+ function objectPayload(input) {
142
+ return input && typeof input === "object" && !Array.isArray(input) ? input : {};
143
+ }
144
+
145
+ // src/adapters/claude-code.ts
146
+ var events = {
147
+ "session-start": "session.started",
148
+ "session-end": "session.ended",
149
+ heartbeat: "presence.heartbeat",
150
+ "user-prompt-submit": "lifecycle.before",
151
+ stop: "lifecycle.after",
152
+ "pre-tool-use": "tool.started",
153
+ "post-tool-use": "tool.completed",
154
+ "post-tool-use-failure": "tool.completed",
155
+ notification: "lifecycle.after"
156
+ };
157
+ var safeKeys = [
158
+ "hook_event_name",
159
+ "tool_name",
160
+ "permission_mode",
161
+ "source",
162
+ "model",
163
+ "agent_id",
164
+ "path",
165
+ "lines_added",
166
+ "lines_deleted",
167
+ "session_started_at",
168
+ "heartbeat_at",
169
+ "heartbeat_interval_ms",
170
+ "session_elapsed_ms"
171
+ ];
172
+ function mapClaudeCodeHook(eventName, input) {
173
+ const payload = objectPayload(input);
174
+ const toolInput = objectPayload(payload.tool_input);
175
+ const sessionId = safeIdentifier(payload.session_id);
176
+ const model = safeModel(payload.model);
177
+ const path = safeRelativePath(
178
+ toolInput.file_path ?? payload.file_path,
179
+ typeof payload.cwd === "string" ? payload.cwd : process.cwd()
180
+ );
181
+ const duration = safeDuration(payload.duration_ms ?? payload.durationMs);
182
+ const normalizedEvent = normalizeName(eventName);
183
+ const toolName = typeof payload.tool_name === "string" ? payload.tool_name.toLowerCase() : "";
184
+ const event = mapHookPayload(
185
+ "claude-code",
186
+ eventName,
187
+ {
188
+ ...payload,
189
+ ...sessionId ? { session_id: sessionId } : {},
190
+ ...model ? { model } : {},
191
+ ...path ? { path } : {},
192
+ ...duration !== void 0 ? { duration_ms: duration } : {},
193
+ ...normalizedEvent === "post-tool-use-failure" && !payload.outcome ? { outcome: "failure" } : {},
194
+ ...typeof payload.lines_added === "number" ? { lines_added: payload.lines_added } : {},
195
+ ...typeof payload.lines_deleted === "number" ? { lines_deleted: payload.lines_deleted } : {}
196
+ },
197
+ events,
198
+ safeKeys
199
+ );
200
+ return path && normalizedEvent === "post-tool-use" && (toolName === "write" || toolName === "edit") ? { ...event, kind: "file.changed" } : event;
201
+ }
202
+
203
+ // src/adapters/codex.ts
204
+ var events2 = {
205
+ "agent-turn-complete": "lifecycle.after",
206
+ "session-start": "session.started",
207
+ "session-end": "session.ended",
208
+ heartbeat: "presence.heartbeat",
209
+ "turn-start": "lifecycle.before",
210
+ "turn-complete": "lifecycle.after",
211
+ "tool-start": "tool.started",
212
+ "tool-complete": "tool.completed",
213
+ "command-start": "shell.started",
214
+ "command-complete": "shell.completed",
215
+ "file-change": "file.changed",
216
+ "user-prompt-submit": "lifecycle.before",
217
+ "pre-tool-use": "tool.started",
218
+ "post-tool-use": "tool.completed",
219
+ "subagent-start": "lifecycle.before",
220
+ "subagent-stop": "lifecycle.after",
221
+ stop: "lifecycle.after"
222
+ };
223
+ var safeKeys2 = [
224
+ "event",
225
+ "tool_name",
226
+ "command_type",
227
+ "model",
228
+ "reason",
229
+ "turn_id",
230
+ "path",
231
+ "lines_added",
232
+ "lines_deleted",
233
+ "session_started_at",
234
+ "heartbeat_at",
235
+ "heartbeat_interval_ms",
236
+ "session_elapsed_ms"
237
+ ];
238
+ function mapCodexHook(eventName, input) {
239
+ const payload = objectPayload(input);
240
+ const toolInput = objectPayload(payload.tool_input);
241
+ const threadId = safeIdentifier(
242
+ payload["thread-id"] ?? payload.thread_id ?? payload.session_id
243
+ );
244
+ const turnId = safeIdentifier(payload["turn-id"] ?? payload.turn_id);
245
+ const model = safeModel(payload.model);
246
+ const path = safeRelativePath(
247
+ payload.file_path ?? payload.path ?? toolInput.file_path,
248
+ typeof payload.cwd === "string" ? payload.cwd : process.cwd()
249
+ );
250
+ return mapHookPayload(
251
+ "codex",
252
+ eventName,
253
+ {
254
+ ...payload,
255
+ ...threadId ? { session_id: threadId } : {},
256
+ ...turnId ? { turn_id: turnId } : {},
257
+ ...model ? { model } : {},
258
+ ...path ? { path } : {},
259
+ ...typeof payload.lines_added === "number" ? { lines_added: payload.lines_added } : {},
260
+ ...typeof payload.lines_deleted === "number" ? { lines_deleted: payload.lines_deleted } : {}
261
+ },
262
+ events2,
263
+ safeKeys2
264
+ );
265
+ }
266
+
267
+ // src/adapters/cursor.ts
268
+ var events3 = {
269
+ "session-start": "session.started",
270
+ "session-end": "session.ended",
271
+ heartbeat: "presence.heartbeat",
272
+ "before-submit-prompt": "lifecycle.before",
273
+ "after-agent-response": "lifecycle.after",
274
+ "before-tool": "tool.started",
275
+ "after-tool": "tool.completed",
276
+ "after-file-edit": "file.changed",
277
+ "before-shell-execution": "shell.started",
278
+ "after-shell-execution": "shell.completed",
279
+ "before-mcp-execution": "mcp.started",
280
+ "after-mcp-execution": "mcp.completed"
281
+ };
282
+ var safeKeys3 = [
283
+ "generation_id",
284
+ "tool_name",
285
+ "command_type",
286
+ "file_extension",
287
+ "language",
288
+ "model",
289
+ "path",
290
+ "lines_added",
291
+ "lines_deleted",
292
+ "session_started_at",
293
+ "heartbeat_at",
294
+ "heartbeat_interval_ms",
295
+ "session_elapsed_ms"
296
+ ];
297
+ function mapCursorHook(eventName, input) {
298
+ const payload = objectPayload(input);
299
+ const sessionId = safeIdentifier(payload.conversation_id ?? payload.session_id);
300
+ const model = safeModel(payload.model);
301
+ const workspaceRoot = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.find(
302
+ (root) => typeof root === "string"
303
+ ) : void 0;
304
+ const path = safeRelativePath(
305
+ payload.file_path,
306
+ workspaceRoot ?? process.cwd()
307
+ );
308
+ return mapHookPayload(
309
+ "cursor",
310
+ eventName,
311
+ {
312
+ ...payload,
313
+ ...sessionId ? { session_id: sessionId } : {},
314
+ ...model ? { model } : {},
315
+ ...path ? { path } : {},
316
+ ...typeof payload.lines_added === "number" ? { lines_added: payload.lines_added } : {},
317
+ ...typeof payload.lines_deleted === "number" ? { lines_deleted: payload.lines_deleted } : {}
318
+ },
319
+ events3,
320
+ safeKeys3
321
+ );
322
+ }
323
+
324
+ // src/client.ts
325
+ import { randomUUID as randomUUID2 } from "crypto";
326
+ function eventTypeForHook(event) {
327
+ if (event.kind === "session.started") return "presence.started";
328
+ if (event.kind === "presence.heartbeat") return "presence.heartbeat";
329
+ if (event.kind === "session.ended") return "presence.stopped";
330
+ if (event.kind === "file.changed") return "workspace.files_changed";
331
+ if (event.kind === "shell.completed" && event.outcome && isTestHookEvent(event)) {
332
+ return "workspace.test_completed";
333
+ }
334
+ return null;
335
+ }
336
+ function isTestHookEvent(event) {
337
+ const labels = [
338
+ event.metadata.command_type,
339
+ event.metadata.tool_name,
340
+ event.metadata.task_type
341
+ ].filter((value) => typeof value === "string").join(" ").toLowerCase();
342
+ return /(^|\W)(test|tests|testing|vitest|jest|pytest|playwright)(\W|$)/.test(
343
+ labels
344
+ );
345
+ }
346
+ function toEnvelope(event, options) {
347
+ const type = eventTypeForHook(event);
348
+ if (!type) {
349
+ throw new Error(`Hook event ${event.kind} is not publishable`);
350
+ }
351
+ const paths = [
352
+ ...Array.isArray(event.metadata.paths) ? event.metadata.paths.filter((path) => typeof path === "string") : [],
353
+ ...typeof event.metadata.path === "string" ? [event.metadata.path] : []
354
+ ];
355
+ const payload = event.kind === "file.changed" ? {
356
+ paths,
357
+ tool: event.source,
358
+ agent_name: event.source,
359
+ hook_kind: event.kind,
360
+ lines_added: typeof event.metadata.lines_added === "number" ? event.metadata.lines_added : void 0,
361
+ lines_deleted: typeof event.metadata.lines_deleted === "number" ? event.metadata.lines_deleted : void 0
362
+ } : event.kind === "presence.heartbeat" ? {
363
+ verification: event.metadata.verification,
364
+ sequence: event.metadata.sequence,
365
+ active_seconds: event.metadata.active_seconds,
366
+ lines_added: event.metadata.lines_added,
367
+ lines_deleted: event.metadata.lines_deleted,
368
+ changed_files: event.metadata.changed_files,
369
+ model: event.metadata.model,
370
+ model_active_seconds: event.metadata.model_active_seconds,
371
+ model_seconds: event.metadata.model_seconds,
372
+ tool_counts: event.metadata.tool_counts,
373
+ tests_passed: event.metadata.tests_passed,
374
+ tests_failed: event.metadata.tests_failed,
375
+ session_started_at: event.metadata.session_started_at,
376
+ heartbeat_at: event.metadata.heartbeat_at,
377
+ heartbeat_interval_ms: event.metadata.heartbeat_interval_ms,
378
+ session_elapsed_ms: event.metadata.session_elapsed_ms,
379
+ working_tree_lines_added: event.metadata.working_tree_lines_added,
380
+ working_tree_lines_deleted: event.metadata.working_tree_lines_deleted,
381
+ tool: event.source,
382
+ agent_name: event.source,
383
+ hook_kind: event.kind
384
+ } : {
385
+ paths,
386
+ tool: event.source,
387
+ agent_name: event.source,
388
+ outcome: event.outcome,
389
+ duration_ms: event.durationMs,
390
+ hook_kind: event.kind,
391
+ ...event.metadata
392
+ };
393
+ return {
394
+ event_id: event.id,
395
+ schema_version: 1,
396
+ occurred_at: event.occurredAt,
397
+ organization_id: options.organizationId,
398
+ project_id: options.projectId,
399
+ repository_id: options.repositoryId,
400
+ device_id: options.deviceId,
401
+ source: "local_collector",
402
+ type,
403
+ visibility: "project",
404
+ idempotency_key: `${event.source}:${event.id}`,
405
+ correlation: {
406
+ session_id: event.sessionId,
407
+ branch: event.repo?.branch,
408
+ commit_sha: event.repo?.commit
409
+ },
410
+ payload
411
+ };
412
+ }
413
+ var SignedBatchClient = class {
414
+ constructor(options) {
415
+ this.options = options;
416
+ this.request = options.fetch ?? globalThis.fetch;
417
+ this.batchSize = options.batchSize ?? 50;
418
+ this.timeoutMs = options.timeoutMs ?? 1e4;
419
+ }
420
+ options;
421
+ request;
422
+ batchSize;
423
+ timeoutMs;
424
+ async capture(event) {
425
+ const parsed = canonicalHookEventSchema.parse(event);
426
+ if (!eventTypeForHook(parsed)) {
427
+ return { sent: 0, remaining: await this.options.queue.size() };
428
+ }
429
+ await this.options.queue.enqueue([
430
+ {
431
+ ...parsed,
432
+ metadata: redact(parsed.metadata)
433
+ }
434
+ ]);
435
+ return this.flush();
436
+ }
437
+ async flush() {
438
+ const queuedEvents = await this.options.queue.peek(this.batchSize);
439
+ if (queuedEvents.length === 0) return { sent: 0, remaining: 0 };
440
+ const events4 = queuedEvents.filter((event) => eventTypeForHook(event));
441
+ const ignoredIds = queuedEvents.filter((event) => !eventTypeForHook(event)).map((event) => event.id);
442
+ if (ignoredIds.length > 0) await this.options.queue.remove(ignoredIds);
443
+ if (events4.length === 0) {
444
+ return { sent: 0, remaining: await this.options.queue.size() };
445
+ }
446
+ const body = JSON.stringify({
447
+ batch_id: randomUUID2(),
448
+ events: events4.map((event) => toEnvelope(event, this.options))
449
+ });
450
+ try {
451
+ const response = await this.request(this.options.endpoint, {
452
+ method: "POST",
453
+ body,
454
+ signal: AbortSignal.timeout(this.timeoutMs),
455
+ headers: {
456
+ "content-type": "application/json",
457
+ "user-agent": "wibe-bridge/1",
458
+ authorization: `Bearer ${this.options.accessToken}`
459
+ }
460
+ });
461
+ if (!response.ok) {
462
+ const requestId = response.headers.get("x-request-id");
463
+ let detail = "";
464
+ try {
465
+ const body2 = await response.json();
466
+ const code = typeof body2.code === "string" ? body2.code.slice(0, 80) : void 0;
467
+ const message = typeof body2.error === "string" ? body2.error.slice(0, 160) : void 0;
468
+ detail = [code, message].filter(Boolean).join(": ");
469
+ } catch {
470
+ }
471
+ const suffix = [
472
+ detail ? `: ${detail}` : "",
473
+ requestId ? ` (request ${requestId.slice(0, 80)})` : ""
474
+ ].join("");
475
+ throw new Error(`Collector returned HTTP ${response.status}${suffix}`);
476
+ }
477
+ await this.options.queue.remove(events4.map((event) => event.id));
478
+ return { sent: events4.length, remaining: await this.options.queue.size() };
479
+ } catch (error) {
480
+ return {
481
+ sent: 0,
482
+ remaining: await this.options.queue.size(),
483
+ error: error instanceof Error ? error.message : String(error)
484
+ };
485
+ }
486
+ }
487
+ };
488
+
489
+ // src/queue.ts
490
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
491
+ import { dirname } from "path";
492
+ var MemoryOfflineQueue = class {
493
+ events = [];
494
+ async enqueue(events4) {
495
+ this.events.push(...events4);
496
+ }
497
+ async peek(limit) {
498
+ return this.events.slice(0, Math.max(0, limit));
499
+ }
500
+ async remove(ids) {
501
+ const removed = new Set(ids);
502
+ this.events = this.events.filter((event) => !removed.has(event.id));
503
+ }
504
+ async size() {
505
+ return this.events.length;
506
+ }
507
+ };
508
+ var JsonFileOfflineQueue = class {
509
+ constructor(filePath) {
510
+ this.filePath = filePath;
511
+ }
512
+ filePath;
513
+ operation = Promise.resolve();
514
+ async enqueue(events4) {
515
+ await this.update((current) => [...current, ...events4]);
516
+ }
517
+ async peek(limit) {
518
+ await this.operation;
519
+ return (await this.read()).slice(0, Math.max(0, limit));
520
+ }
521
+ async remove(ids) {
522
+ const removed = new Set(ids);
523
+ await this.update((current) => current.filter((event) => !removed.has(event.id)));
524
+ }
525
+ async size() {
526
+ await this.operation;
527
+ return (await this.read()).length;
528
+ }
529
+ async read() {
530
+ try {
531
+ const data = JSON.parse(await readFile(this.filePath, "utf8"));
532
+ return canonicalHookEventSchema.array().parse(data);
533
+ } catch (error) {
534
+ if (error.code === "ENOENT") return [];
535
+ throw error;
536
+ }
537
+ }
538
+ async update(transform) {
539
+ const next = this.operation.then(async () => {
540
+ const events4 = transform(await this.read());
541
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
542
+ const temporary = `${this.filePath}.${process.pid}.tmp`;
543
+ await writeFile(temporary, JSON.stringify(events4), { encoding: "utf8", mode: 384 });
544
+ await rename(temporary, this.filePath);
545
+ });
546
+ this.operation = next.catch(() => void 0);
547
+ await next;
548
+ }
549
+ };
550
+
551
+ // src/presence.ts
552
+ import { createHash, randomUUID as randomUUID3 } from "crypto";
553
+ import { spawn } from "child_process";
554
+ import { mkdir as mkdir2, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
555
+ import { homedir } from "os";
556
+ import { join } from "path";
557
+ var PRESENCE_HEARTBEAT_INTERVAL_MS = 45e3;
558
+ var MAX_SESSION_DURATION_MS = 12 * 60 * 60 * 1e3;
559
+ async function startPresenceSession(source, sessionId, cwd = process.cwd()) {
560
+ const statePath = presenceStatePath(source, sessionId, cwd);
561
+ await rm(statePath, { force: true });
562
+ await mkdir2(presenceDirectory(), { recursive: true, mode: 448 });
563
+ const instanceId = randomUUID3();
564
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
565
+ const cliPath = process.argv[1];
566
+ const state = {
567
+ instanceId,
568
+ source,
569
+ ...sessionId ? { sessionId } : {},
570
+ startedAt,
571
+ pid: 0,
572
+ sequence: 0,
573
+ linesAdded: 0,
574
+ linesDeleted: 0,
575
+ paths: [],
576
+ modelSeconds: {},
577
+ toolCounts: {},
578
+ testsPassed: 0,
579
+ testsFailed: 0,
580
+ measuredAt: startedAt
581
+ };
582
+ const metrics = metricsFromState(state, startedAt);
583
+ if (!cliPath) return metrics;
584
+ const child = spawn(
585
+ process.execPath,
586
+ [
587
+ ...process.execArgv,
588
+ cliPath,
589
+ "_presence-heartbeat",
590
+ "--adapter",
591
+ source,
592
+ "--state",
593
+ statePath,
594
+ "--instance",
595
+ instanceId
596
+ ],
597
+ {
598
+ cwd,
599
+ detached: true,
600
+ stdio: "ignore",
601
+ env: process.env
602
+ }
603
+ );
604
+ child.unref();
605
+ state.pid = child.pid ?? 0;
606
+ await writePresenceState(statePath, state);
607
+ return metrics;
608
+ }
609
+ async function updatePresenceSession(source, sessionId, event, cwd = process.cwd()) {
610
+ const statePath = presenceStatePath(source, sessionId, cwd);
611
+ const state = await readPresenceState(statePath);
612
+ if (!state) return void 0;
613
+ const measuredAt = (/* @__PURE__ */ new Date()).toISOString();
614
+ accrueModelTime(state, measuredAt);
615
+ const model = typeof event.metadata.model === "string" ? event.metadata.model : void 0;
616
+ if (model) state.model = model;
617
+ state.linesAdded += safeCount(event.metadata.lines_added);
618
+ state.linesDeleted += safeCount(event.metadata.lines_deleted);
619
+ const workingLinesAdded = optionalCount(
620
+ event.metadata.working_tree_lines_added
621
+ );
622
+ const workingLinesDeleted = optionalCount(
623
+ event.metadata.working_tree_lines_deleted
624
+ );
625
+ if (workingLinesAdded !== void 0) {
626
+ state.baselineLinesAdded ??= workingLinesAdded;
627
+ state.linesAdded = Math.max(
628
+ state.linesAdded,
629
+ workingLinesAdded - state.baselineLinesAdded
630
+ );
631
+ }
632
+ if (workingLinesDeleted !== void 0) {
633
+ state.baselineLinesDeleted ??= workingLinesDeleted;
634
+ state.linesDeleted = Math.max(
635
+ state.linesDeleted,
636
+ workingLinesDeleted - state.baselineLinesDeleted
637
+ );
638
+ }
639
+ const paths = [
640
+ ...Array.isArray(event.metadata.paths) ? event.metadata.paths.filter(
641
+ (path) => typeof path === "string"
642
+ ) : [],
643
+ ...Array.isArray(event.metadata.working_tree_paths) ? event.metadata.working_tree_paths.filter(
644
+ (path) => typeof path === "string"
645
+ ) : [],
646
+ ...typeof event.metadata.path === "string" ? [event.metadata.path] : []
647
+ ];
648
+ state.paths = [.../* @__PURE__ */ new Set([...state.paths, ...paths])].slice(0, 2e3);
649
+ const category = toolCategory(event);
650
+ if (category) {
651
+ state.toolCounts[category] = (state.toolCounts[category] ?? 0) + 1;
652
+ }
653
+ if (event.kind === "shell.completed" && isTestEvent(event)) {
654
+ if (event.outcome === "success") state.testsPassed += 1;
655
+ if (event.outcome === "failure") state.testsFailed += 1;
656
+ }
657
+ await writePresenceState(statePath, state);
658
+ return metricsFromState(state, measuredAt);
659
+ }
660
+ async function stopPresenceSession(source, sessionId, cwd = process.cwd()) {
661
+ const statePath = presenceStatePath(source, sessionId, cwd);
662
+ const state = await readPresenceState(statePath);
663
+ await rm(statePath, { force: true });
664
+ if (!state) return void 0;
665
+ const measuredAt = (/* @__PURE__ */ new Date()).toISOString();
666
+ accrueModelTime(state, measuredAt);
667
+ state.sequence += 1;
668
+ return metricsFromState(state, measuredAt);
669
+ }
670
+ async function runPresenceHeartbeat(statePath, expectedInstanceId, emit, intervalMs = PRESENCE_HEARTBEAT_INTERVAL_MS) {
671
+ let nextHeartbeat = Date.now() + intervalMs;
672
+ for (; ; ) {
673
+ await delay(Math.min(1e3, Math.max(0, nextHeartbeat - Date.now())));
674
+ const state = await readPresenceState(statePath);
675
+ if (!state || state.instanceId !== expectedInstanceId) return;
676
+ if (Date.now() < nextHeartbeat) continue;
677
+ const heartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
678
+ if (Date.parse(heartbeatAt) - Date.parse(state.startedAt) > MAX_SESSION_DURATION_MS) {
679
+ await rm(statePath, { force: true });
680
+ return;
681
+ }
682
+ accrueModelTime(state, heartbeatAt);
683
+ state.sequence += 1;
684
+ await writePresenceState(statePath, state);
685
+ await emit(state.source, "heartbeat", {
686
+ ...state.sessionId ? { session_id: state.sessionId } : {},
687
+ heartbeat_at: heartbeatAt,
688
+ heartbeat_interval_ms: intervalMs,
689
+ ...metricsFromState(state, heartbeatAt)
690
+ });
691
+ nextHeartbeat += intervalMs;
692
+ if (nextHeartbeat <= Date.now()) nextHeartbeat = Date.now() + intervalMs;
693
+ }
694
+ }
695
+ function presenceStatePath(source, sessionId, cwd = process.cwd()) {
696
+ const key = createHash("sha256").update(`${source}\0${sessionId ?? ""}\0${cwd}`).digest("hex");
697
+ return join(presenceDirectory(), `${key}.json`);
698
+ }
699
+ function presenceDirectory() {
700
+ return process.env.WIBE_PRESENCE_DIR ?? join(homedir(), ".wibe", "presence");
701
+ }
702
+ async function readPresenceState(path) {
703
+ try {
704
+ const value = JSON.parse(await readFile2(path, "utf8"));
705
+ if (typeof value.instanceId !== "string" || value.source !== "cursor" && value.source !== "claude-code" && value.source !== "codex" || typeof value.startedAt !== "string" || !Number.isFinite(Date.parse(value.startedAt)) || typeof value.pid !== "number") {
706
+ return void 0;
707
+ }
708
+ return {
709
+ instanceId: value.instanceId,
710
+ source: value.source,
711
+ sessionId: typeof value.sessionId === "string" ? value.sessionId : void 0,
712
+ startedAt: value.startedAt,
713
+ pid: value.pid,
714
+ sequence: safeCount(value.sequence),
715
+ linesAdded: safeCount(value.linesAdded),
716
+ linesDeleted: safeCount(value.linesDeleted),
717
+ baselineLinesAdded: optionalCount(value.baselineLinesAdded),
718
+ baselineLinesDeleted: optionalCount(value.baselineLinesDeleted),
719
+ paths: Array.isArray(value.paths) ? value.paths.filter((path2) => typeof path2 === "string") : [],
720
+ model: typeof value.model === "string" ? value.model : void 0,
721
+ modelSeconds: safeCountRecord(value.modelSeconds),
722
+ toolCounts: safeCountRecord(value.toolCounts),
723
+ testsPassed: safeCount(value.testsPassed),
724
+ testsFailed: safeCount(value.testsFailed),
725
+ measuredAt: typeof value.measuredAt === "string" && Number.isFinite(Date.parse(value.measuredAt)) ? value.measuredAt : value.startedAt
726
+ };
727
+ } catch {
728
+ return void 0;
729
+ }
730
+ }
731
+ async function writePresenceState(path, state) {
732
+ await writeFile2(path, `${JSON.stringify(state)}
733
+ `, { mode: 384 });
734
+ }
735
+ function accrueModelTime(state, measuredAt) {
736
+ const elapsedSeconds = Math.max(
737
+ 0,
738
+ Math.floor(
739
+ (Date.parse(measuredAt) - Date.parse(state.measuredAt || state.startedAt)) / 1e3
740
+ )
741
+ );
742
+ if (state.model && elapsedSeconds > 0) {
743
+ state.modelSeconds[state.model] = (state.modelSeconds[state.model] ?? 0) + elapsedSeconds;
744
+ }
745
+ state.measuredAt = measuredAt;
746
+ }
747
+ function metricsFromState(state, measuredAt) {
748
+ const sessionElapsedMs = Math.max(
749
+ 0,
750
+ Date.parse(measuredAt) - Date.parse(state.startedAt)
751
+ );
752
+ return {
753
+ session_started_at: state.startedAt,
754
+ session_elapsed_ms: sessionElapsedMs,
755
+ sequence: state.sequence,
756
+ active_seconds: Math.floor(sessionElapsedMs / 1e3),
757
+ lines_added: state.linesAdded,
758
+ lines_deleted: state.linesDeleted,
759
+ changed_files: state.paths.length,
760
+ model_seconds: state.modelSeconds,
761
+ tool_counts: state.toolCounts,
762
+ tests_passed: state.testsPassed,
763
+ tests_failed: state.testsFailed
764
+ };
765
+ }
766
+ function safeCount(value) {
767
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
768
+ }
769
+ function optionalCount(value) {
770
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : void 0;
771
+ }
772
+ function safeCountRecord(value) {
773
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
774
+ return Object.fromEntries(
775
+ Object.entries(value).filter(
776
+ (entry) => typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] >= 0
777
+ ).map(([key, count]) => [key.slice(0, 160), Math.floor(count)])
778
+ );
779
+ }
780
+ function toolCategory(event) {
781
+ if (event.kind === "file.changed") return "edit";
782
+ if (event.kind === "shell.started" || event.kind === "shell.completed") {
783
+ return "shell";
784
+ }
785
+ if (event.kind === "mcp.started" || event.kind === "mcp.completed") {
786
+ return "mcp";
787
+ }
788
+ if (event.kind === "tool.started" || event.kind === "tool.completed") {
789
+ return "tool";
790
+ }
791
+ return void 0;
792
+ }
793
+ function isTestEvent(event) {
794
+ const labels = [
795
+ event.metadata.command_type,
796
+ event.metadata.tool_name,
797
+ event.metadata.task_type
798
+ ].filter((value) => typeof value === "string").join(" ").toLowerCase();
799
+ return /(^|\W)(test|tests|testing|vitest|jest|pytest|playwright)(\W|$)/.test(
800
+ labels
801
+ );
802
+ }
803
+ function delay(milliseconds) {
804
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
805
+ }
806
+
807
+ // src/repo.ts
808
+ import { execFile } from "child_process";
809
+ import { promisify } from "util";
810
+ var execFileAsync = promisify(execFile);
811
+ async function git(cwd, args) {
812
+ try {
813
+ const { stdout } = await execFileAsync("git", args, {
814
+ cwd,
815
+ encoding: "utf8",
816
+ timeout: 2e3,
817
+ maxBuffer: 64 * 1024
818
+ });
819
+ return stdout.trim() || void 0;
820
+ } catch {
821
+ return void 0;
822
+ }
823
+ }
824
+ async function detectRepository(cwd = process.cwd()) {
825
+ const root = await git(cwd, ["rev-parse", "--show-toplevel"]);
826
+ if (!root) return void 0;
827
+ const [remote, branch, commit] = await Promise.all([
828
+ git(root, ["remote", "get-url", "origin"]),
829
+ git(root, ["branch", "--show-current"]),
830
+ git(root, ["rev-parse", "HEAD"])
831
+ ]);
832
+ return {
833
+ root,
834
+ ...remote ? { remote: sanitizeRemote(remote) } : {},
835
+ ...branch ? { branch } : {},
836
+ ...commit ? { commit } : {}
837
+ };
838
+ }
839
+ async function detectWorkingTreeMetrics(root) {
840
+ const output = await git(root, ["diff", "--numstat", "HEAD", "--"]);
841
+ if (output === void 0) return void 0;
842
+ let linesAdded = 0;
843
+ let linesDeleted = 0;
844
+ const paths = [];
845
+ for (const line of output.split("\n")) {
846
+ if (!line) continue;
847
+ const [added, deleted, ...pathParts] = line.split(" ");
848
+ const path = pathParts.join(" ");
849
+ if (!path || isSensitivePath(path)) continue;
850
+ if (/^\d+$/.test(added)) linesAdded += Number(added);
851
+ if (/^\d+$/.test(deleted)) linesDeleted += Number(deleted);
852
+ paths.push(path);
853
+ }
854
+ return { linesAdded, linesDeleted, paths: [...new Set(paths)].slice(0, 2e3) };
855
+ }
856
+ function sanitizeRemote(remote) {
857
+ try {
858
+ const url = new URL(remote);
859
+ url.username = "";
860
+ url.password = "";
861
+ return url.toString().replace(/\/$/, "");
862
+ } catch {
863
+ return remote.replace(
864
+ /^(?:[^@\s]+@)?([^:\s]+):(.+)$/,
865
+ (_match, host, path) => `${host}:${path}`
866
+ );
867
+ }
868
+ }
869
+ function normalizeGitHubRepository(value) {
870
+ const trimmed = value.trim();
871
+ if (!trimmed) return void 0;
872
+ let path = trimmed;
873
+ try {
874
+ const url = new URL(trimmed);
875
+ if (url.hostname.toLowerCase() !== "github.com") return void 0;
876
+ path = url.pathname;
877
+ } catch {
878
+ const scpLike = trimmed.match(
879
+ /^(?:(?:[^@\s]+)@)?github\.com:(.+)$/i
880
+ );
881
+ if (scpLike) {
882
+ path = scpLike[1];
883
+ } else if (/^github\.com\//i.test(trimmed)) {
884
+ path = trimmed.replace(/^github\.com\//i, "");
885
+ } else if (/^[^/\s]+\/[^/\s]+\/?$/.test(trimmed)) {
886
+ path = trimmed;
887
+ } else {
888
+ return void 0;
889
+ }
890
+ }
891
+ const normalized = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase();
892
+ return /^[^/\s]+\/[^/\s]+$/.test(normalized) ? normalized : void 0;
893
+ }
894
+ function matchesGitHubRepository(remote, expected) {
895
+ const normalizedRemote = remote ? normalizeGitHubRepository(remote) : void 0;
896
+ const normalizedExpected = normalizeGitHubRepository(expected);
897
+ return Boolean(
898
+ normalizedRemote && normalizedExpected && normalizedRemote === normalizedExpected
899
+ );
900
+ }
901
+ function isSensitivePath(path) {
902
+ return /(^|\/)\.env(?:\.|$)/i.test(path) || /(^|\/)(?:id_rsa|id_ed25519|credentials|secrets?)(?:\.|$)/i.test(path) || /\.(?:pem|key|p12|pfx)$/i.test(path);
903
+ }
904
+
905
+ // src/auth.ts
906
+ import { z as z2 } from "zod";
907
+ import { execFile as execFileCallback } from "child_process";
908
+ import { promisify as promisify2 } from "util";
909
+ var execFile2 = promisify2(execFileCallback);
910
+ var deviceAuthorizationSchema = z2.object({
911
+ device_code: z2.string().min(1),
912
+ user_code: z2.string().min(1),
913
+ verification_uri: z2.string().url(),
914
+ verification_uri_complete: z2.string().url().optional(),
915
+ expires_at: z2.string().datetime(),
916
+ interval: z2.number().int().positive().default(5)
917
+ });
918
+ var deviceTokenResponseSchema = z2.discriminatedUnion("status", [
919
+ z2.object({
920
+ status: z2.literal("approved"),
921
+ accessToken: z2.string().min(1),
922
+ projectId: z2.string().uuid(),
923
+ organizationId: z2.string().uuid(),
924
+ repositoryId: z2.string().uuid().optional(),
925
+ deviceId: z2.string().uuid()
926
+ }),
927
+ z2.object({
928
+ status: z2.enum(["pending", "denied"])
929
+ }),
930
+ z2.object({
931
+ status: z2.enum(["expired", "invalid"])
932
+ })
933
+ ]);
934
+ var SystemCredentialStore = class {
935
+ async get(service, account) {
936
+ try {
937
+ if (process.platform === "darwin") {
938
+ const { stdout } = await execFile2("security", [
939
+ "find-generic-password",
940
+ "-s",
941
+ service,
942
+ "-a",
943
+ account,
944
+ "-w"
945
+ ]);
946
+ return stdout.trim() || void 0;
947
+ }
948
+ if (process.platform === "linux") {
949
+ const { stdout } = await execFile2("secret-tool", [
950
+ "lookup",
951
+ "service",
952
+ service,
953
+ "account",
954
+ account
955
+ ]);
956
+ return stdout.trim() || void 0;
957
+ }
958
+ throw new Error("Use WIBE_ACCESS_TOKEN on platforms without a supported keychain.");
959
+ } catch (error) {
960
+ if (error instanceof Error && error.message.startsWith("Use WIBE_")) throw error;
961
+ return void 0;
962
+ }
963
+ }
964
+ async set(service, account, value) {
965
+ if (process.platform === "darwin") {
966
+ await execFile2("security", [
967
+ "add-generic-password",
968
+ "-U",
969
+ "-s",
970
+ service,
971
+ "-a",
972
+ account,
973
+ "-w",
974
+ value
975
+ ]);
976
+ return;
977
+ }
978
+ if (process.platform === "linux") {
979
+ await new Promise((resolve2, reject) => {
980
+ const child = execFileCallback(
981
+ "secret-tool",
982
+ ["store", "--label=Wibe agent bridge", "service", service, "account", account],
983
+ (error) => error ? reject(error) : resolve2()
984
+ );
985
+ child.stdin?.end(value);
986
+ });
987
+ return;
988
+ }
989
+ throw new Error("No supported OS keychain was found.");
990
+ }
991
+ async delete(service, account) {
992
+ if (process.platform === "darwin") {
993
+ await execFile2("security", [
994
+ "delete-generic-password",
995
+ "-s",
996
+ service,
997
+ "-a",
998
+ account
999
+ ]).catch(() => void 0);
1000
+ return;
1001
+ }
1002
+ if (process.platform === "linux") {
1003
+ await execFile2("secret-tool", [
1004
+ "clear",
1005
+ "service",
1006
+ service,
1007
+ "account",
1008
+ account
1009
+ ]).catch(() => void 0);
1010
+ return;
1011
+ }
1012
+ }
1013
+ };
1014
+ async function requestDeviceAuthorization(input) {
1015
+ const response = await fetch(
1016
+ `${input.appUrl.replace(/\/$/, "")}/api/devices/authorize`,
1017
+ {
1018
+ method: "POST",
1019
+ headers: { "content-type": "application/json" },
1020
+ body: JSON.stringify({
1021
+ projectId: input.projectId,
1022
+ deviceName: input.deviceName,
1023
+ agentName: input.agentName
1024
+ })
1025
+ }
1026
+ );
1027
+ if (!response.ok) throw new Error(`Device authorization failed (${response.status}).`);
1028
+ return deviceAuthorizationSchema.parse(await response.json());
1029
+ }
1030
+ async function pollDeviceToken(input) {
1031
+ const response = await fetch(
1032
+ `${input.appUrl.replace(/\/$/, "")}/api/devices/authorize`,
1033
+ {
1034
+ method: "PUT",
1035
+ headers: { "content-type": "application/json" },
1036
+ body: JSON.stringify({ device_code: input.deviceCode })
1037
+ }
1038
+ );
1039
+ if (!response.ok) throw new Error(`Device polling failed (${response.status}).`);
1040
+ return deviceTokenResponseSchema.parse(await response.json());
1041
+ }
1042
+
1043
+ export {
1044
+ agentSourceSchema,
1045
+ hookEventKindSchema,
1046
+ safeValueSchema,
1047
+ canonicalHookEventSchema,
1048
+ createHookEvent,
1049
+ redact,
1050
+ safeMetadata,
1051
+ mapClaudeCodeHook,
1052
+ mapCodexHook,
1053
+ mapCursorHook,
1054
+ eventTypeForHook,
1055
+ SignedBatchClient,
1056
+ MemoryOfflineQueue,
1057
+ JsonFileOfflineQueue,
1058
+ PRESENCE_HEARTBEAT_INTERVAL_MS,
1059
+ startPresenceSession,
1060
+ updatePresenceSession,
1061
+ stopPresenceSession,
1062
+ runPresenceHeartbeat,
1063
+ presenceStatePath,
1064
+ detectRepository,
1065
+ detectWorkingTreeMetrics,
1066
+ sanitizeRemote,
1067
+ normalizeGitHubRepository,
1068
+ matchesGitHubRepository,
1069
+ deviceAuthorizationSchema,
1070
+ deviceTokenResponseSchema,
1071
+ SystemCredentialStore,
1072
+ requestDeviceAuthorization,
1073
+ pollDeviceToken
1074
+ };