@birdybeep/agent-core 0.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1027 @@
1
+ // src/adapter.ts
2
+ var INTEGRATION_STATUSES = [
3
+ "installed",
4
+ "not_detected",
5
+ "needs_restart",
6
+ "needs_trust",
7
+ "error",
8
+ "revoked",
9
+ "unknown"
10
+ ];
11
+
12
+ // src/api.ts
13
+ import { z } from "zod";
14
+ var ERROR_CODES = [
15
+ "validation_failed",
16
+ "unauthorized",
17
+ "forbidden",
18
+ "token_revoked",
19
+ "not_found",
20
+ "payload_too_large",
21
+ "rate_limited",
22
+ "quota_exceeded",
23
+ "internal_error"
24
+ ];
25
+ var errorCodeSchema = z.enum(ERROR_CODES);
26
+ var errorEnvelopeSchema = z.object({
27
+ error: z.object({
28
+ code: errorCodeSchema,
29
+ message: z.string(),
30
+ details: z.unknown().optional()
31
+ }),
32
+ requestId: z.string().optional()
33
+ });
34
+ var successEnvelopeSchema = (data) => z.object({ data });
35
+ var ERROR_STATUS = {
36
+ validation_failed: 400,
37
+ unauthorized: 401,
38
+ forbidden: 403,
39
+ token_revoked: 403,
40
+ not_found: 404,
41
+ payload_too_large: 413,
42
+ rate_limited: 429,
43
+ quota_exceeded: 429,
44
+ internal_error: 500
45
+ };
46
+
47
+ // src/dedup.ts
48
+ import { createHash } from "crypto";
49
+ import {
50
+ chmodSync,
51
+ existsSync,
52
+ mkdirSync,
53
+ readFileSync,
54
+ renameSync,
55
+ rmSync,
56
+ statSync,
57
+ writeFileSync
58
+ } from "fs";
59
+ import { dirname, join as join2 } from "path";
60
+
61
+ // src/paths.ts
62
+ import { homedir } from "os";
63
+ import { join } from "path";
64
+ function baseDir(kind) {
65
+ const home = homedir();
66
+ if (process.platform === "win32") {
67
+ const local = process.env["LOCALAPPDATA"];
68
+ return local && local.length > 0 ? local : join(home, "AppData", "Local");
69
+ }
70
+ if (process.platform === "darwin") {
71
+ return join(home, "Library", "Application Support");
72
+ }
73
+ const xdg = kind === "data" ? process.env["XDG_DATA_HOME"] : process.env["XDG_CONFIG_HOME"];
74
+ if (xdg && xdg.length > 0) return xdg;
75
+ return join(home, kind === "data" ? join(".local", "share") : ".config");
76
+ }
77
+ function birdyBeepDataDir() {
78
+ return join(baseDir("data"), "birdybeep");
79
+ }
80
+ function birdyBeepConfigDir() {
81
+ return join(baseDir("config"), "birdybeep");
82
+ }
83
+
84
+ // src/dedup.ts
85
+ var DEFAULT_DEDUP_WINDOW_MS = 1e4;
86
+ var APPROVAL_COLLAPSE_WINDOW_MS = 1e3;
87
+ function isLedgerEntry(v) {
88
+ return typeof v === "object" && v !== null && typeof v["id"] === "string" && typeof v["ts"] === "number";
89
+ }
90
+ function contentHash(title, body) {
91
+ return createHash("sha256").update(`${title}
92
+ ${body}`).digest("hex").slice(0, 16);
93
+ }
94
+ function eventIdentity(event) {
95
+ return `${event.harness}:${event.source_session_id}:${event.event_type}:${contentHash(
96
+ event.title,
97
+ event.body
98
+ )}`;
99
+ }
100
+ function approvalCollapseIdentity(event) {
101
+ return `${event.harness}:${event.source_session_id}:approval_required:any`;
102
+ }
103
+ var RecentEventLedger = class {
104
+ path;
105
+ #windowMs;
106
+ #now;
107
+ constructor(options = {}) {
108
+ this.path = options.path ?? join2(birdyBeepDataDir(), "recent-events.json");
109
+ this.#windowMs = options.windowMs ?? DEFAULT_DEDUP_WINDOW_MS;
110
+ this.#now = options.now ?? (() => Date.now());
111
+ }
112
+ #read() {
113
+ if (!existsSync(this.path)) return [];
114
+ try {
115
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
116
+ return Array.isArray(parsed) ? parsed.filter(isLedgerEntry) : [];
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+ #write(entries) {
122
+ const dir = dirname(this.path);
123
+ mkdirSync(dir, { recursive: true, mode: 448 });
124
+ if (process.platform !== "win32") chmodSync(dir, 448);
125
+ const tmp = `${this.path}.tmp`;
126
+ writeFileSync(tmp, JSON.stringify(entries), { mode: 384 });
127
+ renameSync(tmp, this.path);
128
+ if (process.platform !== "win32") chmodSync(this.path, 384);
129
+ }
130
+ /**
131
+ * If `identity` was recorded within the window, return true (it's a duplicate —
132
+ * caller should skip). Otherwise record it (pruning expired entries) and return
133
+ * false. `windowMs` narrows the duplicate check for THIS identity (e.g. the short
134
+ * approval-collapse window) — pruning always uses the ledger's own (max) window so
135
+ * a narrower check never evicts entries other checks still need. Fail-open: any
136
+ * I/O error returns false (allow the send).
137
+ */
138
+ markAndCheck(identity, windowMs = this.#windowMs) {
139
+ try {
140
+ const now = this.#now();
141
+ const pruneCutoff = now - this.#windowMs;
142
+ const dupCutoff = now - Math.min(windowMs, this.#windowMs);
143
+ const fresh = this.#read().filter((e) => e.ts >= pruneCutoff);
144
+ if (fresh.some((e) => e.id === identity && e.ts >= dupCutoff)) return true;
145
+ fresh.push({ id: identity, ts: now });
146
+ this.#write(fresh);
147
+ return false;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+ clear() {
153
+ try {
154
+ if (existsSync(this.path)) rmSync(this.path, { force: true });
155
+ } catch {
156
+ }
157
+ }
158
+ /** Whether the ledger file has secure (0600) perms. True on Windows (ACL-based). */
159
+ isSecure() {
160
+ if (process.platform === "win32") return true;
161
+ try {
162
+ return (statSync(this.path).mode & 63) === 0;
163
+ } catch {
164
+ return true;
165
+ }
166
+ }
167
+ };
168
+
169
+ // src/event.ts
170
+ import { z as z3 } from "zod";
171
+
172
+ // src/primitives.ts
173
+ import { z as z2 } from "zod";
174
+ var BIRDYBEEP_EVENT_TYPES = [
175
+ "session_started",
176
+ "session_resumed",
177
+ "session_active",
178
+ "needs_input",
179
+ "approval_required",
180
+ "agent_idle",
181
+ "agent_completed",
182
+ "agent_failed",
183
+ "test_failed",
184
+ "tool_started",
185
+ "tool_finished",
186
+ "subagent_started",
187
+ "subagent_completed",
188
+ "custom",
189
+ // "test" (9fh): the `birdybeep test` diagnostic. Notifies by default server-side
190
+ // (unlike "custom", which the §10.5 matrix suppresses unconditionally) and is
191
+ // quota-exempt — the product's DEFAULT_NOTIFY/gauntlet carry the other half.
192
+ "test"
193
+ ];
194
+ var AGENT_SESSION_STATUSES = [
195
+ "starting",
196
+ "running",
197
+ "waiting_for_input",
198
+ "waiting_for_approval",
199
+ "idle",
200
+ "completed",
201
+ "failed",
202
+ "unknown"
203
+ ];
204
+ var HARNESS_IDS = ["claude_code", "codex", "opencode"];
205
+ var eventTypeSchema = z2.enum(BIRDYBEEP_EVENT_TYPES);
206
+ var sessionStatusSchema = z2.enum(AGENT_SESSION_STATUSES);
207
+ var harnessSchema = z2.enum(HARNESS_IDS);
208
+ var isoDateTimeSchema = z2.iso.datetime({ offset: true });
209
+ var MAX_AGENT_EVENT_BYTES = 16 * 1024;
210
+ function isWithinMaxAgentEventSize(body) {
211
+ const bytes = typeof body === "number" ? body : body.byteLength;
212
+ return bytes <= MAX_AGENT_EVENT_BYTES;
213
+ }
214
+
215
+ // src/event.ts
216
+ var machineSchema = z3.object({
217
+ label: z3.string().min(1),
218
+ os: z3.string().min(1)
219
+ });
220
+ var workspaceSchema = z3.object({
221
+ cwd: z3.string().min(1),
222
+ repo_name: z3.string().optional(),
223
+ branch: z3.string().optional()
224
+ });
225
+ var eventMetadataSchema = z3.object({
226
+ tool: z3.string().optional(),
227
+ command_summary: z3.string().optional()
228
+ }).catchall(z3.unknown());
229
+ var birdyBeepAgentEventSchema = z3.object({
230
+ event_id: z3.string().min(1),
231
+ event_type: eventTypeSchema,
232
+ occurred_at: isoDateTimeSchema,
233
+ harness: harnessSchema,
234
+ harness_version: z3.string().optional(),
235
+ source_session_id: z3.string().min(1),
236
+ machine: machineSchema,
237
+ workspace: workspaceSchema,
238
+ status: sessionStatusSchema,
239
+ title: z3.string(),
240
+ body: z3.string(),
241
+ metadata: eventMetadataSchema.optional()
242
+ });
243
+ var agentEventsRequestSchema = birdyBeepAgentEventSchema;
244
+
245
+ // src/fingerprint.ts
246
+ import { createHash as createHash2 } from "crypto";
247
+ import { cpus, hostname, networkInterfaces, totalmem } from "os";
248
+ function primaryMac() {
249
+ const ifaces = networkInterfaces();
250
+ for (const list of Object.values(ifaces)) {
251
+ for (const entry of list ?? []) {
252
+ if (!entry.internal && entry.mac && entry.mac !== "00:00:00:00:00:00") return entry.mac;
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+ function collectMachineSignals() {
258
+ return {
259
+ hostname: hostname(),
260
+ platform: process.platform,
261
+ arch: process.arch,
262
+ cpuModel: cpus()[0]?.model ?? "unknown",
263
+ totalmem: totalmem(),
264
+ mac: primaryMac()
265
+ };
266
+ }
267
+ function fingerprintFromSignals(signals) {
268
+ const material = [
269
+ signals.platform,
270
+ signals.arch,
271
+ signals.hostname,
272
+ signals.cpuModel,
273
+ String(signals.totalmem),
274
+ signals.mac ?? ""
275
+ ].join("\0");
276
+ return createHash2("sha256").update(material).digest("hex");
277
+ }
278
+ function getMachineFingerprintHash(signals = collectMachineSignals()) {
279
+ return fingerprintFromSignals(signals);
280
+ }
281
+ function getOS(platform = process.platform) {
282
+ switch (platform) {
283
+ case "darwin":
284
+ return "macos";
285
+ case "win32":
286
+ return "windows";
287
+ case "linux":
288
+ return "linux";
289
+ default:
290
+ return platform;
291
+ }
292
+ }
293
+ function getMachineLabel(rawHostname = hostname()) {
294
+ const label = rawHostname.replace(/\.local$/i, "").trim();
295
+ return label.length > 0 ? label : `${getOS()}-machine`;
296
+ }
297
+ function getMachineIdentity() {
298
+ return {
299
+ label: getMachineLabel(),
300
+ os: getOS(),
301
+ fingerprintHash: getMachineFingerprintHash()
302
+ };
303
+ }
304
+
305
+ // src/hook.ts
306
+ async function runAgentHook(adapter, rawInput, options) {
307
+ let event;
308
+ try {
309
+ event = await adapter.normalizeEvent(rawInput);
310
+ } catch {
311
+ return { outcome: "skipped" };
312
+ }
313
+ const ledger = options.ledger ?? new RecentEventLedger();
314
+ const contentDup = ledger.markAndCheck(eventIdentity(event));
315
+ const approvalDup = event.event_type === "approval_required" && ledger.markAndCheck(approvalCollapseIdentity(event), APPROVAL_COLLAPSE_WINDOW_MS);
316
+ if (contentDup || approvalDup) {
317
+ return { outcome: "deduped", eventType: event.event_type };
318
+ }
319
+ const send = await options.sender.send(event);
320
+ return { outcome: send.outcome, eventType: event.event_type, send };
321
+ }
322
+
323
+ // src/integrations.ts
324
+ import { z as z4 } from "zod";
325
+ var integrationStatusSchema = z4.enum(INTEGRATION_STATUSES);
326
+ var STATUS_REPORT_MAX_ITEMS = 16;
327
+ var STATUS_REPORT_MAX_PAYLOAD_BYTES = 2048;
328
+ var integrationStatusItemSchema = z4.object({
329
+ harness: harnessSchema,
330
+ status: integrationStatusSchema,
331
+ harness_version: z4.string().max(64).optional(),
332
+ adapter_version: z4.string().max(64).optional(),
333
+ last_status_payload: z4.unknown().optional().refine((v) => v === void 0 || JSON.stringify(v).length <= STATUS_REPORT_MAX_PAYLOAD_BYTES, {
334
+ message: "last_status_payload too large"
335
+ })
336
+ });
337
+ var integrationStatusReportSchema = z4.object({
338
+ integrations: z4.array(integrationStatusItemSchema).min(1).max(STATUS_REPORT_MAX_ITEMS)
339
+ });
340
+ var integrationStatusResponseSchema = z4.object({
341
+ integrations: z4.array(
342
+ z4.object({ harness: harnessSchema, status: integrationStatusSchema }).catchall(z4.unknown())
343
+ )
344
+ });
345
+
346
+ // src/normalize.ts
347
+ import { createHash as createHash3, randomUUID } from "crypto";
348
+ var TITLE_MAX_CHARS = 200;
349
+ var BODY_MAX_CHARS = 2e3;
350
+ var METADATA_VALUE_MAX_CHARS = 500;
351
+ var METADATA_MAX_KEYS = 64;
352
+ var METADATA_MAX_DEPTH = 4;
353
+ var LABEL_MAX_CHARS = 120;
354
+ var NormalizeError = class extends Error {
355
+ constructor(message, issues) {
356
+ super(message);
357
+ this.issues = issues;
358
+ this.name = "NormalizeError";
359
+ }
360
+ issues;
361
+ };
362
+ function isString(v) {
363
+ return typeof v === "string";
364
+ }
365
+ function asRecord(v) {
366
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
367
+ }
368
+ function hashToken(value) {
369
+ return `h_${createHash3("sha256").update(value).digest("hex").slice(0, 16)}`;
370
+ }
371
+ var ABSOLUTE_PATH_RE = /(?:\/[A-Za-z0-9_.-]+){2,}|[A-Za-z]:(?:[\\/][A-Za-z0-9_. -]+)+/g;
372
+ var SECRET_RES = [
373
+ /\bAKIA[0-9A-Z]{16}\b/g,
374
+ // AWS access key id
375
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
376
+ // GitHub tokens
377
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g,
378
+ // OpenAI-style keys
379
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
380
+ // Slack tokens
381
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
382
+ // JWT
383
+ /\b(?:bearer|token|secret|password|passwd|api[_-]?key)\b\s*[:=]\s*\S+/gi
384
+ // key=value secrets
385
+ ];
386
+ function scrubAbsolutePaths(text) {
387
+ return text.replace(ABSOLUTE_PATH_RE, (match) => hashToken(match));
388
+ }
389
+ function redactSecrets(text) {
390
+ let out = text;
391
+ for (const re of SECRET_RES) out = out.replace(re, "[redacted]");
392
+ return out;
393
+ }
394
+ function truncate(text, max) {
395
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}\u2026`;
396
+ }
397
+ function hashPath(path) {
398
+ return hashToken(path);
399
+ }
400
+ function cleanString(text, max) {
401
+ return truncate(redactSecrets(scrubAbsolutePaths(text)), max);
402
+ }
403
+ function sanitizeValue(value, depth) {
404
+ if (isString(value)) return cleanString(value, METADATA_VALUE_MAX_CHARS);
405
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
406
+ if (depth >= METADATA_MAX_DEPTH) return void 0;
407
+ if (Array.isArray(value)) {
408
+ return value.slice(0, METADATA_MAX_KEYS).map((v) => sanitizeValue(v, depth + 1));
409
+ }
410
+ if (typeof value === "object") {
411
+ const out = {};
412
+ let n = 0;
413
+ for (const [k, v] of Object.entries(value)) {
414
+ if (n >= METADATA_MAX_KEYS) break;
415
+ const cleaned = sanitizeValue(v, depth + 1);
416
+ if (cleaned !== void 0) {
417
+ out[cleanString(k, LABEL_MAX_CHARS)] = cleaned;
418
+ n++;
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ return void 0;
424
+ }
425
+ function serializedBytes(event) {
426
+ return Buffer.byteLength(JSON.stringify(event), "utf8");
427
+ }
428
+ function enforceSize(event) {
429
+ if (serializedBytes(event) <= MAX_AGENT_EVENT_BYTES) return event;
430
+ const steps = [
431
+ { ...event, metadata: void 0 },
432
+ { ...event, metadata: void 0, body: truncate(event.body, 256) },
433
+ {
434
+ ...event,
435
+ metadata: void 0,
436
+ body: truncate(event.body, 256),
437
+ title: truncate(event.title, 120)
438
+ }
439
+ ];
440
+ for (const candidate of steps) {
441
+ if (serializedBytes(candidate) <= MAX_AGENT_EVENT_BYTES) return candidate;
442
+ }
443
+ throw new NormalizeError("event exceeds max payload size even after shrinking");
444
+ }
445
+ function normalizeEvent(input, opts = {}) {
446
+ const rec = asRecord(input);
447
+ const ws = asRecord(rec["workspace"]);
448
+ const machine = asRecord(rec["machine"]);
449
+ const candidate = {
450
+ event_id: isString(rec["event_id"]) && rec["event_id"].length > 0 ? rec["event_id"] : opts.generateId?.() ?? `evt_local_${randomUUID()}`,
451
+ event_type: rec["event_type"],
452
+ occurred_at: isString(rec["occurred_at"]) && rec["occurred_at"].length > 0 ? rec["occurred_at"] : opts.now?.() ?? (/* @__PURE__ */ new Date()).toISOString(),
453
+ harness: rec["harness"],
454
+ // source_session_id is a key (§10.3) — keep it stable, but never let a raw path through.
455
+ source_session_id: isString(rec["source_session_id"]) ? scrubAbsolutePaths(rec["source_session_id"]) : rec["source_session_id"],
456
+ machine: {
457
+ label: isString(machine["label"]) ? cleanString(machine["label"], LABEL_MAX_CHARS) : machine["label"],
458
+ os: isString(machine["os"]) ? cleanString(machine["os"], LABEL_MAX_CHARS) : machine["os"]
459
+ },
460
+ workspace: {
461
+ // cwd is an absolute path → always hashed (§15: no absolute path leaves the machine).
462
+ cwd: isString(ws["cwd"]) ? hashPath(ws["cwd"]) : ws["cwd"]
463
+ },
464
+ status: rec["status"],
465
+ title: isString(rec["title"]) ? cleanString(rec["title"], TITLE_MAX_CHARS) : rec["title"],
466
+ body: isString(rec["body"]) ? cleanString(rec["body"], BODY_MAX_CHARS) : rec["body"]
467
+ };
468
+ if (isString(rec["harness_version"])) {
469
+ candidate["harness_version"] = cleanString(rec["harness_version"], LABEL_MAX_CHARS);
470
+ }
471
+ const wsOut = candidate["workspace"];
472
+ if (isString(ws["repo_name"])) wsOut["repo_name"] = cleanString(ws["repo_name"], LABEL_MAX_CHARS);
473
+ if (isString(ws["branch"])) wsOut["branch"] = cleanString(ws["branch"], LABEL_MAX_CHARS);
474
+ if (rec["metadata"] !== void 0) {
475
+ candidate["metadata"] = sanitizeValue(rec["metadata"], 0);
476
+ }
477
+ const parsed = birdyBeepAgentEventSchema.safeParse(candidate);
478
+ if (!parsed.success) {
479
+ throw new NormalizeError("normalized event failed schema validation", parsed.error.issues);
480
+ }
481
+ return enforceSize(parsed.data);
482
+ }
483
+
484
+ // src/pairing.ts
485
+ import { z as z5 } from "zod";
486
+ var pairStartRequestSchema = z5.object({
487
+ machine_label: z5.string().min(1),
488
+ os: z5.string().optional().catch(void 0),
489
+ cli_version: z5.string().optional().catch(void 0),
490
+ requested_scopes: z5.array(z5.string()).optional().catch(void 0)
491
+ });
492
+ var pairTokenRequestSchema = z5.object({
493
+ device_code: z5.string().min(1),
494
+ machine_fingerprint: z5.string().optional().catch(void 0)
495
+ });
496
+ var pairStartResponseSchema = z5.object({
497
+ device_code: z5.string(),
498
+ user_code: z5.string(),
499
+ qr_payload: z5.string(),
500
+ expires_at: isoDateTimeSchema
501
+ });
502
+ var pairTokenResponseSchema = z5.object({
503
+ machine_token: z5.string(),
504
+ machine_id: z5.string()
505
+ });
506
+
507
+ // src/queue.ts
508
+ import { randomUUID as randomUUID2 } from "crypto";
509
+ import {
510
+ chmodSync as chmodSync2,
511
+ existsSync as existsSync2,
512
+ mkdirSync as mkdirSync2,
513
+ readdirSync,
514
+ readFileSync as readFileSync2,
515
+ renameSync as renameSync2,
516
+ rmSync as rmSync2,
517
+ statSync as statSync2,
518
+ writeFileSync as writeFileSync2
519
+ } from "fs";
520
+ import { join as join3 } from "path";
521
+ var QUEUE_RETENTION_MS = 24 * 60 * 60 * 1e3;
522
+ var DEFAULT_DRAIN_MAX = 50;
523
+ var CLAIM_RECLAIM_MS = 6e4;
524
+ function isRecord(v) {
525
+ return typeof v === "object" && v !== null;
526
+ }
527
+ var LocalEventQueue = class {
528
+ dir;
529
+ #retentionMs;
530
+ #now;
531
+ constructor(options = {}) {
532
+ this.dir = options.dir ?? join3(birdyBeepDataDir(), "queue");
533
+ this.#retentionMs = options.retentionMs ?? QUEUE_RETENTION_MS;
534
+ this.#now = options.now ?? (() => Date.now());
535
+ }
536
+ /** Ensure the dir exists with 0700 perms; repair a too-permissive existing dir. */
537
+ #ensureDir() {
538
+ mkdirSync2(this.dir, { recursive: true, mode: 448 });
539
+ if (process.platform !== "win32") chmodSync2(this.dir, 448);
540
+ }
541
+ /** Park a normalized event on disk (atomic write, 0600). Never throws. */
542
+ enqueue(event) {
543
+ try {
544
+ this.#ensureDir();
545
+ const enqueuedAt = this.#now();
546
+ const name = `${enqueuedAt}-${randomUUID2()}.json`;
547
+ const finalPath = join3(this.dir, name);
548
+ const tmpPath = `${finalPath}.tmp`;
549
+ writeFileSync2(tmpPath, JSON.stringify({ enqueuedAt, event }), { mode: 384 });
550
+ renameSync2(tmpPath, finalPath);
551
+ if (process.platform !== "win32") chmodSync2(finalPath, 384);
552
+ return true;
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
557
+ /**
558
+ * Return orphaned `.claim` files (claims older than {@link CLAIM_RECLAIM_MS}) to the
559
+ * queue by renaming them back to their original `.json` name. The claim timestamp is
560
+ * embedded in the claim FILENAME (rename preserves mtime, so fs times would report
561
+ * the enqueue age, not the claim age — an active drain of an old entry must not be
562
+ * "reclaimed" out from under it). Racing reclaims are safe: rename losers ENOENT.
563
+ * Known window: a drainer SUSPENDED (not dead) >60s can resume after its claim was
564
+ * reclaimed and double-send — accepted, since 60s ≫ the 5s send budget (a claim that
565
+ * old almost always is a dead process) and the backend dedupe absorbs the repeat.
566
+ */
567
+ #reclaimOrphanedClaims(names) {
568
+ const cutoff = this.#now() - CLAIM_RECLAIM_MS;
569
+ for (const name of names) {
570
+ const m = /^(.+\.json)\.(\d+)-[0-9a-f-]+\.claim$/.exec(name);
571
+ if (!m || Number(m[2]) > cutoff) continue;
572
+ try {
573
+ renameSync2(join3(this.dir, name), join3(this.dir, m[1]));
574
+ } catch {
575
+ }
576
+ }
577
+ }
578
+ /** Read all non-expired entries (FIFO by enqueue time); prune expired/corrupt ones. */
579
+ #readFresh() {
580
+ if (!existsSync2(this.dir)) return { fresh: [], pruned: 0 };
581
+ if (process.platform !== "win32") {
582
+ try {
583
+ chmodSync2(this.dir, 448);
584
+ } catch {
585
+ }
586
+ }
587
+ let pruned = 0;
588
+ const fresh = [];
589
+ const cutoff = this.#now() - this.#retentionMs;
590
+ let names;
591
+ try {
592
+ names = readdirSync(this.dir);
593
+ this.#reclaimOrphanedClaims(names);
594
+ names = readdirSync(this.dir);
595
+ } catch {
596
+ return { fresh: [], pruned: 0 };
597
+ }
598
+ for (const name of names) {
599
+ if (!name.endsWith(".json")) continue;
600
+ const path = join3(this.dir, name);
601
+ try {
602
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
603
+ const enqueuedAt = isRecord(parsed) ? parsed["enqueuedAt"] : void 0;
604
+ const event = isRecord(parsed) ? parsed["event"] : void 0;
605
+ if (typeof enqueuedAt !== "number" || !isRecord(event)) {
606
+ rmSync2(path, { force: true });
607
+ pruned++;
608
+ continue;
609
+ }
610
+ if (enqueuedAt < cutoff) {
611
+ rmSync2(path, { force: true });
612
+ pruned++;
613
+ continue;
614
+ }
615
+ fresh.push({ path, enqueuedAt, event });
616
+ } catch {
617
+ try {
618
+ rmSync2(path, { force: true });
619
+ } catch {
620
+ }
621
+ pruned++;
622
+ }
623
+ }
624
+ fresh.sort((a, b) => a.enqueuedAt - b.enqueuedAt);
625
+ return { fresh, pruned };
626
+ }
627
+ /** Count of fresh (non-expired) queued events. Prunes expired entries as a side effect. */
628
+ size() {
629
+ return this.#readFresh().fresh.length;
630
+ }
631
+ /**
632
+ * Drain up to `max` fresh entries through `send`. Each entry is CLAIMED via an
633
+ * atomic rename before sending, so two concurrent drains never double-send the
634
+ * same event. delivered/drop remove the entry; retry keeps it for next time.
635
+ * Bounded + best-effort: never throws into the caller.
636
+ */
637
+ async drain(send, options = {}) {
638
+ const max = options.max ?? DEFAULT_DRAIN_MAX;
639
+ const result = { delivered: 0, dropped: 0, kept: 0, pruned: 0 };
640
+ let fresh;
641
+ try {
642
+ this.#ensureDir();
643
+ const read = this.#readFresh();
644
+ fresh = read.fresh;
645
+ result.pruned = read.pruned;
646
+ } catch {
647
+ return result;
648
+ }
649
+ for (const entry of fresh.slice(0, max)) {
650
+ if (options.stopWhen?.() === true) {
651
+ result.kept += 1;
652
+ continue;
653
+ }
654
+ const claim = `${entry.path}.${this.#now()}-${randomUUID2()}.claim`;
655
+ try {
656
+ renameSync2(entry.path, claim);
657
+ } catch {
658
+ continue;
659
+ }
660
+ let outcome;
661
+ try {
662
+ outcome = await send(entry.event);
663
+ } catch {
664
+ outcome = "retry";
665
+ }
666
+ if (outcome === "retry") {
667
+ try {
668
+ renameSync2(claim, entry.path);
669
+ } catch {
670
+ }
671
+ result.kept++;
672
+ } else {
673
+ try {
674
+ rmSync2(claim, { force: true });
675
+ } catch {
676
+ }
677
+ if (outcome === "delivered") result.delivered++;
678
+ else result.dropped++;
679
+ }
680
+ }
681
+ return result;
682
+ }
683
+ /** Remove every queued entry (used by `doctor` / debug tooling). Never throws. */
684
+ clear() {
685
+ if (!existsSync2(this.dir)) return 0;
686
+ let removed = 0;
687
+ try {
688
+ for (const name of readdirSync(this.dir)) {
689
+ try {
690
+ rmSync2(join3(this.dir, name), { force: true });
691
+ removed++;
692
+ } catch {
693
+ }
694
+ }
695
+ } catch {
696
+ }
697
+ return removed;
698
+ }
699
+ /** Whether the queue dir has secure (0700) perms. Returns true on Windows (ACL-based). */
700
+ isSecure() {
701
+ if (process.platform === "win32") return true;
702
+ try {
703
+ return (statSync2(this.dir).mode & 63) === 0;
704
+ } catch {
705
+ return false;
706
+ }
707
+ }
708
+ };
709
+
710
+ // src/token-store.ts
711
+ import { execFile } from "child_process";
712
+ import {
713
+ chmodSync as chmodSync3,
714
+ existsSync as existsSync3,
715
+ mkdirSync as mkdirSync3,
716
+ readFileSync as readFileSync3,
717
+ renameSync as renameSync3,
718
+ rmSync as rmSync3,
719
+ statSync as statSync3,
720
+ writeFileSync as writeFileSync3
721
+ } from "fs";
722
+ import { dirname as dirname2, join as join4 } from "path";
723
+ import { promisify } from "util";
724
+ var execFileAsync = promisify(execFile);
725
+ var SERVICE = "birdybeep";
726
+ var ACCOUNT = "machine-token";
727
+ var FileTokenStore = class {
728
+ kind = "file";
729
+ path;
730
+ constructor(options = {}) {
731
+ this.path = options.path ?? join4(birdyBeepDataDir(), "token");
732
+ }
733
+ // Sync internals, Promise-returning to satisfy the TokenStore interface.
734
+ get() {
735
+ if (!existsSync3(this.path)) return Promise.resolve(null);
736
+ this.#repairPerms();
737
+ const raw = readFileSync3(this.path, "utf8").trim();
738
+ return Promise.resolve(raw.length > 0 ? raw : null);
739
+ }
740
+ set(token) {
741
+ const dir = dirname2(this.path);
742
+ mkdirSync3(dir, { recursive: true, mode: 448 });
743
+ if (process.platform !== "win32") chmodSync3(dir, 448);
744
+ const tmp = `${this.path}.tmp`;
745
+ writeFileSync3(tmp, token, { mode: 384 });
746
+ renameSync3(tmp, this.path);
747
+ if (process.platform !== "win32") chmodSync3(this.path, 384);
748
+ return Promise.resolve();
749
+ }
750
+ clear() {
751
+ rmSync3(this.path, { force: true });
752
+ return Promise.resolve();
753
+ }
754
+ /** Repair a too-permissive token file (§15.1: strict perms). POSIX only. */
755
+ #repairPerms() {
756
+ if (process.platform === "win32") return;
757
+ try {
758
+ if ((statSync3(this.path).mode & 63) !== 0) chmodSync3(this.path, 384);
759
+ } catch {
760
+ }
761
+ }
762
+ };
763
+ var KeychainTokenStore = class {
764
+ kind = "keychain";
765
+ #backend;
766
+ constructor(backend) {
767
+ this.#backend = backend;
768
+ }
769
+ get() {
770
+ return this.#backend.get(SERVICE, ACCOUNT);
771
+ }
772
+ set(token) {
773
+ return this.#backend.set(SERVICE, ACCOUNT, token);
774
+ }
775
+ clear() {
776
+ return this.#backend.delete(SERVICE, ACCOUNT);
777
+ }
778
+ };
779
+ var unavailableKeychainBackend = {
780
+ available: false,
781
+ get: () => Promise.resolve(null),
782
+ set: () => Promise.reject(new Error("keychain unavailable")),
783
+ delete: () => Promise.resolve()
784
+ };
785
+ function macosKeychainBackend() {
786
+ return {
787
+ available: process.platform === "darwin",
788
+ async get(service, account) {
789
+ try {
790
+ const { stdout } = await execFileAsync("security", [
791
+ "find-generic-password",
792
+ "-s",
793
+ service,
794
+ "-a",
795
+ account,
796
+ "-w"
797
+ ]);
798
+ const value = stdout.replace(/\n$/, "");
799
+ return value.length > 0 ? value : null;
800
+ } catch {
801
+ return null;
802
+ }
803
+ },
804
+ async set(service, account, secret) {
805
+ await execFileAsync("security", [
806
+ "add-generic-password",
807
+ "-U",
808
+ "-s",
809
+ service,
810
+ "-a",
811
+ account,
812
+ "-w",
813
+ secret
814
+ ]);
815
+ },
816
+ async delete(service, account) {
817
+ try {
818
+ await execFileAsync("security", ["delete-generic-password", "-s", service, "-a", account]);
819
+ } catch {
820
+ }
821
+ }
822
+ };
823
+ }
824
+ function defaultKeychainBackend() {
825
+ if (process.platform === "darwin") return macosKeychainBackend();
826
+ return unavailableKeychainBackend;
827
+ }
828
+ function resolveTokenStore(options = {}) {
829
+ const backend = options.backend ?? defaultKeychainBackend();
830
+ if (backend.available) return new KeychainTokenStore(backend);
831
+ return new FileTokenStore(options.filePath !== void 0 ? { path: options.filePath } : {});
832
+ }
833
+ async function setToken(token, options = {}) {
834
+ const store = resolveTokenStore(options);
835
+ await store.set(token);
836
+ return store.kind;
837
+ }
838
+ async function getToken(options = {}) {
839
+ const backend = options.backend ?? defaultKeychainBackend();
840
+ if (backend.available) {
841
+ const fromKeychain = await new KeychainTokenStore(backend).get();
842
+ if (fromKeychain !== null) return fromKeychain;
843
+ }
844
+ const file = new FileTokenStore(options.filePath !== void 0 ? { path: options.filePath } : {});
845
+ return file.get();
846
+ }
847
+ async function clearToken(options = {}) {
848
+ const backend = options.backend ?? defaultKeychainBackend();
849
+ if (backend.available) await new KeychainTokenStore(backend).clear();
850
+ await new FileTokenStore(
851
+ options.filePath !== void 0 ? { path: options.filePath } : {}
852
+ ).clear();
853
+ }
854
+
855
+ // src/sender.ts
856
+ var DEFAULT_SEND_TIMEOUT_MS = 3e3;
857
+ var DEFAULT_TOTAL_BUDGET_MS = 5e3;
858
+ var MIN_DRAIN_ATTEMPT_MS = 250;
859
+ var AGENT_EVENTS_PATH = "/v1/agent-events";
860
+ var EMPTY_DRAIN = { delivered: 0, dropped: 0, kept: 0, pruned: 0 };
861
+ function classify(status, code) {
862
+ if (code === "rate_limited" || code === "internal_error") return "retry";
863
+ if (code !== void 0) return "drop";
864
+ if (status >= 500 || status === 429) return "retry";
865
+ return "drop";
866
+ }
867
+ function parseDecision(body) {
868
+ if (typeof body !== "object" || body === null) return void 0;
869
+ const decision = body["decision"];
870
+ return typeof decision === "string" ? decision : void 0;
871
+ }
872
+ function createSender(config) {
873
+ const baseUrl = config.baseUrl.replace(/\/$/, "");
874
+ const timeoutMs = config.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
875
+ const totalBudgetMs = config.totalBudgetMs ?? DEFAULT_TOTAL_BUDGET_MS;
876
+ const queue = config.queue ?? new LocalEventQueue();
877
+ const fetchImpl = config.fetchImpl ?? fetch;
878
+ const drainMax = config.drainMax ?? DEFAULT_DRAIN_MAX;
879
+ const clock = config.now ?? (() => Date.now());
880
+ async function attempt(event, token, attemptTimeoutMs = timeoutMs) {
881
+ const controller = new AbortController();
882
+ const timer = setTimeout(() => controller.abort(), attemptTimeoutMs);
883
+ try {
884
+ const res = await fetchImpl(`${baseUrl}${AGENT_EVENTS_PATH}`, {
885
+ method: "POST",
886
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
887
+ body: JSON.stringify(event),
888
+ signal: controller.signal
889
+ });
890
+ if (res.status >= 200 && res.status < 300) {
891
+ const decision = parseDecision(await res.json().catch(() => void 0));
892
+ return { result: "delivered", status: res.status, decision };
893
+ }
894
+ let code;
895
+ try {
896
+ const body = await res.json();
897
+ const parsed = errorEnvelopeSchema.safeParse(body);
898
+ if (parsed.success) code = parsed.data.error.code;
899
+ } catch {
900
+ }
901
+ return { result: classify(res.status, code), status: res.status, code };
902
+ } catch {
903
+ return { result: "retry" };
904
+ } finally {
905
+ clearTimeout(timer);
906
+ }
907
+ }
908
+ function drainQueue(token, deadline) {
909
+ return queue.drain(
910
+ (e) => {
911
+ const remaining = deadline - clock();
912
+ return attempt(e, token, Math.max(1, Math.min(timeoutMs, remaining))).then((a) => a.result);
913
+ },
914
+ { max: drainMax, stopWhen: () => deadline - clock() < MIN_DRAIN_ATTEMPT_MS }
915
+ );
916
+ }
917
+ return {
918
+ async send(event) {
919
+ const deadline = clock() + totalBudgetMs;
920
+ const token = await getToken(config.tokenOptions);
921
+ if (token === null) {
922
+ queue.enqueue(event);
923
+ return { outcome: "queued" };
924
+ }
925
+ const a = await attempt(event, token, Math.min(timeoutMs, totalBudgetMs));
926
+ let outcome;
927
+ if (a.result === "delivered") {
928
+ outcome = "delivered";
929
+ } else if (a.result === "retry") {
930
+ queue.enqueue(event);
931
+ outcome = "queued";
932
+ } else {
933
+ outcome = "dropped";
934
+ }
935
+ const drained = await drainQueue(token, deadline);
936
+ const result = { outcome, drained };
937
+ if (a.status !== void 0) result.status = a.status;
938
+ if (a.code !== void 0) result.code = a.code;
939
+ if (a.decision !== void 0) result.decision = a.decision;
940
+ return result;
941
+ },
942
+ async drainNow() {
943
+ const token = await getToken(config.tokenOptions);
944
+ if (token === null) return EMPTY_DRAIN;
945
+ return drainQueue(token, clock() + totalBudgetMs);
946
+ }
947
+ };
948
+ }
949
+
950
+ // src/index.ts
951
+ var AGENT_CORE_VERSION = "0.0.0";
952
+ export {
953
+ AGENT_CORE_VERSION,
954
+ AGENT_SESSION_STATUSES,
955
+ APPROVAL_COLLAPSE_WINDOW_MS,
956
+ BIRDYBEEP_EVENT_TYPES,
957
+ BODY_MAX_CHARS,
958
+ CLAIM_RECLAIM_MS,
959
+ DEFAULT_DEDUP_WINDOW_MS,
960
+ DEFAULT_DRAIN_MAX,
961
+ DEFAULT_SEND_TIMEOUT_MS,
962
+ DEFAULT_TOTAL_BUDGET_MS,
963
+ ERROR_CODES,
964
+ ERROR_STATUS,
965
+ FileTokenStore,
966
+ HARNESS_IDS,
967
+ INTEGRATION_STATUSES,
968
+ KeychainTokenStore,
969
+ LABEL_MAX_CHARS,
970
+ LocalEventQueue,
971
+ MAX_AGENT_EVENT_BYTES,
972
+ METADATA_MAX_DEPTH,
973
+ METADATA_MAX_KEYS,
974
+ METADATA_VALUE_MAX_CHARS,
975
+ NormalizeError,
976
+ QUEUE_RETENTION_MS,
977
+ RecentEventLedger,
978
+ STATUS_REPORT_MAX_ITEMS,
979
+ STATUS_REPORT_MAX_PAYLOAD_BYTES,
980
+ TITLE_MAX_CHARS,
981
+ agentEventsRequestSchema,
982
+ approvalCollapseIdentity,
983
+ birdyBeepAgentEventSchema,
984
+ birdyBeepConfigDir,
985
+ birdyBeepDataDir,
986
+ clearToken,
987
+ collectMachineSignals,
988
+ createSender,
989
+ defaultKeychainBackend,
990
+ errorCodeSchema,
991
+ errorEnvelopeSchema,
992
+ eventIdentity,
993
+ eventMetadataSchema,
994
+ eventTypeSchema,
995
+ fingerprintFromSignals,
996
+ getMachineFingerprintHash,
997
+ getMachineIdentity,
998
+ getMachineLabel,
999
+ getOS,
1000
+ getToken,
1001
+ harnessSchema,
1002
+ hashPath,
1003
+ integrationStatusItemSchema,
1004
+ integrationStatusReportSchema,
1005
+ integrationStatusResponseSchema,
1006
+ integrationStatusSchema,
1007
+ isWithinMaxAgentEventSize,
1008
+ isoDateTimeSchema,
1009
+ machineSchema,
1010
+ macosKeychainBackend,
1011
+ normalizeEvent,
1012
+ pairStartRequestSchema,
1013
+ pairStartResponseSchema,
1014
+ pairTokenRequestSchema,
1015
+ pairTokenResponseSchema,
1016
+ redactSecrets,
1017
+ resolveTokenStore,
1018
+ runAgentHook,
1019
+ scrubAbsolutePaths,
1020
+ sessionStatusSchema,
1021
+ setToken,
1022
+ successEnvelopeSchema,
1023
+ truncate,
1024
+ unavailableKeychainBackend,
1025
+ workspaceSchema
1026
+ };
1027
+ //# sourceMappingURL=index.js.map