@agent-idle/cli 0.1.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.
Files changed (3) hide show
  1. package/README.md +37 -0
  2. package/index.js +1382 -0
  3. package/package.json +29 -0
package/index.js ADDED
@@ -0,0 +1,1382 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/daemon.ts
4
+ import { randomUUID } from "crypto";
5
+ import { createServer } from "http";
6
+
7
+ // ../../packages/engine/dist/config.js
8
+ var HOUR_MS = 60 * 60 * 1e3;
9
+ var DAY_MS = 24 * HOUR_MS;
10
+ var DECAY = {
11
+ /**
12
+ * Fraction of energy (0..1) lost per hour with no usage. 6/h ⇒ full→empty in ~10 min,
13
+ * so an idle session-pet visibly weakens (lively→weary→drained→fainted) and dies on a
14
+ * timescale you can actually watch. DEV-fast — raise it for a longer, gentler game.
15
+ */
16
+ energyLostPerHour: 6,
17
+ /** Energy ladder thresholds (inclusive lower bounds), high → low. */
18
+ thresholds: {
19
+ /** >= lively → "lively" */
20
+ lively: 0.66,
21
+ /** >= weary → "weary" */
22
+ weary: 0.33
23
+ /** > 0 → "drained" */
24
+ // (drained is anything above 0 but below `weary`)
25
+ },
26
+ /**
27
+ * Once energy reaches 0 the pet is "fainted" (recoverable). After this many additional
28
+ * hours at zero it crosses the TERMINAL rung (death in hardcore, deep faint in normal).
29
+ * 1/60 h = 1 min: a session that ENDS (clean exit / detected kill empties energy at once)
30
+ * reads as DEAD within a minute. A naturally idle pet still drains first (~10 min) before
31
+ * this window starts. Tune up for a longer "fainted but not yet dead" grace.
32
+ */
33
+ terminalGraceHours: 1 / 60,
34
+ /**
35
+ * After the terminal rung, how many MORE hours before the pet is REMOVED from the
36
+ * menagerie entirely (despawned). A dead pet lingers only this long before it is "gone":
37
+ * filtered from reads and eligible for the sweep to hard-delete. 1/60 h = 1 min, so an
38
+ * ended/killed session is GONE ~2 min after it empties (1 min dead + 1 min more). Kept
39
+ * short so dead pets don't pile up — still revivable the whole time by using the session.
40
+ */
41
+ removalGraceHours: 1 / 60
42
+ };
43
+ var SCORING = {
44
+ includePromptQualityInScore: false,
45
+ weights: {
46
+ tokensFed: 1 / 1e3,
47
+ // 1 point per 1k tokens fed
48
+ avgPromptQuality: 0,
49
+ // intentionally 0 — see note above
50
+ survivalStreakDays: 25,
51
+ zoneAchievements: 50
52
+ }
53
+ };
54
+
55
+ // ../../packages/engine/dist/prompt.js
56
+ var VAGUE_PHRASES = [
57
+ "make it pop",
58
+ "make it nice",
59
+ "make it better",
60
+ "make it good",
61
+ "make it cool",
62
+ "do the thing",
63
+ "fix it",
64
+ "clean it up",
65
+ "just do it",
66
+ "somehow",
67
+ "some stuff"
68
+ ];
69
+ var SPECIFIC_WORDS = /* @__PURE__ */ new Set([
70
+ "should",
71
+ "must",
72
+ "expect",
73
+ "given",
74
+ "when",
75
+ "then",
76
+ "because",
77
+ "constraint",
78
+ "return",
79
+ "function",
80
+ "test",
81
+ "error",
82
+ "input",
83
+ "output",
84
+ "api",
85
+ "type",
86
+ "interface",
87
+ "edge"
88
+ ]);
89
+ var round2 = (n) => Math.round(n * 100) / 100;
90
+ function clamp012(n) {
91
+ if (n < 0)
92
+ return 0;
93
+ if (n > 1)
94
+ return 1;
95
+ return n;
96
+ }
97
+ function appraisePrompt(text) {
98
+ const raw = text ?? "";
99
+ const lower = raw.toLowerCase().trim();
100
+ const words = lower.length === 0 ? [] : lower.split(/\s+/);
101
+ const wordCount = words.length;
102
+ let vagueHits = 0;
103
+ for (const phrase of VAGUE_PHRASES) {
104
+ if (lower.includes(phrase))
105
+ vagueHits++;
106
+ }
107
+ let specificHits = 0;
108
+ for (const w of words) {
109
+ const bare = w.replace(/[^a-z]/g, "");
110
+ if (SPECIFIC_WORDS.has(bare))
111
+ specificHits++;
112
+ }
113
+ const hasCodeFence = raw.includes("```");
114
+ const hasFilePath = /[\w-]+\.[a-z]{1,5}\b/.test(lower) || /\/[\w-]+/.test(raw);
115
+ const hasNumbers = /\d/.test(raw);
116
+ let quality = 0.15;
117
+ quality += Math.min(wordCount / 40, 1) * 0.4;
118
+ quality += Math.min(specificHits / 4, 1) * 0.2;
119
+ if (hasCodeFence)
120
+ quality += 0.15;
121
+ if (hasFilePath)
122
+ quality += 0.1;
123
+ if (hasNumbers)
124
+ quality += 0.05;
125
+ quality -= vagueHits * 0.4;
126
+ if (wordCount > 0 && wordCount <= 4 && specificHits === 0)
127
+ quality -= 0.3;
128
+ quality = clamp012(quality);
129
+ const tier = quality >= 0.75 ? "gourmet" : quality >= 0.5 ? "balanced" : quality >= 0.25 ? "snack" : "junk";
130
+ const status2 = vagueHits > 0 || wordCount > 0 && wordCount <= 4 && specificHits === 0 ? "confused" : quality >= 0.7 ? "satisfied" : quality >= 0.45 ? "content" : "meh";
131
+ const fill = round2(quality * 0.7);
132
+ return { tier, fill, status: status2, quality: round2(quality) };
133
+ }
134
+
135
+ // src/daemon.ts
136
+ import { ConvexHttpClient } from "convex/browser";
137
+ import { makeFunctionReference } from "convex/server";
138
+
139
+ // src/config.ts
140
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
141
+ import { homedir } from "os";
142
+ import { dirname, join } from "path";
143
+ var DAEMON_PORT = 47615;
144
+ var DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
145
+ var STATE_DIR = join(homedir(), ".agent-idle");
146
+ var OUTBOX_PATH = join(STATE_DIR, "outbox.jsonl");
147
+ var AUTH_TOKEN_PATH = join(STATE_DIR, "auth.json");
148
+ var CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
149
+ var CODEX_HOOKS_PATH = join(homedir(), ".codex", "hooks.json");
150
+ var AGENTS = ["claude", "codex"];
151
+ function parseAgent(arg2) {
152
+ return arg2 === "codex" ? "codex" : "claude";
153
+ }
154
+ function parseAgents(args) {
155
+ const tokens = args.flatMap((a) => a.split(",")).map((t) => t.trim().toLowerCase());
156
+ const wantAll = tokens.includes("all");
157
+ const selected = AGENTS.filter((agent) => wantAll || tokens.includes(agent));
158
+ return selected.length > 0 ? [...selected] : ["claude"];
159
+ }
160
+ var SOURCES = {
161
+ claude: "cli-daemon",
162
+ codex: "codex-daemon"
163
+ };
164
+ var CONVEX_URL_PATH = join(STATE_DIR, "convex-url");
165
+ function persistedConvexUrl() {
166
+ try {
167
+ return existsSync(CONVEX_URL_PATH) ? readFileSync(CONVEX_URL_PATH, "utf8").trim() || null : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ var BUILD_CONVEX_URL = "https://handsome-camel-783.convex.cloud";
173
+ var BUILD_AUTH_URL = "https://agent-idle-app.vercel.app";
174
+ var LOCAL_CONVEX_URL = "http://127.0.0.1:3210";
175
+ function resolveConvexUrl() {
176
+ return process.env.CONVEX_URL ?? persistedConvexUrl() ?? BUILD_CONVEX_URL ?? LOCAL_CONVEX_URL;
177
+ }
178
+ var DEFAULT_CONVEX_URL = resolveConvexUrl();
179
+ var AUTH_URL = process.env.AGENT_IDLE_AUTH_URL ?? BUILD_AUTH_URL ?? "http://localhost:1420";
180
+ function ensureDir(path, mode) {
181
+ const dir = dirname(path);
182
+ mkdirSync(dir, { recursive: true, ...mode !== void 0 ? { mode } : {} });
183
+ if (mode !== void 0) {
184
+ try {
185
+ chmodSync(dir, mode);
186
+ } catch {
187
+ }
188
+ }
189
+ }
190
+ function readToken() {
191
+ if (!existsSync(AUTH_TOKEN_PATH)) return null;
192
+ try {
193
+ return JSON.parse(readFileSync(AUTH_TOKEN_PATH, "utf8")).token ?? null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ function writeToken(token) {
199
+ ensureDir(AUTH_TOKEN_PATH, 448);
200
+ writeFileSync(AUTH_TOKEN_PATH, JSON.stringify({ token }) + "\n", { mode: 384 });
201
+ try {
202
+ chmodSync(AUTH_TOKEN_PATH, 384);
203
+ } catch {
204
+ }
205
+ }
206
+ function clearToken() {
207
+ try {
208
+ if (existsSync(AUTH_TOKEN_PATH)) rmSync(AUTH_TOKEN_PATH);
209
+ } catch {
210
+ }
211
+ }
212
+
213
+ // src/outbox.ts
214
+ import { appendFileSync, chmodSync as chmodSync2, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
215
+ function tighten() {
216
+ try {
217
+ chmodSync2(OUTBOX_PATH, 384);
218
+ } catch {
219
+ }
220
+ }
221
+ function enqueue(item) {
222
+ ensureDir(OUTBOX_PATH, 448);
223
+ appendFileSync(OUTBOX_PATH, JSON.stringify(item) + "\n", { mode: 384 });
224
+ tighten();
225
+ }
226
+ function readOutbox() {
227
+ if (!existsSync2(OUTBOX_PATH)) return [];
228
+ return readFileSync2(OUTBOX_PATH, "utf8").split("\n").filter(Boolean).flatMap((line) => {
229
+ try {
230
+ return [JSON.parse(line)];
231
+ } catch {
232
+ return [];
233
+ }
234
+ });
235
+ }
236
+ function writeOutbox(items) {
237
+ ensureDir(OUTBOX_PATH, 448);
238
+ writeFileSync2(OUTBOX_PATH, items.map((i) => JSON.stringify(i)).join("\n") + (items.length ? "\n" : ""), {
239
+ mode: 384
240
+ });
241
+ tighten();
242
+ }
243
+
244
+ // src/proc.ts
245
+ import { execFileSync } from "child_process";
246
+ function processAlive(pid) {
247
+ try {
248
+ process.kill(pid, 0);
249
+ return true;
250
+ } catch (err) {
251
+ return err?.code === "EPERM";
252
+ }
253
+ }
254
+ function processStartTime(pid) {
255
+ if (process.platform === "win32") return "";
256
+ try {
257
+ return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
258
+ encoding: "utf8",
259
+ timeout: 500
260
+ }).trim();
261
+ } catch {
262
+ return "";
263
+ }
264
+ }
265
+
266
+ // src/transcript.ts
267
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
268
+ function readTokenUsage(path, agent) {
269
+ if (!path || !existsSync3(path)) return { tokens: 0 };
270
+ try {
271
+ const text = readFileSync3(path, "utf8");
272
+ return { tokens: agent === "codex" ? codexTokens(text) : claudeTokens(text) };
273
+ } catch {
274
+ return { tokens: 0 };
275
+ }
276
+ }
277
+ function usageTotal(u) {
278
+ return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
279
+ }
280
+ function isPrompt(e) {
281
+ if (e.type !== "user") return false;
282
+ const c = e.message?.content;
283
+ if (typeof c === "string") return true;
284
+ if (Array.isArray(c)) return !c.some((x) => typeof x === "object" && x !== null && x.type === "tool_result");
285
+ return false;
286
+ }
287
+ function claudeTokens(text) {
288
+ const entries = [];
289
+ for (const line of text.split("\n")) {
290
+ if (!line) continue;
291
+ try {
292
+ entries.push(JSON.parse(line));
293
+ } catch {
294
+ }
295
+ }
296
+ let start = 0;
297
+ for (let i = entries.length - 1; i >= 0; i--) {
298
+ const e = entries[i];
299
+ if (e && isPrompt(e)) {
300
+ start = i;
301
+ break;
302
+ }
303
+ }
304
+ let tokens = 0;
305
+ for (let i = start; i < entries.length; i++) {
306
+ const u = entries[i]?.message?.usage;
307
+ if (u) tokens += usageTotal(u);
308
+ }
309
+ return tokens;
310
+ }
311
+ function codexTokens(text) {
312
+ const sum = (u) => u ? u.total_tokens ?? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) : 0;
313
+ let tokens = 0;
314
+ for (const line of text.split("\n")) {
315
+ if (!line) continue;
316
+ try {
317
+ const obj = JSON.parse(line);
318
+ const p = obj.payload;
319
+ if (p?.type !== "token_count") continue;
320
+ const last = p.last_token_usage ?? p.info?.last_token_usage;
321
+ const total = p.total_token_usage ?? p.info?.total_token_usage;
322
+ tokens = last ? sum(last) : sum(total);
323
+ } catch {
324
+ }
325
+ }
326
+ return tokens;
327
+ }
328
+
329
+ // src/daemon.ts
330
+ var FLUSH_INTERVAL_MS = 5e3;
331
+ var RENEW_MS = 12e3;
332
+ var HELPER_CREDIT_FLUSH_MS = 2e3;
333
+ var HELPER_RETAIN_MS = 6e3;
334
+ var LIVENESS_POLL_MS = 5e3;
335
+ var DEBUG = Boolean(process.env.AGENT_IDLE_DEBUG) || process.argv.includes("--debug");
336
+ function debug(...args) {
337
+ if (DEBUG) console.log("[agent-idle]", ...args);
338
+ }
339
+ var tag = (s) => s.slice(0, 8);
340
+ function repoFromCwd(cwd) {
341
+ if (!cwd) return "";
342
+ const parts = cwd.replace(/[/\\]+$/, "").split(/[/\\]/);
343
+ return parts[parts.length - 1] ?? "";
344
+ }
345
+ var TOPIC_STOP = /* @__PURE__ */ new Set([
346
+ "the",
347
+ "a",
348
+ "an",
349
+ "to",
350
+ "of",
351
+ "and",
352
+ "or",
353
+ "for",
354
+ "in",
355
+ "on",
356
+ "with",
357
+ "my",
358
+ "our",
359
+ "your",
360
+ "please",
361
+ "pls",
362
+ "can",
363
+ "could",
364
+ "would",
365
+ "should",
366
+ "i",
367
+ "we",
368
+ "it",
369
+ "this",
370
+ "that",
371
+ "these",
372
+ "is",
373
+ "are",
374
+ "be",
375
+ "do",
376
+ "make",
377
+ "add",
378
+ "fix",
379
+ "update",
380
+ "create",
381
+ "change",
382
+ "how",
383
+ "what",
384
+ "when",
385
+ "why",
386
+ "help",
387
+ "need",
388
+ "want",
389
+ "get",
390
+ "set",
391
+ "use",
392
+ "using",
393
+ "into",
394
+ "from",
395
+ "so",
396
+ "also",
397
+ "just",
398
+ "new",
399
+ "some",
400
+ "any",
401
+ "through",
402
+ "look",
403
+ "about",
404
+ "like",
405
+ "have",
406
+ "has"
407
+ ]);
408
+ function topicWord(prompt) {
409
+ const words = (prompt ?? "").toLowerCase().match(/[a-z][a-z0-9+_-]{2,}/g) ?? [];
410
+ const meaningful = words.filter((w) => !TOPIC_STOP.has(w));
411
+ const pool = meaningful.length > 0 ? meaningful : words;
412
+ return pool.reduce((best, w) => w.length > best.length ? w : best, "");
413
+ }
414
+ var ingestEvent = makeFunctionReference("events:ingestEvent");
415
+ function classifyToolAction(toolName) {
416
+ switch (toolName) {
417
+ case "Bash":
418
+ case "BashOutput":
419
+ case "KillShell":
420
+ case "KillBash":
421
+ return "shell";
422
+ case "Edit":
423
+ case "MultiEdit":
424
+ case "Write":
425
+ case "NotebookEdit":
426
+ return "edit";
427
+ case "Read":
428
+ case "Grep":
429
+ case "Glob":
430
+ case "LS":
431
+ return "read";
432
+ case "WebFetch":
433
+ case "WebSearch":
434
+ return "web";
435
+ default:
436
+ return "none";
437
+ }
438
+ }
439
+ function classifyNotification(message) {
440
+ const m = (message ?? "").toLowerCase();
441
+ if (m.includes("permission") || m.includes("approve") || m.includes("approval")) return "alert";
442
+ return "question";
443
+ }
444
+ var MAX_BODY_BYTES = 1e6;
445
+ function readBody(req) {
446
+ return new Promise((resolve) => {
447
+ const chunks = [];
448
+ let size = 0;
449
+ req.on("data", (c) => {
450
+ size += c.length;
451
+ if (size > MAX_BODY_BYTES) {
452
+ req.destroy();
453
+ resolve("");
454
+ return;
455
+ }
456
+ chunks.push(c);
457
+ });
458
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
459
+ req.on("error", () => resolve(""));
460
+ });
461
+ }
462
+ var ALLOWED_ORIGINS = (() => {
463
+ const out = /* @__PURE__ */ new Set([
464
+ "http://localhost:1420",
465
+ "http://127.0.0.1:1420",
466
+ // Vite dev / Tauri dev webview
467
+ "tauri://localhost",
468
+ "http://tauri.localhost",
469
+ "https://tauri.localhost"
470
+ // Tauri production webview schemes (platform-dependent)
471
+ ]);
472
+ try {
473
+ out.add(new URL(AUTH_URL).origin);
474
+ } catch {
475
+ }
476
+ for (const o of (process.env.AGENT_IDLE_APP_ORIGINS ?? "").split(",")) {
477
+ const trimmed = o.trim();
478
+ if (trimmed) out.add(trimmed);
479
+ }
480
+ return out;
481
+ })();
482
+ function hostOk(req) {
483
+ const host = req.headers.host;
484
+ return host === `127.0.0.1:${DAEMON_PORT}` || host === `localhost:${DAEMON_PORT}`;
485
+ }
486
+ function originOk(req) {
487
+ const origin = req.headers.origin;
488
+ return !origin || ALLOWED_ORIGINS.has(origin);
489
+ }
490
+ function corsHeaders(req) {
491
+ const origin = req.headers.origin;
492
+ if (!origin || !ALLOWED_ORIGINS.has(origin)) return {};
493
+ return {
494
+ "Access-Control-Allow-Origin": origin,
495
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
496
+ "Access-Control-Allow-Headers": "Content-Type",
497
+ Vary: "Origin"
498
+ };
499
+ }
500
+ function startDaemon() {
501
+ const lastAppraisal = /* @__PURE__ */ new Map();
502
+ const registered = /* @__PURE__ */ new Set();
503
+ const workingSentAt = /* @__PURE__ */ new Map();
504
+ const waiting = /* @__PURE__ */ new Set();
505
+ const lastAction = /* @__PURE__ */ new Map();
506
+ const sessionMeta = /* @__PURE__ */ new Map();
507
+ const agentProc = /* @__PURE__ */ new Map();
508
+ const liveHelpers = /* @__PURE__ */ new Map();
509
+ const pendingHelperCredits = /* @__PURE__ */ new Map();
510
+ function helperBucket(session) {
511
+ let bucket = liveHelpers.get(session);
512
+ if (!bucket) {
513
+ bucket = /* @__PURE__ */ new Map();
514
+ liveHelpers.set(session, bucket);
515
+ }
516
+ return bucket;
517
+ }
518
+ function pruneHelpers() {
519
+ const now = Date.now();
520
+ for (const [session, bucket] of liveHelpers) {
521
+ for (const [agentId, h] of bucket) {
522
+ if (h.finishedAt != null && now - h.finishedAt > HELPER_RETAIN_MS) bucket.delete(agentId);
523
+ }
524
+ if (bucket.size === 0) liveHelpers.delete(session);
525
+ }
526
+ }
527
+ function clearHelpers(session) {
528
+ liveHelpers.delete(session);
529
+ }
530
+ function queueHelperCredit(session, agent, tokens, action) {
531
+ if (tokens <= 0) return;
532
+ const prev = pendingHelperCredits.get(session);
533
+ pendingHelperCredits.set(session, {
534
+ agent,
535
+ tokens: (prev?.tokens ?? 0) + tokens,
536
+ action
537
+ });
538
+ }
539
+ function flushHelperCredits(session) {
540
+ const entries = session != null ? pendingHelperCredits.has(session) ? [[session, pendingHelperCredits.get(session)]] : [] : [...pendingHelperCredits.entries()];
541
+ for (const [parentSession, credit] of entries) {
542
+ pendingHelperCredits.delete(parentSession);
543
+ emit(parentSession, credit.agent, {
544
+ working: true,
545
+ action: lastAction.get(parentSession) ?? credit.action,
546
+ tokens: credit.tokens
547
+ });
548
+ }
549
+ }
550
+ function noteMeta(session, payload) {
551
+ const prev = sessionMeta.get(session) ?? { repo: "", topic: "", terminal: "" };
552
+ sessionMeta.set(session, {
553
+ repo: prev.repo || repoFromCwd(payload.cwd),
554
+ topic: prev.topic || topicWord(payload.prompt ?? ""),
555
+ terminal: prev.terminal || (payload.terminal ?? "")
556
+ });
557
+ }
558
+ let flushing = false;
559
+ function emit(session, agent, payload) {
560
+ enqueue({
561
+ type: "activity",
562
+ sessionId: session,
563
+ source: SOURCES[agent],
564
+ payload,
565
+ clientEventId: randomUUID(),
566
+ clientAt: Date.now()
567
+ });
568
+ void flush();
569
+ }
570
+ function register(session, agent) {
571
+ if (registered.has(session)) return;
572
+ registered.add(session);
573
+ enqueue({
574
+ type: "register",
575
+ sessionId: session,
576
+ source: SOURCES[agent],
577
+ payload: {},
578
+ clientEventId: randomUUID(),
579
+ clientAt: Date.now()
580
+ });
581
+ }
582
+ function working(session, agent, force, action = "none") {
583
+ const changed = action !== (lastAction.get(session) ?? "none");
584
+ if (!force && !changed && Date.now() - (workingSentAt.get(session) ?? 0) < RENEW_MS) return;
585
+ workingSentAt.set(session, Date.now());
586
+ lastAction.set(session, action);
587
+ emit(session, agent, { working: true, action });
588
+ }
589
+ function handleHook(payload, agent) {
590
+ const session = payload.session_id ?? "default";
591
+ if (typeof payload.agentPid === "number" && payload.agentStart) {
592
+ agentProc.set(session, { pid: payload.agentPid, start: payload.agentStart, agent });
593
+ }
594
+ switch (payload.hook_event_name) {
595
+ // A session opened / resumed / cleared → spawn-or-wake its pet so it's on screen before
596
+ // the first prompt. Server derives species/name and dedups by sessionId.
597
+ case "SessionStart": {
598
+ const firstStart = !sessionMeta.has(session);
599
+ noteMeta(session, payload);
600
+ register(session, agent);
601
+ if (firstStart) {
602
+ console.log(
603
+ `[agent-idle] session ${tag(session)} started in "${payload.terminal || "unknown terminal"}" (${repoFromCwd(payload.cwd) || "no repo"})`
604
+ );
605
+ }
606
+ debug(`hook SessionStart agent=${agent} session=${tag(session)} source=${payload.source ?? "?"} \u2192 spawn/wake`);
607
+ return;
608
+ }
609
+ // Context compaction is the agent "consolidating memory" mid-task — keep the pet busy at
610
+ // its current job so a long compaction doesn't lapse it to idle.
611
+ case "PreCompact": {
612
+ working(session, agent, false, lastAction.get(session) ?? "none");
613
+ debug(`hook PreCompact agent=${agent} session=${tag(session)} trigger=${payload.trigger ?? "?"} \u2192 keep working`);
614
+ return;
615
+ }
616
+ case "UserPromptSubmit": {
617
+ register(session, agent);
618
+ const firstSight = !sessionMeta.has(session);
619
+ noteMeta(session, payload);
620
+ if (firstSight) {
621
+ console.log(
622
+ `[agent-idle] session ${tag(session)} active in "${payload.terminal || "unknown terminal"}" (${repoFromCwd(payload.cwd) || "no repo"})`
623
+ );
624
+ }
625
+ lastAppraisal.set(session, appraisePrompt(payload.prompt ?? ""));
626
+ waiting.delete(session);
627
+ working(session, agent, true);
628
+ debug(`hook UserPromptSubmit agent=${agent} session=${tag(session)} \u2192 working`);
629
+ return;
630
+ }
631
+ // Tool activity DURING a turn renews the working window. When the agent stops (turn
632
+ // end OR a Ctrl-C interrupt), these stop firing and the pet lapses to idle.
633
+ case "PreToolUse":
634
+ case "PostToolUse": {
635
+ if (payload.agent_id) {
636
+ const h = liveHelpers.get(session)?.get(payload.agent_id);
637
+ if (h && h.finishedAt == null) h.action = classifyToolAction(payload.tool_name);
638
+ working(session, agent, false, lastAction.get(session) ?? "none");
639
+ return;
640
+ }
641
+ const wasWaiting = waiting.delete(session);
642
+ const action = classifyToolAction(payload.tool_name);
643
+ working(session, agent, wasWaiting, action);
644
+ return;
645
+ }
646
+ // A sub-agent (Task) finished. Credit its token output to the PARENT via one ordinary
647
+ // activity event (same additive path as the main Stop; no appraisal ⇒ the prompt-quality
648
+ // average is untouched), keeping the parent mining at its CURRENT job (action MUST be carried
649
+ // — apply() resets an omitted action to "none", which would yank the parent out of its room).
650
+ // Then mark the helper finished, retained briefly so the app can play its deliver→poof outro.
651
+ case "SubagentStop": {
652
+ const parentAction = lastAction.get(session) ?? "none";
653
+ const { tokens } = readTokenUsage(payload.agent_transcript_path, agent);
654
+ if (tokens > 0) queueHelperCredit(session, agent, tokens, parentAction);
655
+ working(session, agent, false, parentAction);
656
+ if (payload.agent_id) {
657
+ const h = liveHelpers.get(session)?.get(payload.agent_id);
658
+ if (h) {
659
+ h.finishedAt = Date.now();
660
+ h.tokens = tokens;
661
+ }
662
+ debug(`hook SubagentStop agent=${agent} session=${tag(session)} helper=${tag(payload.agent_id)} tokens=${tokens}`);
663
+ }
664
+ return;
665
+ }
666
+ // The session is gone (quit / logout / clear). The authoritative "agent stopped" signal —
667
+ // force the pet idle now instead of waiting out the freshness window, and drop any bubble.
668
+ case "SessionEnd": {
669
+ flushHelperCredits(session);
670
+ workingSentAt.delete(session);
671
+ waiting.delete(session);
672
+ lastAction.delete(session);
673
+ registered.delete(session);
674
+ agentProc.delete(session);
675
+ clearHelpers(session);
676
+ emit(session, agent, { working: false, ended: true });
677
+ debug(`hook SessionEnd agent=${agent} session=${tag(session)} \u2192 ended`);
678
+ return;
679
+ }
680
+ // Precise "the agent needs you" signals (richer than Notification): a permission dialog
681
+ // or an MCP input request blocks the turn → show the alert bubble, stop working.
682
+ case "PermissionRequest":
683
+ case "Elicitation": {
684
+ waiting.add(session);
685
+ emit(session, agent, { working: false, waiting: "alert" });
686
+ debug(`hook ${payload.hook_event_name} agent=${agent} session=${tag(session)} \u2192 waiting=alert`);
687
+ return;
688
+ }
689
+ // The ask was resolved (denied / MCP answered) → drop the bubble now; a later tool or
690
+ // prompt resumes work.
691
+ case "PermissionDenied":
692
+ case "ElicitationResult": {
693
+ waiting.delete(session);
694
+ emit(session, agent, { working: false });
695
+ return;
696
+ }
697
+ // Compaction finished → the agent is active; keep the pet busy at its current job (no
698
+ // tool_name on this event ⇒ reuse lastAction).
699
+ case "PostCompact": {
700
+ working(session, agent, false, lastAction.get(session) ?? "none");
701
+ return;
702
+ }
703
+ // A sub-agent spawned → the PARENT keeps mining at its current job, AND we start tracking the
704
+ // helper so the app can render it as a mini pet beside the parent (local visual only).
705
+ case "SubagentStart": {
706
+ working(session, agent, false, lastAction.get(session) ?? "none");
707
+ if (payload.agent_id) {
708
+ helperBucket(session).set(payload.agent_id, {
709
+ agentId: payload.agent_id,
710
+ agentType: payload.agent_type ?? "subagent",
711
+ action: "none",
712
+ startedAt: Date.now()
713
+ });
714
+ debug(`hook SubagentStart agent=${agent} session=${tag(session)} helper=${tag(payload.agent_id)} (${payload.agent_type ?? "subagent"})`);
715
+ }
716
+ return;
717
+ }
718
+ // A tool FAILED but the agent keeps going → stay working at the (failed) tool's job.
719
+ case "PostToolUseFailure": {
720
+ const wasWaiting = waiting.delete(session);
721
+ working(session, agent, wasWaiting, classifyToolAction(payload.tool_name));
722
+ return;
723
+ }
724
+ // The turn ended via an API error (no Stop fires) → the pet is "knocked out" (collapsed
725
+ // at camp) so a failed run is visible, until it recovers or the next turn starts.
726
+ case "StopFailure": {
727
+ flushHelperCredits(session);
728
+ workingSentAt.delete(session);
729
+ waiting.delete(session);
730
+ lastAction.delete(session);
731
+ clearHelpers(session);
732
+ emit(session, agent, { working: false, failed: true });
733
+ debug(`hook StopFailure agent=${agent} session=${tag(session)} \u2192 failed (knocked out)`);
734
+ return;
735
+ }
736
+ // The agent wants the human: a permission prompt or an idle wait-for-input. Mark the
737
+ // session waiting (not working) so the ?/! bubble shows; classify the kind locally.
738
+ case "Notification": {
739
+ const kind = classifyNotification(payload.message);
740
+ waiting.add(session);
741
+ emit(session, agent, { working: false, waiting: kind });
742
+ debug(`hook Notification agent=${agent} session=${tag(session)} \u2192 waiting=${kind}`);
743
+ return;
744
+ }
745
+ case "Stop": {
746
+ const { tokens } = readTokenUsage(payload.transcript_path, agent);
747
+ const appraisal = lastAppraisal.get(session) ?? appraisePrompt("");
748
+ flushHelperCredits(session);
749
+ lastAppraisal.delete(session);
750
+ workingSentAt.delete(session);
751
+ waiting.delete(session);
752
+ lastAction.delete(session);
753
+ clearHelpers(session);
754
+ emit(session, agent, { working: false, appraisal, tokens });
755
+ debug(`hook Stop agent=${agent} session=${tag(session)} \u2192 idle (tokens=${tokens}, fill=${appraisal.fill})`);
756
+ return;
757
+ }
758
+ default:
759
+ return;
760
+ }
761
+ }
762
+ function checkLiveness() {
763
+ pruneHelpers();
764
+ for (const [session, proc] of agentProc) {
765
+ if (processAlive(proc.pid)) {
766
+ const start = processStartTime(proc.pid);
767
+ if (!start || start === proc.start) continue;
768
+ }
769
+ workingSentAt.delete(session);
770
+ waiting.delete(session);
771
+ lastAction.delete(session);
772
+ lastAppraisal.delete(session);
773
+ registered.delete(session);
774
+ agentProc.delete(session);
775
+ flushHelperCredits(session);
776
+ clearHelpers(session);
777
+ emit(session, proc.agent, { working: false, ended: true });
778
+ debug(`liveness: agent pid=${proc.pid} session=${tag(session)} gone \u2192 ended (killed)`);
779
+ }
780
+ }
781
+ async function flush() {
782
+ if (flushing) return;
783
+ const token = readToken();
784
+ const items = readOutbox();
785
+ if (items.length === 0) return;
786
+ if (!token) {
787
+ debug(`flush: ${items.length} event(s) queued but no auth token \u2014 sign in via the app`);
788
+ return;
789
+ }
790
+ flushing = true;
791
+ try {
792
+ const convexUrl = resolveConvexUrl();
793
+ debug(`flush: posting ${items.length} event(s) to ${convexUrl}`);
794
+ const client = new ConvexHttpClient(convexUrl);
795
+ client.setAuth(token);
796
+ const remaining = [];
797
+ for (const item of items) {
798
+ try {
799
+ const res = await client.mutation(ingestEvent, item);
800
+ debug(` ${item.type} session=${tag(item.sessionId)} \u2192`, res);
801
+ } catch (err) {
802
+ remaining.push(item);
803
+ debug(` ${item.type} session=${tag(item.sessionId)} FAILED:`, err?.message ?? err);
804
+ }
805
+ }
806
+ writeOutbox(remaining);
807
+ } finally {
808
+ flushing = false;
809
+ }
810
+ }
811
+ const server = createServer((req, res) => {
812
+ if (!hostOk(req)) {
813
+ res.writeHead(403).end();
814
+ return;
815
+ }
816
+ const cors = corsHeaders(req);
817
+ if (req.method === "OPTIONS") {
818
+ res.writeHead(204, cors).end();
819
+ return;
820
+ }
821
+ if (req.method === "GET" && req.url === "/token") {
822
+ if (req.headers.origin) {
823
+ res.writeHead(403).end();
824
+ return;
825
+ }
826
+ res.writeHead(200, { "Content-Type": "application/json" });
827
+ res.end(JSON.stringify({ token: readToken() }));
828
+ return;
829
+ }
830
+ if (req.method === "GET" && req.url === "/sessions") {
831
+ res.writeHead(200, { ...cors, "Content-Type": "application/json" });
832
+ res.end(JSON.stringify(Object.fromEntries(sessionMeta)));
833
+ return;
834
+ }
835
+ if (req.method === "GET" && req.url === "/subagents") {
836
+ pruneHelpers();
837
+ const out = {};
838
+ for (const [session, bucket] of liveHelpers) {
839
+ if (bucket.size > 0) out[session] = [...bucket.values()];
840
+ }
841
+ res.writeHead(200, { ...cors, "Content-Type": "application/json" });
842
+ res.end(JSON.stringify(out));
843
+ return;
844
+ }
845
+ if (!originOk(req)) {
846
+ res.writeHead(403, cors).end();
847
+ return;
848
+ }
849
+ if (req.method === "POST" && req.url === "/shutdown") {
850
+ res.writeHead(204, cors).end();
851
+ debug("shutdown requested via /shutdown \u2014 exiting");
852
+ setTimeout(() => process.exit(0), 50);
853
+ return;
854
+ }
855
+ if (req.method !== "POST") {
856
+ res.writeHead(404, cors).end();
857
+ return;
858
+ }
859
+ void readBody(req).then((body) => {
860
+ try {
861
+ if (req.url === "/auth-token") {
862
+ const { token: t } = JSON.parse(body || "{}");
863
+ if (t) {
864
+ writeToken(t);
865
+ debug("auth token received from app \u2014 will flush as the authenticated user");
866
+ }
867
+ } else if (req.url?.startsWith("/hook")) {
868
+ const agent = parseAgent(new URL(req.url, "http://127.0.0.1").searchParams.get("agent") ?? void 0);
869
+ handleHook(JSON.parse(body || "{}"), agent);
870
+ }
871
+ } catch {
872
+ }
873
+ res.writeHead(204, cors).end();
874
+ });
875
+ });
876
+ server.on("error", () => process.exit(0));
877
+ server.listen(DAEMON_PORT, "127.0.0.1", () => {
878
+ console.log(`agent-idle daemon listening on http://127.0.0.1:${DAEMON_PORT}`);
879
+ console.log(
880
+ readToken() ? " shared auth token present." : " no auth token yet \u2014 run `agent-idle setup` to sign in."
881
+ );
882
+ });
883
+ setInterval(() => void flush(), FLUSH_INTERVAL_MS);
884
+ setInterval(() => flushHelperCredits(), HELPER_CREDIT_FLUSH_MS);
885
+ setInterval(checkLiveness, LIVENESS_POLL_MS);
886
+ }
887
+
888
+ // src/hook.ts
889
+ import { execFileSync as execFileSync2 } from "child_process";
890
+
891
+ // src/daemonControl.ts
892
+ import { spawn } from "child_process";
893
+ import { fileURLToPath } from "url";
894
+ function spawnDaemon() {
895
+ const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
896
+ const child = spawn(process.execPath, [indexPath, "daemon"], {
897
+ detached: true,
898
+ stdio: "ignore"
899
+ });
900
+ child.unref();
901
+ }
902
+ async function pingDaemon() {
903
+ try {
904
+ const controller = new AbortController();
905
+ const timer = setTimeout(() => controller.abort(), 500);
906
+ const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
907
+ clearTimeout(timer);
908
+ return res.ok;
909
+ } catch {
910
+ return false;
911
+ }
912
+ }
913
+ async function daemonToken() {
914
+ try {
915
+ const controller = new AbortController();
916
+ const timer = setTimeout(() => controller.abort(), 800);
917
+ const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
918
+ clearTimeout(timer);
919
+ if (!res.ok) return null;
920
+ const { token } = await res.json();
921
+ return token ?? null;
922
+ } catch {
923
+ return null;
924
+ }
925
+ }
926
+
927
+ // src/hook.ts
928
+ var SHELL_COMMS = /* @__PURE__ */ new Set([
929
+ "sh",
930
+ "bash",
931
+ "zsh",
932
+ "dash",
933
+ "fish",
934
+ "ksh",
935
+ "tcsh",
936
+ "csh",
937
+ "-sh",
938
+ "-bash",
939
+ "-zsh",
940
+ "-dash",
941
+ "-fish",
942
+ "-ksh",
943
+ "-tcsh",
944
+ "-csh"
945
+ ]);
946
+ function psField(pid, field) {
947
+ try {
948
+ return execFileSync2("ps", ["-o", `${field}=`, "-p", String(pid)], {
949
+ encoding: "utf8",
950
+ timeout: 500
951
+ }).trim();
952
+ } catch {
953
+ return "";
954
+ }
955
+ }
956
+ function resolveAgentProcess() {
957
+ let pid = process.ppid;
958
+ for (let hop = 0; hop < 5 && pid > 1; hop++) {
959
+ const comm = psField(pid, "comm");
960
+ if (!comm) return null;
961
+ const base = comm.split("/").pop() ?? comm;
962
+ if (!SHELL_COMMS.has(base)) {
963
+ const start = psField(pid, "lstart");
964
+ return start ? { pid, start } : null;
965
+ }
966
+ const ppid = Number.parseInt(psField(pid, "ppid"), 10);
967
+ if (!Number.isFinite(ppid) || ppid <= 1) return null;
968
+ pid = ppid;
969
+ }
970
+ return null;
971
+ }
972
+ function terminalName() {
973
+ const e = process.env;
974
+ return e.AGENT_IDLE_LABEL || e.TERM_PROGRAM || e.WT_SESSION || (e.TMUX_PANE ? `tmux ${e.TMUX_PANE}` : "") || e.TERM || "";
975
+ }
976
+ function tagPayload(body) {
977
+ try {
978
+ const obj = JSON.parse(body);
979
+ if (!obj || typeof obj !== "object") return body;
980
+ if (!obj.terminal) obj.terminal = terminalName();
981
+ const event = obj.hook_event_name;
982
+ if ((event === "SessionStart" || event === "UserPromptSubmit") && !obj.agentPid) {
983
+ const proc = resolveAgentProcess();
984
+ if (proc) {
985
+ obj.agentPid = proc.pid;
986
+ obj.agentStart = proc.start;
987
+ }
988
+ }
989
+ return JSON.stringify(obj);
990
+ } catch {
991
+ return body;
992
+ }
993
+ }
994
+ function readStdin() {
995
+ return new Promise((resolve) => {
996
+ const chunks = [];
997
+ process.stdin.on("data", (c) => chunks.push(c));
998
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
999
+ process.stdin.on("error", () => resolve(""));
1000
+ });
1001
+ }
1002
+ async function postToDaemon(url, body) {
1003
+ try {
1004
+ const controller = new AbortController();
1005
+ const timer = setTimeout(() => controller.abort(), 800);
1006
+ await fetch(url, {
1007
+ method: "POST",
1008
+ headers: { "Content-Type": "application/json" },
1009
+ body,
1010
+ signal: controller.signal
1011
+ });
1012
+ clearTimeout(timer);
1013
+ return true;
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ }
1018
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1019
+ async function runHook(agentArg) {
1020
+ const url = `${DAEMON_URL}/hook?agent=${parseAgent(agentArg)}`;
1021
+ let body = "{}";
1022
+ try {
1023
+ body = await readStdin() || "{}";
1024
+ } catch {
1025
+ }
1026
+ body = tagPayload(body);
1027
+ if (await postToDaemon(url, body)) return;
1028
+ spawnDaemon();
1029
+ await sleep(600);
1030
+ await postToDaemon(url, body);
1031
+ }
1032
+
1033
+ // src/kill.ts
1034
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1035
+ async function kill() {
1036
+ if (!await pingDaemon()) {
1037
+ console.log("No agent-idle daemon is running.");
1038
+ return;
1039
+ }
1040
+ try {
1041
+ const controller = new AbortController();
1042
+ const timer = setTimeout(() => controller.abort(), 1e3);
1043
+ await fetch(`${DAEMON_URL}/shutdown`, { method: "POST", signal: controller.signal });
1044
+ clearTimeout(timer);
1045
+ } catch {
1046
+ }
1047
+ await sleep2(250);
1048
+ if (await pingDaemon()) {
1049
+ console.log(
1050
+ "Asked the daemon to stop, but it's still responding. Re-run `agent-idle kill`, or kill whatever holds port 47615."
1051
+ );
1052
+ } else {
1053
+ console.log("\u2713 Stopped the agent-idle daemon. The next hook will start a fresh one.");
1054
+ }
1055
+ }
1056
+
1057
+ // src/remove.ts
1058
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
1059
+
1060
+ // src/hooks.ts
1061
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
1062
+ import { fileURLToPath as fileURLToPath2 } from "url";
1063
+ function ourBinPath() {
1064
+ return fileURLToPath2(new URL("./index.js", import.meta.url));
1065
+ }
1066
+ function isOurEntry(entry, binPath) {
1067
+ const inner = entry?.hooks;
1068
+ if (!Array.isArray(inner)) return false;
1069
+ return inner.some((h) => {
1070
+ const cmd = h?.command;
1071
+ if (typeof cmd !== "string") return false;
1072
+ return cmd.includes(binPath) || cmd.includes("agent-idle") && /\bhook\b/.test(cmd);
1073
+ });
1074
+ }
1075
+ function countInstalledEvents(path) {
1076
+ if (!existsSync4(path)) return 0;
1077
+ let config;
1078
+ try {
1079
+ config = JSON.parse(readFileSync4(path, "utf8"));
1080
+ } catch {
1081
+ return 0;
1082
+ }
1083
+ const hooks = config.hooks;
1084
+ if (!hooks || typeof hooks !== "object") return 0;
1085
+ const binPath = ourBinPath();
1086
+ let count = 0;
1087
+ for (const event of Object.keys(hooks)) {
1088
+ const arr = hooks[event];
1089
+ if (Array.isArray(arr) && arr.some((entry) => isOurEntry(entry, binPath))) count++;
1090
+ }
1091
+ return count;
1092
+ }
1093
+
1094
+ // src/remove.ts
1095
+ function stripHooks(path) {
1096
+ if (!existsSync5(path)) return 0;
1097
+ let config;
1098
+ try {
1099
+ config = JSON.parse(readFileSync5(path, "utf8"));
1100
+ } catch {
1101
+ return 0;
1102
+ }
1103
+ const hooks = config.hooks;
1104
+ if (!hooks || typeof hooks !== "object") return 0;
1105
+ const binPath = ourBinPath();
1106
+ let removed = 0;
1107
+ for (const event of Object.keys(hooks)) {
1108
+ const arr = hooks[event];
1109
+ if (!Array.isArray(arr)) continue;
1110
+ const kept = arr.filter((entry) => !isOurEntry(entry, binPath));
1111
+ if (kept.length === arr.length) continue;
1112
+ removed++;
1113
+ if (kept.length === 0) delete hooks[event];
1114
+ else hooks[event] = kept;
1115
+ }
1116
+ if (removed === 0) return 0;
1117
+ if (Object.keys(hooks).length === 0) delete config.hooks;
1118
+ writeFileSync3(path, JSON.stringify(config, null, 2) + "\n");
1119
+ return removed;
1120
+ }
1121
+ function remove() {
1122
+ const claude = stripHooks(CLAUDE_SETTINGS_PATH);
1123
+ const codex = stripHooks(CODEX_HOOKS_PATH);
1124
+ const hadToken = existsSync5(AUTH_TOKEN_PATH);
1125
+ clearToken();
1126
+ console.log(`
1127
+ \u2713 agent-idle removed
1128
+ Claude Code hooks: ${claude > 0 ? `cleared (${claude} events) \u2014 ${CLAUDE_SETTINGS_PATH}` : "none found"}
1129
+ Codex hooks: ${codex > 0 ? `cleared (${codex} events) \u2014 ${CODEX_HOOKS_PATH}` : "none found"}
1130
+ session: ${hadToken ? `cleared \u2014 ${AUTH_TOKEN_PATH}` : "none found"}
1131
+
1132
+ The daemon stops posting on its next tick. Re-run \`agent-idle setup\` to reinstall.
1133
+ `);
1134
+ }
1135
+
1136
+ // src/setup.ts
1137
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1138
+ import { fileURLToPath as fileURLToPath3 } from "url";
1139
+ import { cancel, isCancel, multiselect } from "@clack/prompts";
1140
+
1141
+ // src/auth.ts
1142
+ import { spawn as spawn2 } from "child_process";
1143
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1144
+ function openBrowser(url) {
1145
+ const [bin, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1146
+ try {
1147
+ spawn2(bin, args, {
1148
+ stdio: "ignore",
1149
+ detached: true
1150
+ }).unref();
1151
+ } catch {
1152
+ }
1153
+ }
1154
+ async function signIn() {
1155
+ if (readToken()) {
1156
+ console.log(
1157
+ "\u2713 Already signed in (shared session at ~/.agent-idle/auth.json)."
1158
+ );
1159
+ return;
1160
+ }
1161
+ if (!await pingDaemon()) {
1162
+ spawnDaemon();
1163
+ await sleep3(700);
1164
+ }
1165
+ console.log(`
1166
+ Opening ${AUTH_URL} to sign in (GitHub or email + password)\u2026`);
1167
+ console.log(
1168
+ "If it doesn't open, visit that URL manually. Waiting for sign-in\u2026 (Ctrl-C to cancel)"
1169
+ );
1170
+ openBrowser(AUTH_URL);
1171
+ const deadline = Date.now() + 12e4;
1172
+ while (Date.now() < deadline) {
1173
+ await sleep3(1500);
1174
+ if (await daemonToken() ?? readToken()) {
1175
+ console.log(
1176
+ "\u2713 Signed in \u2014 shared session established for the app, CLI, and daemon."
1177
+ );
1178
+ return;
1179
+ }
1180
+ }
1181
+ console.log(
1182
+ "Timed out waiting for sign-in. Re-run `agent-idle setup` once you've signed in."
1183
+ );
1184
+ }
1185
+
1186
+ // src/setup.ts
1187
+ var CLAUDE_HOOK_EVENTS = [
1188
+ "SessionStart",
1189
+ "UserPromptSubmit",
1190
+ "PreToolUse",
1191
+ "PostToolUse",
1192
+ "PostToolUseFailure",
1193
+ "PermissionRequest",
1194
+ "PermissionDenied",
1195
+ "Notification",
1196
+ "Elicitation",
1197
+ "ElicitationResult",
1198
+ "SubagentStart",
1199
+ "SubagentStop",
1200
+ "Stop",
1201
+ "StopFailure",
1202
+ "PreCompact",
1203
+ "PostCompact",
1204
+ "SessionEnd"
1205
+ ];
1206
+ var CODEX_HOOK_EVENTS = [
1207
+ "SessionStart",
1208
+ "UserPromptSubmit",
1209
+ "PreToolUse",
1210
+ "PostToolUse",
1211
+ "PermissionRequest",
1212
+ "SubagentStart",
1213
+ "SubagentStop",
1214
+ "PreCompact",
1215
+ "PostCompact",
1216
+ "Stop"
1217
+ ];
1218
+ function hookCommand(agent) {
1219
+ const indexPath = fileURLToPath3(new URL("./index.js", import.meta.url));
1220
+ const base = `"${process.execPath}" "${indexPath}" hook`;
1221
+ return agent === "codex" ? `${base} codex` : base;
1222
+ }
1223
+ function requireNode22() {
1224
+ const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
1225
+ if (major >= 22) return;
1226
+ console.error(`
1227
+ \u2717 agent-idle setup needs Node \u2265 22 \u2014 you're on Node ${process.versions.node} (${process.execPath}).
1228
+
1229
+ The hook command bakes in THIS Node binary, so installing now would freeze the wrong
1230
+ version into your agent's hook config. Switch first, then re-run:
1231
+
1232
+ nvm use 22 && agent-idle setup${process.argv.length > 3 ? ` ${process.argv.slice(3).join(" ")}` : ""}
1233
+ `);
1234
+ process.exit(1);
1235
+ }
1236
+ function setupClaude(command2) {
1237
+ const settings = existsSync6(CLAUDE_SETTINGS_PATH) ? JSON.parse(readFileSync6(CLAUDE_SETTINGS_PATH, "utf8")) : {};
1238
+ const hooks = settings.hooks ?? {};
1239
+ for (const event of CLAUDE_HOOK_EVENTS) {
1240
+ hooks[event] = [{ hooks: [{ type: "command", command: command2 }] }];
1241
+ }
1242
+ settings.hooks = hooks;
1243
+ ensureDir(CLAUDE_SETTINGS_PATH);
1244
+ writeFileSync4(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
1245
+ console.log(`
1246
+ \u2713 Registered Claude Code hook
1247
+ file: ${CLAUDE_SETTINGS_PATH}
1248
+ events: ${CLAUDE_HOOK_EVENTS.join(", ")}
1249
+ cmd: ${command2}
1250
+ ${privacyAndNext()}`);
1251
+ }
1252
+ function setupCodex(command2) {
1253
+ const config = existsSync6(CODEX_HOOKS_PATH) ? JSON.parse(readFileSync6(CODEX_HOOKS_PATH, "utf8")) : {};
1254
+ const hooks = config.hooks ?? {};
1255
+ for (const event of CODEX_HOOK_EVENTS) {
1256
+ hooks[event] = [{ hooks: [{ type: "command", command: command2 }] }];
1257
+ }
1258
+ config.hooks = hooks;
1259
+ ensureDir(CODEX_HOOKS_PATH);
1260
+ writeFileSync4(CODEX_HOOKS_PATH, JSON.stringify(config, null, 2) + "\n");
1261
+ console.log(`
1262
+ \u2713 Registered Codex hook
1263
+ file: ${CODEX_HOOKS_PATH}
1264
+ events: ${CODEX_HOOK_EVENTS.join(", ")}
1265
+ cmd: ${command2}
1266
+
1267
+ \u26A0 Codex requires you to TRUST a newly-installed command hook before it runs:
1268
+ open Codex and run /hooks \u2192 review and trust the agent-idle hook.
1269
+ ${privacyAndNext()}`);
1270
+ }
1271
+ function privacyAndNext() {
1272
+ return `
1273
+ PRIVACY: the daemon appraises your prompts LOCALLY to compute a coarse numeric
1274
+ quality score. It sends COUNTS + that score to the server \u2014 never your prompt text
1275
+ or source code. Nothing is written to any dotfile except the hook config above.
1276
+
1277
+ The sensor daemon starts AUTOMATICALLY the first time a hook fires (and stays
1278
+ running). You don't need to start it by hand. The SAME daemon serves both Claude
1279
+ Code and Codex.
1280
+
1281
+ (advanced: \`agent-idle daemon\` runs the sensor in the foreground for debugging.)
1282
+ `;
1283
+ }
1284
+ async function promptAgents() {
1285
+ const selection = await multiselect({
1286
+ message: "Which coding agents should feed Agent Idle?",
1287
+ options: [
1288
+ { value: "claude", label: "Claude Code" },
1289
+ { value: "codex", label: "Codex" }
1290
+ ],
1291
+ initialValues: ["claude"],
1292
+ required: true
1293
+ });
1294
+ if (isCancel(selection)) {
1295
+ cancel("Setup cancelled.");
1296
+ process.exit(0);
1297
+ }
1298
+ return parseAgents(selection);
1299
+ }
1300
+ async function setup(agentArgs) {
1301
+ requireNode22();
1302
+ const agents = agentArgs.length > 0 ? parseAgents(agentArgs) : await promptAgents();
1303
+ for (const agent of agents) {
1304
+ const command2 = hookCommand(agent);
1305
+ if (agent === "codex") setupCodex(command2);
1306
+ else setupClaude(command2);
1307
+ }
1308
+ await signIn();
1309
+ }
1310
+
1311
+ // src/status.ts
1312
+ async function fetchSessions() {
1313
+ try {
1314
+ const controller = new AbortController();
1315
+ const timer = setTimeout(() => controller.abort(), 800);
1316
+ const res = await fetch(`${DAEMON_URL}/sessions`, { signal: controller.signal });
1317
+ clearTimeout(timer);
1318
+ if (!res.ok) return null;
1319
+ return await res.json();
1320
+ } catch {
1321
+ return null;
1322
+ }
1323
+ }
1324
+ async function status() {
1325
+ const running = await pingDaemon();
1326
+ const signedIn = Boolean(readToken());
1327
+ const claude = countInstalledEvents(CLAUDE_SETTINGS_PATH);
1328
+ const codex = countInstalledEvents(CODEX_HOOKS_PATH);
1329
+ const queued = readOutbox().length;
1330
+ const lines = [
1331
+ `Daemon: ${running ? `running on 127.0.0.1:${DAEMON_PORT}` : "not running (a hook will auto-start it)"}`,
1332
+ `Session: ${signedIn ? "signed in" : "not signed in \u2014 run `agent-idle setup`"}`,
1333
+ `Hooks: Claude Code ${claude ? `\u2713 (${claude} events)` : "\u2717 not installed"} \xB7 Codex ${codex ? `\u2713 (${codex} events)` : "\u2717 not installed"}`,
1334
+ `Outbox: ${queued} event(s) queued${queued && !signedIn ? " (waiting for sign-in)" : ""}`
1335
+ ];
1336
+ if (running) {
1337
+ const sessions = await fetchSessions();
1338
+ const ids = sessions ? Object.keys(sessions) : [];
1339
+ if (ids.length === 0) {
1340
+ lines.push("Active: no live sessions");
1341
+ } else {
1342
+ lines.push(`Active: ${ids.length} session(s)`);
1343
+ for (const id of ids) {
1344
+ const m = sessions?.[id] ?? {};
1345
+ const label = [m.repo, m.topic].filter(Boolean).join(" \xB7 ") || "\u2014";
1346
+ const term = m.terminal ? ` (${m.terminal})` : "";
1347
+ lines.push(` ${id.slice(0, 8)} ${label}${term}`);
1348
+ }
1349
+ }
1350
+ }
1351
+ console.log(`
1352
+ ${lines.join("\n")}
1353
+ `);
1354
+ }
1355
+
1356
+ // src/index.ts
1357
+ var command = process.argv[2];
1358
+ var arg = process.argv[3];
1359
+ switch (command) {
1360
+ case "setup":
1361
+ await setup(process.argv.slice(3));
1362
+ break;
1363
+ case "status":
1364
+ await status();
1365
+ break;
1366
+ case "remove":
1367
+ remove();
1368
+ break;
1369
+ case "kill":
1370
+ await kill();
1371
+ break;
1372
+ case "daemon":
1373
+ startDaemon();
1374
+ break;
1375
+ case "hook":
1376
+ await runHook(arg);
1377
+ process.exit(0);
1378
+ break;
1379
+ default:
1380
+ console.log("Usage: agent-idle <setup [claude] [codex] | status | remove | kill>");
1381
+ process.exit(command ? 1 : 0);
1382
+ }