harlay 0.1.1 → 0.1.3

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/app/dist/main.cjs CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/main.ts
4
4
  var import_electron = require("electron");
5
- var import_promises12 = require("node:fs/promises");
5
+ var import_promises16 = require("node:fs/promises");
6
6
  var import_node_os2 = require("node:os");
7
- var import_node_path14 = require("node:path");
7
+ var import_node_path18 = require("node:path");
8
8
 
9
9
  // ../../packages/core/src/types.ts
10
10
  var TOOLS = ["claude-code", "codex", "cursor"];
@@ -29,20 +29,112 @@ var LaunchError = class extends HarlayError {
29
29
  written;
30
30
  };
31
31
 
32
+ // ../../packages/core/src/launch/launchers.ts
33
+ var import_node_child_process = require("node:child_process");
34
+ var import_promises = require("node:fs/promises");
35
+ var import_node_path = require("node:path");
36
+ var import_node_util = require("node:util");
37
+ var pexec = (0, import_node_util.promisify)(import_node_child_process.execFile);
38
+ var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
39
+ var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
40
+ var PROMPT_LIMIT = {
41
+ "claude-code": { app: 6e3, terminal: 1e5 },
42
+ codex: { app: 6e3, terminal: 1e5 },
43
+ cursor: { app: 9900, terminal: 1e5 }
44
+ };
45
+ function promptFits(target, mode, prompt) {
46
+ const length = mode === "app" ? encodeURIComponent(prompt).length : prompt.length;
47
+ return length <= PROMPT_LIMIT[target][mode];
48
+ }
49
+ var defaultRunner = {
50
+ async open(args) {
51
+ await pexec("/usr/bin/open", args);
52
+ },
53
+ async writeExecutable(path, content) {
54
+ await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
55
+ await (0, import_promises.writeFile)(path, content, { mode: 493 });
56
+ await (0, import_promises.chmod)(path, 493);
57
+ },
58
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
59
+ };
60
+ function shq(s) {
61
+ return `'${s.replace(/'/g, `'\\''`)}'`;
62
+ }
63
+ var enc = encodeURIComponent;
64
+ function planLaunch(o) {
65
+ if (o.mode === "app") {
66
+ switch (o.target) {
67
+ case "claude-code":
68
+ return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
69
+ case "codex":
70
+ return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
71
+ case "cursor":
72
+ return {
73
+ target: o.target,
74
+ mode: o.mode,
75
+ steps: [
76
+ { kind: "open", args: ["-a", "Cursor", o.cwd] },
77
+ { kind: "wait", ms: 1500 },
78
+ { kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
79
+ ]
80
+ };
81
+ }
82
+ }
83
+ const bin = o.binaries[o.target];
84
+ if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
85
+ const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
86
+ const path = (0, import_node_path.join)(o.scriptDir, `continue-${o.target}.command`);
87
+ const content = `#!/bin/zsh -l
88
+ cd -- ${shq(o.cwd)} || exit 1
89
+ exec ${argv.map(shq).join(" ")}
90
+ `;
91
+ return {
92
+ target: o.target,
93
+ mode: o.mode,
94
+ steps: [
95
+ { kind: "script", path, content },
96
+ { kind: "open", args: ["-a", o.terminalApp, path] }
97
+ ]
98
+ };
99
+ }
100
+ async function executeLaunch(plan, runner = defaultRunner) {
101
+ for (const step of plan.steps) {
102
+ if (step.kind === "open") await runner.open(step.args);
103
+ else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
104
+ else await runner.sleep(step.ms);
105
+ }
106
+ }
107
+
32
108
  // ../../packages/core/src/util/text.ts
33
109
  var CHARS_PER_TOKEN = 4;
34
- function clip(s, maxTokens, pointer) {
35
- const max = maxTokens * CHARS_PER_TOKEN;
36
- if (s.length <= max) return s;
37
- const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
38
- return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
39
- }
110
+ var isHigh = (c) => c >= 55296 && c <= 56319;
111
+ var isLow = (c) => c >= 56320 && c <= 57343;
40
112
  function tail(s, maxChars) {
41
- return s.length <= maxChars ? s : "\u2026" + s.slice(s.length - maxChars + 1);
113
+ if (s.length <= maxChars) return s;
114
+ let start = s.length - maxChars + 1;
115
+ if (start < s.length && isLow(s.charCodeAt(start))) start++;
116
+ return "\u2026" + s.slice(start);
117
+ }
118
+ var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
119
+ function wellFormed(s) {
120
+ return s.replace(LONE_SURROGATE, "\uFFFD");
42
121
  }
43
122
  function oneLine(s, max = 120) {
44
123
  const t = s.replace(/\s+/g, " ").trim();
45
- return t.length <= max ? t : t.slice(0, max - 1) + "\u2026";
124
+ if (t.length <= max) return t;
125
+ let end = max - 1;
126
+ if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
127
+ return t.slice(0, end) + "\u2026";
128
+ }
129
+ function wordCount(s) {
130
+ return s.split(/\s+/).filter(Boolean).length;
131
+ }
132
+ function clipWords(s, max) {
133
+ const w = s.split(/\s+/).filter(Boolean);
134
+ return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
135
+ }
136
+ function splitSentences(s) {
137
+ return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
46
138
  }
47
139
  var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
48
140
  var MACHINE_TAG_RE = /^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|bash-input|bash-stdout|bash-stderr|environment_context|user_instructions)\b/;
@@ -53,8 +145,27 @@ function cleanPrompt(raw) {
53
145
  if (MACHINE_TAG_RE.test(s)) return null;
54
146
  return s ? s : null;
55
147
  }
56
- function isHandoffPrompt(p) {
57
- return p.length <= 300 && p.includes(".harlay/HANDOFF.md");
148
+ var HANDOFF_TAG = '<handoff from="';
149
+ var HANDOFF_END = "</handoff>";
150
+ var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
151
+ var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
152
+ function splitHandoffPrompt(p) {
153
+ const start = p.indexOf(HANDOFF_TAG);
154
+ const end = p.lastIndexOf(HANDOFF_END);
155
+ if (start !== -1 && end > start) {
156
+ const before = p.slice(0, start).trim();
157
+ const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
158
+ const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
159
+ return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
160
+ }
161
+ if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
162
+ return { handoff: false, rest: p, id: null };
163
+ }
164
+ var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
165
+ var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
166
+ var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
167
+ function stripControl(s) {
168
+ return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
58
169
  }
59
170
  function slugify(s, max = 40) {
60
171
  const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
@@ -62,25 +173,25 @@ function slugify(s, max = 40) {
62
173
  }
63
174
 
64
175
  // ../../packages/core/src/sources/claude.ts
65
- var import_promises2 = require("node:fs/promises");
66
- var import_node_path2 = require("node:path");
176
+ var import_promises3 = require("node:fs/promises");
177
+ var import_node_path3 = require("node:path");
67
178
 
68
179
  // ../../packages/core/src/util/files.ts
69
- var import_promises = require("node:fs/promises");
70
- var import_node_path = require("node:path");
180
+ var import_promises2 = require("node:fs/promises");
181
+ var import_node_path2 = require("node:path");
71
182
  async function listSessionFiles(root, accept) {
72
183
  let names;
73
184
  try {
74
- names = await (0, import_promises.readdir)(root, { recursive: true });
185
+ names = await (0, import_promises2.readdir)(root, { recursive: true });
75
186
  } catch {
76
187
  return [];
77
188
  }
78
189
  const out = [];
79
190
  for (const name of names) {
80
- const path = (0, import_node_path.join)(root, name);
191
+ const path = (0, import_node_path2.join)(root, name);
81
192
  if (!accept(path)) continue;
82
193
  try {
83
- const s = await (0, import_promises.stat)(path);
194
+ const s = await (0, import_promises2.stat)(path);
84
195
  if (s.isFile()) out.push({ path, mtime: s.mtime });
85
196
  } catch {
86
197
  }
@@ -88,7 +199,7 @@ async function listSessionFiles(root, accept) {
88
199
  return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
89
200
  }
90
201
  async function readFirstLine(file, maxBytes = 8 * 1024 * 1024) {
91
- const fh = await (0, import_promises.open)(file, "r");
202
+ const fh = await (0, import_promises2.open)(file, "r");
92
203
  try {
93
204
  const chunks = [];
94
205
  const buf = Buffer.alloc(65536);
@@ -142,9 +253,9 @@ function blockText(content) {
142
253
 
143
254
  // ../../packages/core/src/sources/todos.ts
144
255
  var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
145
- function applyTodoWrite(current, input) {
146
- if (!isObj(input) || !Array.isArray(input.todos)) return current ?? /* @__PURE__ */ new Map();
147
- const next = input.merge === true && current ? new Map(current) : /* @__PURE__ */ new Map();
256
+ function applyTodoWrite(current2, input) {
257
+ if (!isObj(input) || !Array.isArray(input.todos)) return current2 ?? /* @__PURE__ */ new Map();
258
+ const next = input.merge === true && current2 ? new Map(current2) : /* @__PURE__ */ new Map();
148
259
  input.todos.forEach((raw, i) => {
149
260
  if (!isObj(raw)) return;
150
261
  const id = raw.id !== void 0 && raw.id !== null ? String(raw.id) : `#${i}`;
@@ -272,10 +383,10 @@ function normalizeClaude(records, ref, invalid = 0) {
272
383
  }
273
384
  async function loadDesktopTitles(dir) {
274
385
  const out = /* @__PURE__ */ new Map();
275
- const files = await listSessionFiles(dir, (f) => (0, import_node_path2.basename)(f).startsWith("local_") && f.endsWith(".json"));
386
+ const files = await listSessionFiles(dir, (f) => (0, import_node_path3.basename)(f).startsWith("local_") && f.endsWith(".json"));
276
387
  for (const f of files) {
277
388
  try {
278
- const d = JSON.parse(await (0, import_promises2.readFile)(f.path, "utf8"));
389
+ const d = JSON.parse(await (0, import_promises3.readFile)(f.path, "utf8"));
279
390
  if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
280
391
  out.set(d.cliSessionId, d.title);
281
392
  }
@@ -285,14 +396,14 @@ async function loadDesktopTitles(dir) {
285
396
  return out;
286
397
  }
287
398
  function createClaudeReader(o) {
288
- const root = (0, import_node_path2.join)(o.home, ".claude", "projects");
289
- const indexDir = (0, import_node_path2.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
290
- const isSessionFile = (file) => file.startsWith(root + import_node_path2.sep) && file.endsWith(".jsonl") && (0, import_node_path2.relative)(root, file).split(import_node_path2.sep).length === 2;
399
+ const root = (0, import_node_path3.join)(o.home, ".claude", "projects");
400
+ const indexDir = (0, import_node_path3.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
401
+ const isSessionFile = (file) => file.startsWith(root + import_node_path3.sep) && file.endsWith(".jsonl") && (0, import_node_path3.relative)(root, file).split(import_node_path3.sep).length === 2;
291
402
  const cache = /* @__PURE__ */ new Map();
292
403
  async function refForFile(file, titles) {
293
404
  let st;
294
405
  try {
295
- st = await (0, import_promises2.stat)(file);
406
+ st = await (0, import_promises3.stat)(file);
296
407
  } catch {
297
408
  return null;
298
409
  }
@@ -300,7 +411,7 @@ function createClaudeReader(o) {
300
411
  if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
301
412
  let text;
302
413
  try {
303
- text = await (0, import_promises2.readFile)(file, "utf8");
414
+ text = await (0, import_promises3.readFile)(file, "utf8");
304
415
  } catch {
305
416
  return null;
306
417
  }
@@ -325,7 +436,7 @@ function createClaudeReader(o) {
325
436
  entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
326
437
  cache.set(file, entry);
327
438
  }
328
- const id = (0, import_node_path2.basename)(file, ".jsonl");
439
+ const id = (0, import_node_path3.basename)(file, ".jsonl");
329
440
  const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
330
441
  if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
331
442
  return entry.ref;
@@ -334,7 +445,7 @@ function createClaudeReader(o) {
334
445
  tool: "claude-code",
335
446
  root,
336
447
  isSessionFile,
337
- idFromPath: (file) => isSessionFile(file) ? (0, import_node_path2.basename)(file, ".jsonl") : null,
448
+ idFromPath: (file) => isSessionFile(file) ? (0, import_node_path3.basename)(file, ".jsonl") : null,
338
449
  refForFile: (file) => refForFile(file),
339
450
  async listSessions(limit = 20) {
340
451
  const files = await listSessionFiles(root, isSessionFile);
@@ -348,7 +459,7 @@ function createClaudeReader(o) {
348
459
  return refs;
349
460
  },
350
461
  async readSession(ref) {
351
- const { records, invalid } = parseJsonLines(await (0, import_promises2.readFile)(ref.file, "utf8"));
462
+ const { records, invalid } = parseJsonLines(await (0, import_promises3.readFile)(ref.file, "utf8"));
352
463
  return normalizeClaude(records, ref, invalid);
353
464
  },
354
465
  detect(record) {
@@ -361,8 +472,8 @@ function createClaudeReader(o) {
361
472
  }
362
473
 
363
474
  // ../../packages/core/src/sources/codex.ts
364
- var import_promises3 = require("node:fs/promises");
365
- var import_node_path3 = require("node:path");
475
+ var import_promises4 = require("node:fs/promises");
476
+ var import_node_path4 = require("node:path");
366
477
  var OUTPUT_TAIL2 = 2e4;
367
478
  var ROLLOUT_ID_RE = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
368
479
  var KIND = { add: "create", update: "modify", delete: "delete" };
@@ -379,25 +490,25 @@ function usageOf(p) {
379
490
  if (!primary || typeof primary.used_percent !== "number") return null;
380
491
  return { usedPercent: primary.used_percent, resetsAt: typeof primary.resets_at === "number" ? new Date(primary.resets_at * 1e3) : null };
381
492
  }
382
- function reachedHalt(p, usage) {
493
+ function reachedHalt(p, usage2) {
383
494
  const rl = isObj(p.rate_limits) ? p.rate_limits : null;
384
495
  const t = rl ? rl.rate_limit_reached_type : null;
385
496
  if (t === null || t === void 0) return null;
386
- return { reason: "usage_limit", message: `Codex usage limit reached (${String(t)})`, resetsAt: usage?.resetsAt ?? null };
497
+ return { reason: "usage_limit", message: `Codex usage limit reached (${String(t)})`, resetsAt: usage2?.resetsAt ?? null };
387
498
  }
388
- function taskHalt(p, usage) {
499
+ function taskHalt(p, usage2) {
389
500
  const e = isObj(p.error) ? p.error : null;
390
501
  const message = e ? str(e.message) : null;
391
502
  if (!e || !message) return null;
392
503
  const limited = e.codex_error_info === "usage_limit_exceeded" || /usage limit|rate limit/i.test(message);
393
- return { reason: limited ? "usage_limit" : "crash", message, resetsAt: usage?.resetsAt ?? null };
504
+ return { reason: limited ? "usage_limit" : "crash", message, resetsAt: usage2?.resetsAt ?? null };
394
505
  }
395
506
  function normalizeCodex(records, ref, invalid = 0) {
396
507
  const timeline = [];
397
508
  const fallbackPrompts = [];
398
509
  let sawItemPrompt = false;
399
510
  let model = null;
400
- let usage = null;
511
+ let usage2 = null;
401
512
  let halt = null;
402
513
  let unknown = invalid;
403
514
  for (const rec of records) {
@@ -440,16 +551,16 @@ function normalizeCodex(records, ref, invalid = 0) {
440
551
  const text = typeof p.message === "string" ? cleanPrompt(p.message) : null;
441
552
  if (text) fallbackPrompts.push(text);
442
553
  } else if (p.type === "token_count") {
443
- usage = usageOf(p) ?? usage;
444
- halt = reachedHalt(p, usage) ?? halt;
554
+ usage2 = usageOf(p) ?? usage2;
555
+ halt = reachedHalt(p, usage2) ?? halt;
445
556
  } else if (p.type === "task_complete") {
446
- halt = taskHalt(p, usage);
557
+ halt = taskHalt(p, usage2);
447
558
  }
448
559
  }
449
560
  if (!sawItemPrompt && fallbackPrompts.length) {
450
561
  timeline.unshift(...fallbackPrompts.map((text) => ({ kind: "user", text })));
451
562
  }
452
- return { ref, model, timeline, todos: null, halt, usage, unknownRecords: unknown };
563
+ return { ref, model, timeline, todos: null, halt, usage: usage2, unknownRecords: unknown };
453
564
  }
454
565
  function parseSessionMeta(line) {
455
566
  let r;
@@ -468,7 +579,7 @@ async function loadTitles(file) {
468
579
  const out = /* @__PURE__ */ new Map();
469
580
  let text;
470
581
  try {
471
- text = await (0, import_promises3.readFile)(file, "utf8");
582
+ text = await (0, import_promises4.readFile)(file, "utf8");
472
583
  } catch {
473
584
  return out;
474
585
  }
@@ -478,14 +589,14 @@ async function loadTitles(file) {
478
589
  return out;
479
590
  }
480
591
  function createCodexReader(o) {
481
- const root = (0, import_node_path3.join)(o.home, ".codex", "sessions");
482
- const indexFile = (0, import_node_path3.join)(o.home, ".codex", "session_index.jsonl");
483
- const isSessionFile = (file) => file.startsWith(root + import_node_path3.sep) && (0, import_node_path3.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
592
+ const root = (0, import_node_path4.join)(o.home, ".codex", "sessions");
593
+ const indexFile = (0, import_node_path4.join)(o.home, ".codex", "session_index.jsonl");
594
+ const isSessionFile = (file) => file.startsWith(root + import_node_path4.sep) && (0, import_node_path4.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
484
595
  const cache = /* @__PURE__ */ new Map();
485
596
  async function refForFile(file, titles) {
486
597
  let st;
487
598
  try {
488
- st = await (0, import_promises3.stat)(file);
599
+ st = await (0, import_promises4.stat)(file);
489
600
  } catch {
490
601
  return null;
491
602
  }
@@ -510,7 +621,7 @@ function createCodexReader(o) {
510
621
  tool: "codex",
511
622
  root,
512
623
  isSessionFile,
513
- idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path3.basename)(file))?.[1] ?? null : null,
624
+ idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path4.basename)(file))?.[1] ?? null : null,
514
625
  refForFile: (file) => refForFile(file),
515
626
  async listSessions(limit = 20) {
516
627
  const files = await listSessionFiles(root, isSessionFile);
@@ -524,7 +635,7 @@ function createCodexReader(o) {
524
635
  return refs;
525
636
  },
526
637
  async readSession(ref) {
527
- const { records, invalid } = parseJsonLines(await (0, import_promises3.readFile)(ref.file, "utf8"));
638
+ const { records, invalid } = parseJsonLines(await (0, import_promises4.readFile)(ref.file, "utf8"));
528
639
  return normalizeCodex(records, ref, invalid);
529
640
  },
530
641
  detect(record) {
@@ -549,21 +660,21 @@ function createCodexReader(o) {
549
660
  }
550
661
 
551
662
  // ../../packages/core/src/sources/cursor.ts
552
- var import_promises4 = require("node:fs/promises");
553
- var import_node_path4 = require("node:path");
663
+ var import_promises5 = require("node:fs/promises");
664
+ var import_node_path5 = require("node:path");
554
665
  var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
555
666
  async function resolveCursorSlug(slug, rootDir = "/") {
556
667
  if (!slug || /^\d+$/.test(slug)) return null;
557
668
  async function walk(dir, rest, depth) {
558
669
  if (depth > 16) return null;
559
- const entries = await (0, import_promises4.readdir)(dir, { withFileTypes: true }).catch(() => null);
670
+ const entries = await (0, import_promises5.readdir)(dir, { withFileTypes: true }).catch(() => null);
560
671
  if (!entries) return null;
561
672
  for (const e of entries) {
562
673
  if (!e.isDirectory()) continue;
563
674
  const n = norm(e.name);
564
- if (rest === n) return (0, import_node_path4.join)(dir, e.name);
675
+ if (rest === n) return (0, import_node_path5.join)(dir, e.name);
565
676
  if (rest.startsWith(n + "-")) {
566
- const found = await walk((0, import_node_path4.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
677
+ const found = await walk((0, import_node_path5.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
567
678
  if (found) return found;
568
679
  }
569
680
  }
@@ -623,7 +734,7 @@ function normalizeCursor(records, ref, invalid = 0) {
623
734
  return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
624
735
  }
625
736
  function createCursorReader(o) {
626
- const root = (0, import_node_path4.join)(o.home, ".cursor", "projects");
737
+ const root = (0, import_node_path5.join)(o.home, ".cursor", "projects");
627
738
  const slugCache = /* @__PURE__ */ new Map();
628
739
  const cwdFor = (slug) => {
629
740
  let p = slugCache.get(slug);
@@ -634,17 +745,17 @@ function createCursorReader(o) {
634
745
  return p;
635
746
  };
636
747
  const isSessionFile = (file) => {
637
- if (!file.startsWith(root + import_node_path4.sep) || !file.endsWith(".jsonl")) return false;
638
- const parts = (0, import_node_path4.relative)(root, file).split(import_node_path4.sep);
748
+ if (!file.startsWith(root + import_node_path5.sep) || !file.endsWith(".jsonl")) return false;
749
+ const parts = (0, import_node_path5.relative)(root, file).split(import_node_path5.sep);
639
750
  return parts.length === 4 && parts[1] === "agent-transcripts" && parts[3] === `${parts[2]}.jsonl`;
640
751
  };
641
752
  const cache = /* @__PURE__ */ new Map();
642
753
  async function refForFile(file) {
643
754
  if (!isSessionFile(file)) return null;
644
- const parts = (0, import_node_path4.relative)(root, file).split(import_node_path4.sep);
755
+ const parts = (0, import_node_path5.relative)(root, file).split(import_node_path5.sep);
645
756
  let st;
646
757
  try {
647
- st = await (0, import_promises4.stat)(file);
758
+ st = await (0, import_promises5.stat)(file);
648
759
  } catch {
649
760
  return null;
650
761
  }
@@ -652,7 +763,7 @@ function createCursorReader(o) {
652
763
  if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
653
764
  let text;
654
765
  try {
655
- text = await (0, import_promises4.readFile)(file, "utf8");
766
+ text = await (0, import_promises5.readFile)(file, "utf8");
656
767
  } catch {
657
768
  return null;
658
769
  }
@@ -660,8 +771,9 @@ function createCursorReader(o) {
660
771
  for (const r of parseJsonLines(text).records) {
661
772
  if (isObj(r) && r.role === "user" && isObj(r.message)) {
662
773
  const p = cleanPrompt(blockText(r.message.content));
663
- if (p && !isHandoffPrompt(p)) {
664
- title = oneLine(p, 60);
774
+ const rest = p ? splitHandoffPrompt(p).rest : "";
775
+ if (rest) {
776
+ title = oneLine(rest, 60);
665
777
  break;
666
778
  }
667
779
  }
@@ -674,7 +786,7 @@ function createCursorReader(o) {
674
786
  tool: "cursor",
675
787
  root,
676
788
  isSessionFile,
677
- idFromPath: (file) => isSessionFile(file) ? (0, import_node_path4.relative)(root, file).split(import_node_path4.sep)[2] : null,
789
+ idFromPath: (file) => isSessionFile(file) ? (0, import_node_path5.relative)(root, file).split(import_node_path5.sep)[2] : null,
678
790
  refForFile,
679
791
  async listSessions(limit = 20) {
680
792
  const files = await listSessionFiles(root, isSessionFile);
@@ -687,7 +799,7 @@ function createCursorReader(o) {
687
799
  return refs;
688
800
  },
689
801
  async readSession(ref) {
690
- const { records, invalid } = parseJsonLines(await (0, import_promises4.readFile)(ref.file, "utf8"));
802
+ const { records, invalid } = parseJsonLines(await (0, import_promises5.readFile)(ref.file, "utf8"));
691
803
  return normalizeCursor(records, ref, invalid);
692
804
  },
693
805
  detect(record) {
@@ -700,14 +812,14 @@ function createCursorReader(o) {
700
812
  }
701
813
 
702
814
  // ../../packages/core/src/git/snapshot.ts
703
- var import_node_child_process = require("node:child_process");
815
+ var import_node_child_process2 = require("node:child_process");
704
816
  var import_node_crypto = require("node:crypto");
705
- var import_node_util = require("node:util");
706
- var pexec = (0, import_node_util.promisify)(import_node_child_process.execFile);
817
+ var import_node_util2 = require("node:util");
818
+ var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
707
819
  var MAX_DIFF_CHARS = 2e6;
708
820
  var MAX_FILES = 500;
709
821
  async function git(cwd, args) {
710
- const { stdout } = await pexec("git", args, {
822
+ const { stdout } = await pexec2("git", args, {
711
823
  cwd,
712
824
  maxBuffer: 64 * 1024 * 1024,
713
825
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
@@ -861,12 +973,76 @@ var Redactor = class {
861
973
  };
862
974
 
863
975
  // ../../packages/core/src/extract/extract.ts
864
- var import_node_path5 = require("node:path");
865
- var DECISION_RE = /\b(don['’]t|do not|never|must|always|keep|avoid|make sure|without|instead of)\b/i;
976
+ var import_node_path6 = require("node:path");
977
+
978
+ // ../../packages/core/src/extract/recent.ts
979
+ var RECENT = { budgetTokens: 450, min: 2, max: 6, words: 120 };
980
+ function pickRecentMessages(prompts) {
981
+ const out = [];
982
+ let used = 0;
983
+ for (let i = prompts.length - 1; i >= 0 && out.length < RECENT.max; i--) {
984
+ const m = clipWords(prompts[i], RECENT.words);
985
+ if (!m) continue;
986
+ const cost = Math.ceil(m.length / CHARS_PER_TOKEN);
987
+ if (out.length >= RECENT.min && used + cost > RECENT.budgetTokens) break;
988
+ out.unshift(m);
989
+ used += cost;
990
+ }
991
+ return out;
992
+ }
993
+
994
+ // ../../packages/core/src/extract/rules.ts
995
+ var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
996
+ var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
997
+ var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
998
+ function findPinnedRules(prompts, recent) {
999
+ const recentText = norm2(recent.join("\n"));
1000
+ const found = [];
1001
+ prompts.forEach((p, message) => {
1002
+ if (wordCount(p) > RULES2.pastedWords) return;
1003
+ for (const sentence of splitSentences(p)) {
1004
+ if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
1005
+ if (recentText.includes(norm2(sentence))) continue;
1006
+ found.push({ text: clipWords(sentence, RULES2.words), message });
1007
+ }
1008
+ });
1009
+ return capRules(latestCopies(found));
1010
+ }
1011
+ function mergePinnedRules(older, newer, recent) {
1012
+ const recentText = norm2(recent.join("\n"));
1013
+ const all = [...older.map((r) => ({ text: r.text, message: -1 })), ...newer].filter((r) => !recentText.includes(norm2(r.text)));
1014
+ return capRules(latestCopies(all));
1015
+ }
1016
+ function latestCopies(rules) {
1017
+ const byKey = /* @__PURE__ */ new Map();
1018
+ for (const r of rules) {
1019
+ const key = norm2(r.text);
1020
+ byKey.delete(key);
1021
+ byKey.set(key, r);
1022
+ }
1023
+ return [...byKey.values()];
1024
+ }
1025
+ function capRules(all) {
1026
+ const kept = [];
1027
+ let used = 0;
1028
+ for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
1029
+ const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
1030
+ if (used + cost > RULES2.budgetTokens) break;
1031
+ kept.unshift(all[i]);
1032
+ used += cost;
1033
+ }
1034
+ return { rules: kept, more: all.length - kept.length };
1035
+ }
1036
+
1037
+ // ../../packages/core/src/extract/extract.ts
866
1038
  var TRANSCRIPT_MAX = 2e5;
1039
+ var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
1040
+ function isSlashCommand(p) {
1041
+ return SLASH_COMMAND_RE.test(p.trim());
1042
+ }
867
1043
  function isSubstantive(p) {
868
1044
  const t = p.trim();
869
- return !t.startsWith("/") && t.split(/\s+/).length >= 3;
1045
+ return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
870
1046
  }
871
1047
  var PROBES = /* @__PURE__ */ new Set([
872
1048
  "rg",
@@ -901,95 +1077,96 @@ function isProbeCommand(cmd) {
901
1077
  let s = cmd;
902
1078
  for (let m = LEADING_CD_RE.exec(s); m; m = LEADING_CD_RE.exec(s)) s = s.slice(m[0].length);
903
1079
  const words = s.trim().split(/\s+/);
904
- const first = (0, import_node_path5.basename)(words[0] ?? "");
1080
+ const first = (0, import_node_path6.basename)(words[0] ?? "");
905
1081
  if (first !== "git") return PROBES.has(first);
906
1082
  let i = 1;
907
1083
  while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
908
1084
  return GIT_PROBES.has(words[i] ?? "");
909
1085
  }
910
- function findBlocker(cmds) {
911
- for (let i = cmds.length - 1; i >= 0; i--) {
912
- const c = cmds[i];
913
- if (c.exitCode === null || c.exitCode === 0 || isProbeCommand(c.cmd)) continue;
914
- if (!cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return c;
1086
+ var FAILURE_WINDOW = 5;
1087
+ function findLastFailure(cmds) {
1088
+ const real = cmds.filter((c) => !isProbeCommand(c.cmd));
1089
+ for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
1090
+ const c = real[i];
1091
+ if (c.exitCode === null || c.exitCode === 0) continue;
1092
+ if (real.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) continue;
1093
+ return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
915
1094
  }
916
1095
  return null;
917
1096
  }
918
- function findDeadEnds(cmds, blocker) {
919
- const seen = /* @__PURE__ */ new Set();
920
- const out = [];
921
- cmds.forEach((c, i) => {
922
- if (c.exitCode === null || c.exitCode === 0 || seen.has(c.cmd) || isProbeCommand(c.cmd)) return;
923
- if (blocker && c.cmd === blocker.cmd) return;
924
- if (cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return;
925
- seen.add(c.cmd);
926
- out.push({ cmd: c.cmd, exitCode: c.exitCode });
927
- });
928
- return out.slice(-5);
1097
+ function dropPartialLine(output) {
1098
+ if (!output.startsWith("\u2026")) return output;
1099
+ const nl = output.indexOf("\n");
1100
+ return nl === -1 ? "" : output.slice(nl + 1);
929
1101
  }
930
- function findDecisions(prompts) {
931
- const out = [];
932
- for (const p of prompts) {
933
- for (const s of p.split(/(?<=[.!?])\s+|\n+/)) {
934
- const t = s.trim();
935
- if (t.length >= 8 && t.length <= 300 && DECISION_RE.test(t) && !out.includes(t)) out.push(t);
936
- }
937
- }
938
- return out.slice(-8);
939
- }
940
- function buildTranscript(s, title, rel) {
1102
+ function buildTranscript(s, title, rel, redactor) {
1103
+ const r = (x) => redactor.apply(x);
941
1104
  const lines = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
942
1105
  for (const t of s.timeline) {
943
- if (t.kind === "user") lines.push("**User:**", t.text, "");
944
- else if (t.kind === "assistant") lines.push("**Agent:**", t.text.length > 600 ? t.text.slice(0, 599) + "\u2026" : t.text, "");
945
- else if (t.kind === "command") lines.push(`\`$ ${oneLine(t.run.cmd, 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
946
- else lines.push(`Edited \`${rel(t.edit.path)}\` (${t.edit.kind})`, "");
1106
+ if (t.kind === "user") {
1107
+ const { handoff: handoff2, rest } = splitHandoffPrompt(t.text);
1108
+ if (handoff2) lines.push("**Harlay handoff prompt** (from the previous agent; context is in the brief)", "");
1109
+ if (rest) lines.push("**User:**", r(rest), "");
1110
+ } else if (t.kind === "assistant") {
1111
+ const text = r(t.text);
1112
+ lines.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
1113
+ } else if (t.kind === "command") lines.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
1114
+ else lines.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
947
1115
  }
948
1116
  const out = lines.join("\n");
949
- return out.length > TRANSCRIPT_MAX ? "\u2026(earlier transcript truncated)\n" + out.slice(out.length - TRANSCRIPT_MAX) : out;
1117
+ if (out.length <= TRANSCRIPT_MAX) return out;
1118
+ const cut = out.slice(out.length - TRANSCRIPT_MAX);
1119
+ const nl = cut.indexOf("\n");
1120
+ return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
950
1121
  }
951
1122
  function extractHandoff(s, git2, o) {
952
1123
  const cwd = s.ref.cwd;
953
1124
  if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
954
1125
  const workdir = git2?.root ?? cwd;
955
1126
  const rel = (p) => {
956
- if (!(0, import_node_path5.isAbsolute)(p)) return p;
957
- const r = (0, import_node_path5.relative)(workdir, p);
958
- return r.startsWith("..") || (0, import_node_path5.isAbsolute)(r) ? p : r;
1127
+ if (!(0, import_node_path6.isAbsolute)(p)) return p;
1128
+ const r = (0, import_node_path6.relative)(workdir, p);
1129
+ return r.startsWith("..") || (0, import_node_path6.isAbsolute)(r) ? p : r;
959
1130
  };
960
- const allPrompts = s.timeline.flatMap((t) => t.kind === "user" ? [t.text] : []);
961
- const prompts = allPrompts.filter((p) => !isHandoffPrompt(p));
1131
+ const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
1132
+ const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
962
1133
  const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
963
1134
  const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
964
1135
  const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
965
- const carried = o.previous && o.previous.goal !== null && prompts.length < allPrompts.length ? o.previous : null;
966
- const goal = carried ? carried.goal : prompts.find(isSubstantive) ?? prompts[0] ?? null;
967
- const last = carried ? [...prompts].reverse().find(isSubstantive) ?? null : prompts.length ? prompts[prompts.length - 1] : null;
968
- const latestInstruction = last !== null && last !== goal && isSubstantive(last) ? last : null;
1136
+ const redactor = new Redactor();
1137
+ const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
1138
+ const prev = o.previous;
1139
+ const fromPrev = prev ? split.some((p) => p.handoff && (p.id === null || p.id === prev.id)) : false;
1140
+ const carried = prev && prev.goal !== null && fromPrev ? prev : null;
1141
+ const carriedRecent = carried ? carried.recentUserMessages.map((m) => redactor.apply(m)) : [];
1142
+ const recentUserMessages = pickRecentMessages([...carriedRecent, ...realPrompts]);
1143
+ let { rules: pinnedRules, more: moreRules } = findPinnedRules(realPrompts, recentUserMessages);
1144
+ if (carried) {
1145
+ const older = [...carried.pinnedRules.map((r) => ({ ...r, text: redactor.apply(r.text) })), ...findPinnedRules(carriedRecent, []).rules];
1146
+ const merged = mergePinnedRules(older, pinnedRules, recentUserMessages);
1147
+ moreRules += merged.more + carried.moreRules;
1148
+ pinnedRules = merged.rules;
1149
+ }
1150
+ const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
969
1151
  const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
970
- const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
971
1152
  const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
972
- const nextStep = nextTodo ? { text: nextTodo.text, source: "todo" } : lastAgent ? { text: lastAgent, source: "agent" } : null;
973
- const blocker = findBlocker(cmds);
974
- const editedFiles = [];
975
- const counts = /* @__PURE__ */ new Map();
976
- for (const e of edits) {
1153
+ const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
1154
+ const openTodos = todos.flatMap((t) => t.status === "in_progress" || t.status === "pending" ? [{ text: redactor.apply(t.text).replace(/\s+/g, " ").trim(), status: t.status }] : []).slice(0, 8);
1155
+ const failure = findLastFailure(cmds);
1156
+ const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
1157
+ const filesTouched = [];
1158
+ for (const e of [...edits].reverse()) {
977
1159
  const r = rel(e.path);
978
- if (!editedFiles.includes(r)) editedFiles.push(r);
979
- counts.set(r, (counts.get(r) ?? 0) + 1);
1160
+ if (!(0, import_node_path6.isAbsolute)(r) && !filesTouched.includes(r)) filesTouched.push(r);
1161
+ if (filesTouched.length === 12) break;
980
1162
  }
981
- const readFirst = [...editedFiles].sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0)).slice(0, 3);
982
- const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
983
- const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
984
- const redactor = new Redactor();
985
- const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
1163
+ const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
986
1164
  const pad = (n2) => String(n2).padStart(2, "0");
987
1165
  const n = o.now;
988
1166
  const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
989
1167
  return {
990
1168
  baseId,
991
1169
  title,
992
- status: "in_progress",
993
1170
  createdAt: o.now,
994
1171
  from: {
995
1172
  tool: s.ref.tool,
@@ -1001,142 +1178,155 @@ function extractHandoff(s, git2, o) {
1001
1178
  },
1002
1179
  workdir,
1003
1180
  git: git2,
1004
- goal,
1005
- latestInstruction,
1006
- nextStep,
1007
- blocker: blocker && blocker.exitCode !== null ? { cmd: blocker.cmd, exitCode: blocker.exitCode, output: blocker.output } : null,
1008
- progress,
1009
- progressSource,
1010
- editedFiles,
1011
- decisions: findDecisions(prompts),
1012
- deadEnds: findDeadEnds(cmds, blocker),
1013
- readFirst,
1014
- transcript: buildTranscript(s, title, rel),
1015
- unknownRecords: s.unknownRecords
1181
+ originalGoal,
1182
+ recentUserMessages,
1183
+ pinnedRules,
1184
+ moreRules,
1185
+ whereItStopped,
1186
+ todos: openTodos,
1187
+ filesTouched,
1188
+ lastFailure,
1189
+ transcript: buildTranscript(s, title, rel, redactor),
1190
+ unknownRecords: s.unknownRecords,
1191
+ redactions: { ...redactor.counts },
1192
+ carriedFrom: carried ? carried.id : null
1016
1193
  };
1017
1194
  }
1018
1195
 
1019
- // ../../packages/core/src/brief/render.ts
1020
- var BUDGET = { goal: 150, latest: 100, next: 200, blocker: 250, progress: 300, files: 300, decisions: 250, deadEnds: 200 };
1196
+ // ../../packages/core/src/brief/write.ts
1197
+ var import_promises6 = require("node:fs/promises");
1198
+ var import_node_path7 = require("node:path");
1199
+
1200
+ // ../../packages/core/src/brief/prompt.ts
1201
+ var TIER_MAX = { full: 6e3, compact: 3e3 };
1021
1202
  var REASON_TEXT = {
1022
1203
  usage_limit: "it hit a usage limit",
1023
1204
  crash: "it stopped with an error",
1024
1205
  manual: "the user handed it off"
1025
1206
  };
1026
- var j = (v) => JSON.stringify(v ?? null);
1027
- function quote(s) {
1028
- return s.split("\n").map((l) => l.trim() ? `> ${l}` : ">").join("\n");
1029
- }
1030
- function listWithin(lines, maxTokens, pointer) {
1031
- const max = maxTokens * CHARS_PER_TOKEN;
1032
- const out = [];
1033
- let used = 0;
1034
- for (let i = 0; i < lines.length; i++) {
1035
- const line = lines[i];
1036
- if (out.length > 0 && used + line.length + 1 > max) {
1037
- out.push(`- \u2026 ${lines.length - i} more (see \`${pointer}\`)`);
1038
- break;
1039
- }
1040
- out.push(line);
1041
- used += line.length + 1;
1042
- }
1043
- return out;
1044
- }
1045
- function tailWithin(s, maxTokens) {
1046
- const max = maxTokens * CHARS_PER_TOKEN;
1047
- const t = s.trimEnd();
1048
- if (t.length <= max) return t;
1049
- const cut = t.slice(t.length - max);
1050
- const nl = cut.indexOf("\n");
1051
- return "\u2026\n" + (nl >= 0 ? cut.slice(nl + 1) : cut);
1052
- }
1053
- function fenceFor(body) {
1054
- const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((r) => r.length));
1055
- return "`".repeat(Math.max(3, longest + 1));
1056
- }
1057
- function fileLine(f) {
1058
- const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
1059
- return `- \`${f.path}\` (${f.status}${counts})`;
1060
- }
1061
- function renderBrief(d, id, opts = {}) {
1062
- const dir = `.harlay/handoffs/${id}`;
1063
- const transcript = `${dir}/transcript.md`;
1064
- const front = [
1065
- "---",
1066
- "harlay: 1",
1067
- `id: ${j(id)}`,
1068
- `status: ${j(d.status)}`,
1069
- `created: ${j(d.createdAt.toISOString())}`,
1070
- "from:",
1071
- ` tool: ${j(d.from.tool)}`,
1072
- ` model: ${j(d.from.model)}`,
1073
- ` session: ${j(d.from.session)}`,
1074
- ` reason: ${j(d.from.reason)}`,
1075
- ` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
1076
- "repo:",
1077
- ` path: ${j(d.workdir)}`,
1078
- ...d.git ? [` branch: ${j(d.git.branch)}`, ` base_commit: ${j(d.git.head)}`, ` dirty_files: ${d.git.files.length}`, ` diff_sha: ${j(d.git.diffSha.slice(0, 12))}`] : [" git: false"],
1079
- "---"
1080
- ];
1081
- const header = [
1082
- `# Continue: ${d.title}`,
1083
- "",
1084
- `> You are continuing a coding task that another AI agent (${TOOL_LABEL[d.from.tool]}) started but could not finish (${REASON_TEXT[d.from.reason]}).`,
1085
- "> This brief was extracted from its session log and the repo.",
1086
- d.git ? '> First run `git status` and check it matches "Changed files". Do not redo finished work.' : "> Check the files listed below before editing. Do not redo finished work."
1207
+ var OUR_TAGS = /<(\/?)(handoff|original_goal|rules_from_user|recent_user_messages|where_it_stopped|files|last_failure)\b/gi;
1208
+ function safe(s) {
1209
+ return s.replace(OUR_TAGS, "\u2039$1$2");
1210
+ }
1211
+ var attr = (s) => safe(s).replace(/"/g, "'").replace(/\s+/g, " ").trim();
1212
+ var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
1213
+ function goalIsRecent(goal, recent) {
1214
+ const g = norm3(goal.replace(/\s…$/, ""));
1215
+ return recent.some((m) => norm3(m).startsWith(g));
1216
+ }
1217
+ function renderPrompt(rec, tier) {
1218
+ const full = tier === "full";
1219
+ const from = TOOL_LABEL[rec.from.tool];
1220
+ const dir = `.harlay/handoffs/${rec.id}`;
1221
+ const out = [
1222
+ `<handoff from="${from}" reason="${rec.from.reason}" task="${attr(rec.task.title)}">`,
1223
+ `You are taking over a coding session that ${from} could not finish (${REASON_TEXT[rec.from.reason]}). What you need to start is below; don't re-read the repo to rebuild it.`
1087
1224
  ];
1088
- const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
1089
- if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
1090
- const next = ["## 2. Next step"];
1091
- if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
1092
- else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
1093
- else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
1094
- const blocker = ["## 3. Blocker"];
1095
- if (d.blocker) {
1096
- const body = tailWithin(d.blocker.output, BUDGET.blocker);
1097
- const fence = fenceFor(body);
1098
- blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
1099
- } else {
1100
- blocker.push("No failing command recorded.");
1225
+ const goal = rec.task.originalGoal;
1226
+ const noRecent = rec.task.recentUserMessages.length === 0;
1227
+ if (goal && (full ? !goalIsRecent(goal, rec.task.recentUserMessages) : noRecent)) out.push("", "<original_goal>", safe(goal), "</original_goal>");
1228
+ if (rec.task.pinnedRules.length) {
1229
+ out.push("", "<rules_from_user>", ...rec.task.pinnedRules.map((r) => `- ${safe(r.text)}`));
1230
+ if (rec.task.moreRules) out.push(`(${rec.task.moreRules} more in the transcript)`);
1231
+ out.push("</rules_from_user>");
1232
+ }
1233
+ const recent = full ? rec.task.recentUserMessages : rec.task.recentUserMessages.slice(-2);
1234
+ if (recent.length) out.push("", '<recent_user_messages oldest_first="true">', ...recent.map((m) => `- ${safe(m)}`), "</recent_user_messages>");
1235
+ const where = rec.state.whereItStopped === null ? null : full ? rec.state.whereItStopped : clipWords(rec.state.whereItStopped, 60);
1236
+ const todos = rec.state.todos.map((t) => `[${t.status === "in_progress" ? "in progress" : "pending"}] ${safe(t.text)}`);
1237
+ if (where || todos.length) {
1238
+ out.push("", "<where_it_stopped>");
1239
+ if (where) out.push(safe(where));
1240
+ if (todos.length) out.push(`Open todos: ${todos.join("; ")}`);
1241
+ out.push("</where_it_stopped>");
1242
+ }
1243
+ if (full && (rec.files.touched.length || rec.files.uncommitted.length)) {
1244
+ out.push("", "<files>");
1245
+ if (rec.files.touched.length) out.push(`Touched: ${rec.files.touched.map(safe).join(", ")}`);
1246
+ if (rec.files.uncommitted.length) {
1247
+ const list = rec.files.uncommitted.map((f) => `${safe(f.path)} (${f.status}${f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : ""})`);
1248
+ out.push(`Uncommitted: ${list.join(", ")}${rec.files.moreUncommitted ? `, and ${rec.files.moreUncommitted} more` : ""}`);
1249
+ }
1250
+ out.push("</files>");
1101
1251
  }
1102
- const progress = ["## 4. Progress"];
1103
- if (d.progressSource === "none") progress.push("Nothing recorded yet.");
1104
- else {
1105
- if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
1106
- progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
1252
+ if (rec.lastFailure) {
1253
+ out.push("", "<last_failure>", `$ ${safe(rec.lastFailure.cmd)} (exit ${rec.lastFailure.exitCode})`);
1254
+ if (full) out.push(safe(rec.lastFailure.outputTail));
1255
+ out.push("</last_failure>");
1107
1256
  }
1108
- const files = [];
1109
- if (d.git) {
1110
- files.push("## 5. Changed files (uncommitted)");
1111
- if (d.git.files.length === 0) files.push("Working tree is clean.");
1112
- else files.push(...listWithin(d.git.files.map(fileLine), BUDGET.files, `${dir}/diff.patch`), `Full diff: \`${dir}/diff.patch\``);
1113
- } else {
1114
- files.push("## 5. Changed files", "Not a git repository." + (d.editedFiles.length ? " Files the agent edited:" : ""));
1115
- files.push(...listWithin(d.editedFiles.map((f) => `- \`${f}\``), BUDGET.files, transcript));
1257
+ const more = [`${dir}/transcript.md (search it, don't read it all)`];
1258
+ if (rec.details.diff) more.push(`${dir}/diff.patch`);
1259
+ const next = noRecent ? "Continue from where it stopped." : "Continue from the most recent user message.";
1260
+ out.push("", `Only if you need more: ${more.join(" and ")}.`, "</handoff>", "", `${next} Start with the next concrete step.`);
1261
+ return wellFormed(out.join("\n"));
1262
+ }
1263
+ function choosePrompt(rec, fits) {
1264
+ for (const tier of ["full", "compact"]) {
1265
+ const text = renderPrompt(rec, tier);
1266
+ if (text.length <= TIER_MAX[tier] && fits(text)) return { tier, text };
1116
1267
  }
1117
- const decisions = [
1118
- "## 6. Decisions and constraints",
1119
- ...d.decisions.length ? listWithin(d.decisions.map((s) => `- "${oneLine(s, 300)}"`), BUDGET.decisions, transcript) : ["None recorded."]
1120
- ];
1121
- const deadEnds = [
1122
- "## 7. Dead ends (don't retry)",
1123
- ...d.deadEnds.length ? listWithin(d.deadEnds.map((e) => `- \`${oneLine(e.cmd, 160)}\` failed (exit ${e.exitCode})`), BUDGET.deadEnds, transcript) : ["None recorded."]
1124
- ];
1125
- const more = ["## 8. Read more", `- Condensed transcript: \`${transcript}\``];
1126
- if (opts.previousBrief) more.push(`- Previous brief (as last updated by the previous agent): \`${dir}/previous-HANDOFF.md\``);
1127
- if (d.readFirst.length) more.push(`- Read these first: ${d.readFirst.map((f) => `\`${f}\``).join(", ")}`);
1128
- const stop = ["## 9. When you stop", "Before ending your turn, update sections 2\u20134 and `status:` in this file so the next agent can continue."];
1129
- return [front, header, goal, next, blocker, progress, files, decisions, deadEnds, more, stop].map((s) => s.join("\n")).join("\n\n") + "\n";
1268
+ return { tier: "pointer", text: HANDOFF_PROMPT };
1269
+ }
1270
+
1271
+ // ../../packages/core/src/brief/record.ts
1272
+ var RECORD_SCHEMA = "harlay/handoff@2";
1273
+ var MAX_UNCOMMITTED = 15;
1274
+ function buildRecord(d, id, previousId, r) {
1275
+ const a = (s) => r.apply(stripControl(s));
1276
+ const files = d.git?.files ?? [];
1277
+ return {
1278
+ schema: RECORD_SCHEMA,
1279
+ id,
1280
+ createdAt: d.createdAt.toISOString(),
1281
+ from: {
1282
+ tool: d.from.tool,
1283
+ model: d.from.model,
1284
+ session: d.from.session,
1285
+ reason: d.from.reason,
1286
+ resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
1287
+ message: d.from.message === null ? null : a(d.from.message)
1288
+ },
1289
+ repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
1290
+ task: {
1291
+ title: a(d.title),
1292
+ originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
1293
+ recentUserMessages: d.recentUserMessages.map(a),
1294
+ pinnedRules: d.pinnedRules.map((p) => ({ text: a(p.text), message: p.message })),
1295
+ moreRules: d.moreRules
1296
+ },
1297
+ state: {
1298
+ whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
1299
+ todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
1300
+ },
1301
+ files: {
1302
+ touched: d.filesTouched.map(a),
1303
+ uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
1304
+ moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
1305
+ },
1306
+ lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, outputTail: a(d.lastFailure.output).split("\n").slice(-8).join("\n") } : null,
1307
+ details: { transcript: "transcript.md", diff: d.git ? "diff.patch" : null, lastError: d.lastFailure ? "last-error.txt" : null },
1308
+ previous: previousId ? { id: previousId } : null,
1309
+ unknownRecords: d.unknownRecords
1310
+ };
1311
+ }
1312
+ var isStr = (v) => typeof v === "string";
1313
+ var isObj2 = (v) => typeof v === "object" && v !== null;
1314
+ function validateRecord(raw) {
1315
+ if (!isObj2(raw) || raw.schema !== RECORD_SCHEMA || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
1316
+ const { task, from } = raw;
1317
+ if (!isObj2(from) || !isTool(from.tool)) return null;
1318
+ if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
1319
+ if (!Array.isArray(task.recentUserMessages) || !task.recentUserMessages.every(isStr)) return null;
1320
+ if (!Array.isArray(task.pinnedRules) || !task.pinnedRules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
1321
+ return raw;
1130
1322
  }
1131
1323
 
1132
1324
  // ../../packages/core/src/brief/write.ts
1133
- var import_promises5 = require("node:fs/promises");
1134
- var import_node_path6 = require("node:path");
1135
1325
  async function allocateHandoffId(handoffsDir, baseId) {
1136
1326
  for (let i = 1; i < 1e3; i++) {
1137
1327
  const id = i === 1 ? baseId : `${baseId}-${i}`;
1138
1328
  try {
1139
- await (0, import_promises5.access)((0, import_node_path6.join)(handoffsDir, id));
1329
+ await (0, import_promises6.access)((0, import_node_path7.join)(handoffsDir, id));
1140
1330
  } catch {
1141
1331
  return id;
1142
1332
  }
@@ -1145,22 +1335,22 @@ async function allocateHandoffId(handoffsDir, baseId) {
1145
1335
  }
1146
1336
  async function ensureGitExclude(workdir) {
1147
1337
  const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
1148
- const file = (0, import_node_path6.isAbsolute)(p) ? p : (0, import_node_path6.join)(workdir, p);
1149
- let current = "";
1338
+ const file = (0, import_node_path7.isAbsolute)(p) ? p : (0, import_node_path7.join)(workdir, p);
1339
+ let current2 = "";
1150
1340
  try {
1151
- current = await (0, import_promises5.readFile)(file, "utf8");
1341
+ current2 = await (0, import_promises6.readFile)(file, "utf8");
1152
1342
  } catch {
1153
- current = "";
1343
+ current2 = "";
1154
1344
  }
1155
- if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1156
- await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
1157
- await (0, import_promises5.appendFile)(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
1345
+ if (current2.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1346
+ await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(file), { recursive: true });
1347
+ await (0, import_promises6.appendFile)(file, `${current2 && !current2.endsWith("\n") ? "\n" : ""}.harlay/
1158
1348
  `);
1159
1349
  }
1160
1350
  async function readHandoffState(workdir) {
1161
1351
  let raw;
1162
1352
  try {
1163
- raw = JSON.parse(await (0, import_promises5.readFile)((0, import_node_path6.join)(workdir, ".harlay", "state.json"), "utf8"));
1353
+ raw = JSON.parse(await (0, import_promises6.readFile)((0, import_node_path7.join)(workdir, ".harlay", "state.json"), "utf8"));
1164
1354
  } catch {
1165
1355
  return null;
1166
1356
  }
@@ -1172,118 +1362,61 @@ async function readHandoffState(workdir) {
1172
1362
  }
1173
1363
  async function readIfExists(file) {
1174
1364
  try {
1175
- return await (0, import_promises5.readFile)(file, "utf8");
1365
+ return await (0, import_promises6.readFile)(file, "utf8");
1176
1366
  } catch {
1177
1367
  return null;
1178
1368
  }
1179
1369
  }
1180
- async function writeHandoff(d) {
1181
- const harlayDir = (0, import_node_path6.join)(d.workdir, ".harlay");
1182
- const latestPath = (0, import_node_path6.join)(harlayDir, "HANDOFF.md");
1183
- const id = await allocateHandoffId((0, import_node_path6.join)(harlayDir, "handoffs"), d.baseId);
1184
- const dir = (0, import_node_path6.join)(harlayDir, "handoffs", id);
1185
- await (0, import_promises5.mkdir)(dir, { recursive: true });
1186
- const r = new Redactor();
1187
- const previous = await readIfExists(latestPath);
1188
- if (previous !== null) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "previous-HANDOFF.md"), r.apply(previous));
1189
- const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
1190
- const briefPath = (0, import_node_path6.join)(dir, "HANDOFF.md");
1191
- await (0, import_promises5.writeFile)(briefPath, brief);
1192
- await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "transcript.md"), r.apply(d.transcript));
1193
- if (d.git) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "diff.patch"), r.apply(d.git.diff));
1194
- if (d.blocker) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "last-error.txt"), r.apply(d.blocker.output));
1195
- const from = {
1196
- ...d.from,
1197
- message: d.from.message === null ? null : r.apply(d.from.message),
1198
- resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
1199
- };
1200
- const goal = d.goal === null ? null : r.apply(d.goal);
1201
- const title = r.apply(d.title);
1202
- const meta = {
1203
- harlay: 1,
1204
- id,
1205
- createdAt: d.createdAt.toISOString(),
1206
- title,
1207
- goal,
1208
- from,
1209
- workdir: d.workdir,
1210
- unknownRecords: d.unknownRecords,
1211
- git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
1212
- redactions: { ...r.counts }
1213
- };
1214
- await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
1215
- await (0, import_promises5.writeFile)(latestPath, brief);
1216
- const state2 = { harlay: 1, lastId: id, goal, title };
1217
- await (0, import_promises5.writeFile)((0, import_node_path6.join)(harlayDir, "state.json"), JSON.stringify(state2, null, 2) + "\n");
1218
- if (d.git) await ensureGitExclude(d.workdir);
1219
- return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
1220
- }
1221
-
1222
- // ../../packages/core/src/launch/launchers.ts
1223
- var import_node_child_process2 = require("node:child_process");
1224
- var import_promises6 = require("node:fs/promises");
1225
- var import_node_path7 = require("node:path");
1226
- var import_node_util2 = require("node:util");
1227
- var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
1228
- var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
1229
- var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
1230
- var defaultRunner = {
1231
- async open(args) {
1232
- await pexec2("/usr/bin/open", args);
1233
- },
1234
- async writeExecutable(path, content) {
1235
- await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(path), { recursive: true });
1236
- await (0, import_promises6.writeFile)(path, content, { mode: 493 });
1237
- await (0, import_promises6.chmod)(path, 493);
1238
- },
1239
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
1240
- };
1241
- function shq(s) {
1242
- return `'${s.replace(/'/g, `'\\''`)}'`;
1243
- }
1244
- var enc = encodeURIComponent;
1245
- function planLaunch(o) {
1246
- if (o.mode === "app") {
1247
- switch (o.target) {
1248
- case "claude-code":
1249
- return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
1250
- case "codex":
1251
- return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
1252
- case "cursor":
1253
- return {
1254
- target: o.target,
1255
- mode: o.mode,
1256
- steps: [
1257
- { kind: "open", args: ["-a", "Cursor", o.cwd] },
1258
- { kind: "wait", ms: 1500 },
1259
- { kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
1260
- ]
1261
- };
1370
+ async function readPreviousHandoff(workdir) {
1371
+ const raw = await readIfExists((0, import_node_path7.join)(workdir, ".harlay", "handoff.json"));
1372
+ if (raw !== null) {
1373
+ let rec;
1374
+ try {
1375
+ rec = validateRecord(JSON.parse(raw));
1376
+ } catch {
1377
+ return null;
1262
1378
  }
1379
+ if (!rec) return null;
1380
+ const { task } = rec;
1381
+ return {
1382
+ id: rec.id,
1383
+ goal: task.originalGoal,
1384
+ title: task.title,
1385
+ recentUserMessages: task.recentUserMessages,
1386
+ pinnedRules: task.pinnedRules,
1387
+ moreRules: typeof task.moreRules === "number" ? task.moreRules : 0
1388
+ };
1263
1389
  }
1264
- const bin = o.binaries[o.target];
1265
- if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
1266
- const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
1267
- const path = (0, import_node_path7.join)(o.scriptDir, `continue-${o.target}.command`);
1268
- const content = `#!/bin/zsh -l
1269
- cd -- ${shq(o.cwd)} || exit 1
1270
- exec ${argv.map(shq).join(" ")}
1271
- `;
1272
- return {
1273
- target: o.target,
1274
- mode: o.mode,
1275
- steps: [
1276
- { kind: "script", path, content },
1277
- { kind: "open", args: ["-a", o.terminalApp, path] }
1278
- ]
1279
- };
1390
+ const state2 = await readHandoffState(workdir);
1391
+ return state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title, recentUserMessages: [], pinnedRules: [], moreRules: 0 } : null;
1280
1392
  }
1281
- async function executeLaunch(plan, runner = defaultRunner) {
1282
- for (const step of plan.steps) {
1283
- if (step.kind === "open") await runner.open(step.args);
1284
- else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
1285
- else await runner.sleep(step.ms);
1286
- }
1393
+ function addCounts(a, b) {
1394
+ const out = { ...a };
1395
+ for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
1396
+ return out;
1397
+ }
1398
+ async function writeHandoff(d, o) {
1399
+ const harlayDir = (0, import_node_path7.join)(d.workdir, ".harlay");
1400
+ const latestPath = (0, import_node_path7.join)(harlayDir, "HANDOFF.md");
1401
+ const id = await allocateHandoffId((0, import_node_path7.join)(harlayDir, "handoffs"), d.baseId);
1402
+ const dir = (0, import_node_path7.join)(harlayDir, "handoffs", id);
1403
+ await (0, import_promises6.mkdir)(dir, { recursive: true });
1404
+ const r = new Redactor();
1405
+ const body = buildRecord(d, id, o.previousId, r);
1406
+ const chosen = choosePrompt(body, (p) => promptFits(o.target, o.mode, p));
1407
+ const record = { ...body, prompt: { target: o.target, tier: chosen.tier, chars: chosen.text.length } };
1408
+ const json = JSON.stringify(record, null, 2) + "\n";
1409
+ const recordPath = (0, import_node_path7.join)(dir, "handoff.json");
1410
+ await (0, import_promises6.writeFile)(recordPath, json);
1411
+ await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "prompt.md"), chosen.text + "\n");
1412
+ await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "transcript.md"), r.apply(stripControl(d.transcript)));
1413
+ if (d.git) await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "diff.patch"), r.apply(d.git.diff));
1414
+ if (d.lastFailure) await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "last-error.txt"), r.apply(stripControl(d.lastFailure.output)));
1415
+ await (0, import_promises6.writeFile)(latestPath, renderPrompt(body, "full") + "\n");
1416
+ await (0, import_promises6.writeFile)((0, import_node_path7.join)(harlayDir, "handoff.json"), json);
1417
+ if (d.git) await ensureGitExclude(d.workdir);
1418
+ const redactions = addCounts(d.redactions ?? {}, r.counts);
1419
+ return { id, dir, recordPath, latestPath, prompt: chosen.text, tier: chosen.tier, redactions };
1287
1420
  }
1288
1421
 
1289
1422
  // ../../packages/core/src/launch/resolve.ts
@@ -1385,10 +1518,9 @@ async function runHandoff(o) {
1385
1518
  }
1386
1519
  const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
1387
1520
  const git2 = await takeGitSnapshot(cwd);
1388
- const state2 = await readHandoffState(git2?.root ?? cwd);
1389
- const previous = state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title } : null;
1521
+ const previous = await readPreviousHandoff(git2?.root ?? cwd);
1390
1522
  const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
1391
- const written = await writeHandoff(data);
1523
+ const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom });
1392
1524
  if (o.launch === false) return { written, plan: null, data };
1393
1525
  let plan;
1394
1526
  try {
@@ -1397,7 +1529,7 @@ async function runHandoff(o) {
1397
1529
  target: o.target,
1398
1530
  mode: o.mode,
1399
1531
  cwd: data.workdir,
1400
- prompt: HANDOFF_PROMPT,
1532
+ prompt: written.prompt,
1401
1533
  scriptDir: written.dir,
1402
1534
  binaries,
1403
1535
  terminalApp: o.terminalApp ?? "Terminal"
@@ -1591,12 +1723,535 @@ var Watcher = class extends import_node_events.EventEmitter {
1591
1723
  }
1592
1724
  };
1593
1725
 
1594
- // src/menu.ts
1595
- var import_node_path11 = require("node:path");
1726
+ // ../../packages/core/src/usage/types.ts
1727
+ var WINDOW_MINUTES = { five_hour: 300, weekly: 10080 };
1728
+ function windowKind(minutes) {
1729
+ if (minutes === 300) return "five_hour";
1730
+ if (minutes === 10080) return "weekly";
1731
+ return "other";
1732
+ }
1733
+ function windowLabel(kind, minutes) {
1734
+ if (kind === "five_hour") return "5h";
1735
+ if (kind === "weekly") return "Week";
1736
+ if (!minutes) return "Limit";
1737
+ return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
1738
+ }
1739
+ function fullestWindow(s) {
1740
+ let best = null;
1741
+ for (const w of s.windows) if (!best || w.usedPercent > best.usedPercent) best = w;
1742
+ return best;
1743
+ }
1596
1744
 
1597
- // src/settings.ts
1745
+ // ../../packages/core/src/usage/codex.ts
1746
+ var import_node_child_process3 = require("node:child_process");
1598
1747
  var import_promises10 = require("node:fs/promises");
1599
1748
  var import_node_path10 = require("node:path");
1749
+ var PACE_MS = 60 * 6e4;
1750
+ var TAIL_BYTES = 1024 * 1024;
1751
+ function num(x) {
1752
+ return typeof x === "number" && Number.isFinite(x) ? x : null;
1753
+ }
1754
+ function codexWindows(rl) {
1755
+ const out = [];
1756
+ for (const key of ["primary", "secondary"]) {
1757
+ const w = rl[key];
1758
+ if (!isObj(w)) continue;
1759
+ const pct = num(w.used_percent) ?? num(w.usedPercent);
1760
+ if (pct === null) continue;
1761
+ const minutes = num(w.window_minutes) ?? num(w.windowDurationMins);
1762
+ const resets = num(w.resets_at) ?? num(w.resetsAt);
1763
+ const kind = windowKind(minutes);
1764
+ out.push({ kind, label: windowLabel(kind, minutes), usedPercent: pct, resetsAt: resets === null ? null : new Date(resets * 1e3), exact: true });
1765
+ }
1766
+ return out;
1767
+ }
1768
+ function planOf(rl) {
1769
+ const p = rl.plan_type ?? rl.planType;
1770
+ return typeof p === "string" ? p : null;
1771
+ }
1772
+ async function readTail(file, bytes) {
1773
+ const fh = await (0, import_promises10.open)(file, "r");
1774
+ try {
1775
+ const { size } = await fh.stat();
1776
+ const start = Math.max(0, size - bytes);
1777
+ const buf = Buffer.alloc(size - start);
1778
+ await fh.read(buf, 0, buf.length, start);
1779
+ const text = buf.toString("utf8");
1780
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
1781
+ } finally {
1782
+ await fh.close();
1783
+ }
1784
+ }
1785
+ async function readCodexReadings(home2, files = 8) {
1786
+ const logs = (await listSessionFiles((0, import_node_path10.join)(home2, ".codex", "sessions"), (f) => f.endsWith(".jsonl"))).slice(0, files);
1787
+ const out = [];
1788
+ for (const { path } of logs) {
1789
+ let text;
1790
+ try {
1791
+ text = await readTail(path, TAIL_BYTES);
1792
+ } catch {
1793
+ continue;
1794
+ }
1795
+ for (const line of text.split("\n")) {
1796
+ if (!line.includes('"rate_limits":{')) continue;
1797
+ try {
1798
+ const rec = JSON.parse(line);
1799
+ const p = isObj(rec.payload) ? rec.payload : null;
1800
+ const rl = p && isObj(p.rate_limits) ? p.rate_limits : null;
1801
+ const at = typeof rec.timestamp === "string" ? new Date(rec.timestamp) : null;
1802
+ if (!rl || !at || Number.isNaN(at.getTime())) continue;
1803
+ const windows = codexWindows(rl);
1804
+ if (windows.length) out.push({ at, windows, plan: planOf(rl), resetCredits: null });
1805
+ } catch {
1806
+ }
1807
+ }
1808
+ }
1809
+ return out.sort((a, b) => a.at.getTime() - b.at.getTime());
1810
+ }
1811
+ function minutesToFull(points, now) {
1812
+ const recent = points.filter((p) => now.getTime() - p.at.getTime() <= PACE_MS);
1813
+ if (recent.length < 2) return null;
1814
+ const first = recent[0];
1815
+ const last = recent[recent.length - 1];
1816
+ const minutes = (last.at.getTime() - first.at.getTime()) / 6e4;
1817
+ const rise = last.pct - first.pct;
1818
+ if (minutes < 5 || rise <= 0) return null;
1819
+ return Math.max(0, (100 - last.pct) / (rise / minutes));
1820
+ }
1821
+ function current(w, now) {
1822
+ return w.resetsAt && w.resetsAt.getTime() <= now.getTime() ? { ...w, usedPercent: 0, resetsAt: null } : w;
1823
+ }
1824
+ function codexSnapshot(readings, now, query = null) {
1825
+ const all = query ? [...readings, query].sort((a, b) => a.at.getTime() - b.at.getTime()) : readings;
1826
+ const latest = all[all.length - 1];
1827
+ if (!latest) return null;
1828
+ const windows = latest.windows.map((w) => current(w, now));
1829
+ const fullest = windows.reduce((b, w) => !b || w.usedPercent > b.usedPercent ? w : b, null);
1830
+ let minutesToLimit = null;
1831
+ if (fullest && fullest.usedPercent > 0) {
1832
+ const series = all.flatMap((r) => r.windows.filter((w) => w.kind === fullest.kind).map((w) => ({ at: r.at, pct: w.usedPercent })));
1833
+ minutesToLimit = minutesToFull(series, now);
1834
+ if (minutesToLimit !== null && fullest.resetsAt && now.getTime() + minutesToLimit * 6e4 >= fullest.resetsAt.getTime()) minutesToLimit = null;
1835
+ }
1836
+ return {
1837
+ tool: "codex",
1838
+ windows,
1839
+ observedAt: latest.at,
1840
+ minutesToLimit,
1841
+ plan: latest.plan ?? query?.plan ?? null,
1842
+ resetCredits: query?.resetCredits ?? null
1843
+ };
1844
+ }
1845
+ function queryCodexUsage(codexBin, timeoutMs = 15e3) {
1846
+ return new Promise((resolve) => {
1847
+ let done = false;
1848
+ let buf = "";
1849
+ const child = (0, import_node_child_process3.spawn)(codexBin, ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
1850
+ const finish = (r) => {
1851
+ if (done) return;
1852
+ done = true;
1853
+ clearTimeout(timer);
1854
+ child.kill();
1855
+ resolve(r);
1856
+ };
1857
+ const timer = setTimeout(() => finish(null), timeoutMs);
1858
+ child.on("error", () => finish(null));
1859
+ child.stdin.on("error", () => finish(null));
1860
+ child.on("exit", () => finish(null));
1861
+ child.stdout.setEncoding("utf8");
1862
+ child.stdout.on("data", (chunk) => {
1863
+ buf += chunk;
1864
+ let nl;
1865
+ while ((nl = buf.indexOf("\n")) !== -1) {
1866
+ const line = buf.slice(0, nl);
1867
+ buf = buf.slice(nl + 1);
1868
+ let msg;
1869
+ try {
1870
+ msg = JSON.parse(line);
1871
+ } catch {
1872
+ continue;
1873
+ }
1874
+ if (msg.id !== 2) continue;
1875
+ const res = isObj(msg.result) ? msg.result : null;
1876
+ const rl = res && isObj(res.rateLimits) ? res.rateLimits : null;
1877
+ if (!res || !rl) return finish(null);
1878
+ const credits = isObj(res.rateLimitResetCredits) ? num(res.rateLimitResetCredits.availableCount) : null;
1879
+ finish({ at: /* @__PURE__ */ new Date(), windows: codexWindows(rl), plan: planOf(rl), resetCredits: credits });
1880
+ }
1881
+ });
1882
+ const send = (o) => child.stdin.write(JSON.stringify(o) + "\n");
1883
+ send({ method: "initialize", id: 1, params: { clientInfo: { name: "harlay", version: "1" } } });
1884
+ send({ method: "initialized" });
1885
+ send({ method: "account/rateLimits/read", id: 2 });
1886
+ });
1887
+ }
1888
+
1889
+ // ../../packages/core/src/usage/claude.ts
1890
+ var import_promises11 = require("node:fs/promises");
1891
+ var import_node_path11 = require("node:path");
1892
+ var HOUR = 36e5;
1893
+ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
1894
+ var DISPROVE_FACTOR = 1.15;
1895
+ var MIN_CALIBRATION_PCT = 5;
1896
+ var PACE_MS2 = 30 * 6e4;
1897
+ function replyUnits(usage2, model) {
1898
+ const n = (k) => typeof usage2[k] === "number" ? usage2[k] : 0;
1899
+ const tokens = n("input_tokens") + 1.25 * n("cache_creation_input_tokens") + 0.1 * n("cache_read_input_tokens") + 5 * n("output_tokens");
1900
+ const tier = /opus/i.test(model) ? 5 : /haiku/i.test(model) ? 1 : 3;
1901
+ return tokens * tier;
1902
+ }
1903
+ function limitKind(t) {
1904
+ if (t === "five_hour") return "five_hour";
1905
+ if (t === "seven_day" || t === "weekly") return "weekly";
1906
+ return null;
1907
+ }
1908
+ var ClaudeUsageLog = class {
1909
+ constructor(home2) {
1910
+ this.home = home2;
1911
+ }
1912
+ home;
1913
+ files = /* @__PURE__ */ new Map();
1914
+ replies = /* @__PURE__ */ new Map();
1915
+ hits = /* @__PURE__ */ new Map();
1916
+ async refresh(now = /* @__PURE__ */ new Date(), maxAgeMs = 8 * 24 * HOUR) {
1917
+ const root = (0, import_node_path11.join)(this.home, ".claude", "projects");
1918
+ let names;
1919
+ try {
1920
+ names = await (0, import_promises11.readdir)(root, { recursive: true });
1921
+ } catch {
1922
+ return;
1923
+ }
1924
+ for (const name of names) {
1925
+ if (!name.endsWith(".jsonl")) continue;
1926
+ const path = (0, import_node_path11.join)(root, name);
1927
+ try {
1928
+ const s = await (0, import_promises11.stat)(path);
1929
+ if (now.getTime() - s.mtimeMs > maxAgeMs) continue;
1930
+ await this.readFrom(path, s.size);
1931
+ } catch {
1932
+ }
1933
+ }
1934
+ const cutoff = now.getTime() - maxAgeMs;
1935
+ for (const [k, e] of this.replies) if (e.at < cutoff) this.replies.delete(k);
1936
+ for (const [k, h] of this.hits) if (h.at < cutoff - SAMPLE_MAX_AGE_MS) this.hits.delete(k);
1937
+ }
1938
+ async readFrom(path, size) {
1939
+ const st = this.files.get(path) ?? { offset: 0, partial: "" };
1940
+ if (size < st.offset) Object.assign(st, { offset: 0, partial: "" });
1941
+ if (size === st.offset) return;
1942
+ const fh = await (0, import_promises11.open)(path, "r");
1943
+ try {
1944
+ const buf = Buffer.alloc(size - st.offset);
1945
+ await fh.read(buf, 0, buf.length, st.offset);
1946
+ st.offset = size;
1947
+ const text = st.partial + buf.toString("utf8");
1948
+ const lines = text.split("\n");
1949
+ st.partial = lines.pop() ?? "";
1950
+ for (const line of lines) this.ingest(line);
1951
+ } finally {
1952
+ await fh.close();
1953
+ this.files.set(path, st);
1954
+ }
1955
+ }
1956
+ ingest(line) {
1957
+ if (!line.includes('"assistant"')) return;
1958
+ let r;
1959
+ try {
1960
+ r = JSON.parse(line);
1961
+ } catch {
1962
+ return;
1963
+ }
1964
+ if (r.type !== "assistant" || typeof r.timestamp !== "string") return;
1965
+ const at = Date.parse(r.timestamp);
1966
+ if (Number.isNaN(at)) return;
1967
+ if (r.isApiErrorMessage === true) {
1968
+ const q = isObj(r.quotaLimits) ? r.quotaLimits : null;
1969
+ const kind = q ? limitKind(q.rateLimitType) : null;
1970
+ if (q && kind && q.status === "rejected" && typeof q.resetsAt === "number") {
1971
+ this.hits.set(`${kind}|${q.resetsAt}`, { at, kind, resetsAt: q.resetsAt * 1e3 });
1972
+ }
1973
+ return;
1974
+ }
1975
+ const m = isObj(r.message) ? r.message : null;
1976
+ const usage2 = m && isObj(m.usage) ? m.usage : null;
1977
+ const model = m && typeof m.model === "string" ? m.model : "";
1978
+ if (!m || !usage2 || model === "<synthetic>") return;
1979
+ const key = `${String(m.id)}|${String(r.requestId)}`;
1980
+ this.replies.set(key, { at, units: replyUnits(usage2, model) });
1981
+ }
1982
+ entries() {
1983
+ return [...this.replies.values()].sort((a, b) => a.at - b.at);
1984
+ }
1985
+ limitHits() {
1986
+ return [...this.hits.values()].sort((a, b) => a.at - b.at);
1987
+ }
1988
+ };
1989
+ function parseClaudeStatusline(json, at) {
1990
+ if (!isObj(json) || !isObj(json.rate_limits)) return null;
1991
+ const rl = json.rate_limits;
1992
+ const windows = [];
1993
+ for (const [key, kind] of [["five_hour", "five_hour"], ["seven_day", "weekly"]]) {
1994
+ const w = rl[key];
1995
+ if (!isObj(w) || typeof w.used_percentage !== "number" || typeof w.resets_at !== "number") continue;
1996
+ windows.push({ kind, usedPercent: w.used_percentage, resetsAt: w.resets_at * 1e3 });
1997
+ }
1998
+ return windows.length ? { at, windows } : null;
1999
+ }
2000
+ var prefixCache = /* @__PURE__ */ new WeakMap();
2001
+ function prefixOf(entries) {
2002
+ let p = prefixCache.get(entries);
2003
+ if (!p) {
2004
+ const at = [];
2005
+ const sum = [0];
2006
+ for (const e of entries) {
2007
+ at.push(e.at);
2008
+ sum.push(sum[sum.length - 1] + e.units);
2009
+ }
2010
+ p = { at, sum };
2011
+ prefixCache.set(entries, p);
2012
+ }
2013
+ return p;
2014
+ }
2015
+ function bound(at, t, after) {
2016
+ let lo = 0;
2017
+ let hi = at.length;
2018
+ while (lo < hi) {
2019
+ const mid = lo + hi >> 1;
2020
+ if (after ? at[mid] <= t : at[mid] < t) lo = mid + 1;
2021
+ else hi = mid;
2022
+ }
2023
+ return lo;
2024
+ }
2025
+ function sumUnits(entries, from, to) {
2026
+ if (to < from) return 0;
2027
+ const p = prefixOf(entries);
2028
+ return p.sum[bound(p.at, to, true)] - p.sum[bound(p.at, from, false)];
2029
+ }
2030
+ function windowMs(kind) {
2031
+ return kind === "weekly" ? WINDOW_MINUTES.weekly * 6e4 : WINDOW_MINUTES.five_hour * 6e4;
2032
+ }
2033
+ function capacitySamples(entries, hits, exact) {
2034
+ const out = [];
2035
+ for (const h of hits) {
2036
+ const units = sumUnits(entries, h.resetsAt - windowMs(h.kind), h.at);
2037
+ if (units > 0) out.push({ kind: h.kind, capacity: units, at: h.at, source: "limit" });
2038
+ }
2039
+ for (const w of exact?.windows ?? []) {
2040
+ if (w.usedPercent < MIN_CALIBRATION_PCT || !exact) continue;
2041
+ const units = sumUnits(entries, w.resetsAt - windowMs(w.kind), exact.at);
2042
+ if (units > 0) out.push({ kind: w.kind, capacity: units / (w.usedPercent / 100), at: exact.at, source: "exact" });
2043
+ }
2044
+ return out;
2045
+ }
2046
+ function mergeSamples(stored, fresh, now) {
2047
+ const byKey = /* @__PURE__ */ new Map();
2048
+ for (const s of [...stored, ...fresh]) byKey.set(`${s.kind}|${s.source}|${Math.round(s.at / 6e4)}`, s);
2049
+ const kept = [...byKey.values()].filter((s) => now.getTime() - s.at <= SAMPLE_MAX_AGE_MS).sort((a, b) => b.at - a.at);
2050
+ const perKind = /* @__PURE__ */ new Map();
2051
+ return kept.filter((s) => {
2052
+ const n = (perKind.get(s.kind) ?? 0) + 1;
2053
+ perKind.set(s.kind, n);
2054
+ return n <= 20;
2055
+ });
2056
+ }
2057
+ function pickCapacity(kind, samples, entries, hits, now) {
2058
+ const len = windowMs(kind);
2059
+ for (const s of samples.filter((x) => x.kind === kind).sort((a, b) => b.at - a.at)) {
2060
+ let disproved = false;
2061
+ for (let start = s.at; start < now.getTime() && !disproved; start += len / 5) {
2062
+ const end = Math.min(start + len, now.getTime());
2063
+ const hitInside = hits.some((h) => h.kind === kind && h.at > start && h.at <= end);
2064
+ if (!hitInside && sumUnits(entries, start, end) > s.capacity * DISPROVE_FACTOR) disproved = true;
2065
+ }
2066
+ if (!disproved) return s.capacity;
2067
+ }
2068
+ return null;
2069
+ }
2070
+ function currentFiveHourStart(entries, now) {
2071
+ const len = windowMs("five_hour");
2072
+ let start = null;
2073
+ for (const e of entries) {
2074
+ if (e.at > now.getTime()) break;
2075
+ if (start === null || e.at >= start + len) start = Math.floor(e.at / HOUR) * HOUR;
2076
+ }
2077
+ return start !== null && now.getTime() < start + len ? start : null;
2078
+ }
2079
+ function claudeSnapshot({ entries, hits, exact, samples, now }) {
2080
+ const t = now.getTime();
2081
+ const windows = [];
2082
+ let minutesToLimit = null;
2083
+ let observedAt = 0;
2084
+ for (const kind of ["five_hour", "weekly"]) {
2085
+ const len = windowMs(kind);
2086
+ const cap = pickCapacity(kind, samples, entries, hits, now);
2087
+ const ex = exact?.windows.find((w) => w.kind === kind && w.resetsAt > t);
2088
+ const hit = [...hits].reverse().find((h) => h.kind === kind && h.resetsAt > t);
2089
+ let start;
2090
+ let resetsAt;
2091
+ let pct = null;
2092
+ let isExact = false;
2093
+ if (ex && exact) {
2094
+ start = ex.resetsAt - len;
2095
+ resetsAt = ex.resetsAt;
2096
+ const since = sumUnits(entries, exact.at + 1, t);
2097
+ if (since === 0) {
2098
+ pct = ex.usedPercent;
2099
+ isExact = true;
2100
+ } else if (cap) {
2101
+ pct = ex.usedPercent + since / cap * 100;
2102
+ } else {
2103
+ pct = ex.usedPercent;
2104
+ }
2105
+ observedAt = Math.max(observedAt, exact.at);
2106
+ } else if (hit) {
2107
+ start = hit.resetsAt - len;
2108
+ resetsAt = hit.resetsAt;
2109
+ pct = 100;
2110
+ isExact = true;
2111
+ observedAt = Math.max(observedAt, hit.at);
2112
+ } else if (kind === "five_hour") {
2113
+ start = currentFiveHourStart(entries, now);
2114
+ resetsAt = start === null ? null : start + len;
2115
+ if (start === null) {
2116
+ if (cap) {
2117
+ windows.push({ kind, label: "5h", usedPercent: 0, resetsAt: null, exact: false });
2118
+ }
2119
+ continue;
2120
+ }
2121
+ if (cap) pct = sumUnits(entries, start, t) / cap * 100;
2122
+ } else {
2123
+ continue;
2124
+ }
2125
+ if (pct === null) continue;
2126
+ pct = Math.min(100, Math.max(0, pct));
2127
+ windows.push({ kind, label: kind === "five_hour" ? "5h" : "Week", usedPercent: pct, resetsAt: resetsAt === null ? null : new Date(resetsAt), exact: isExact });
2128
+ if (cap && pct < 100) {
2129
+ const recent = sumUnits(entries, t - PACE_MS2, t);
2130
+ if (recent > 0) {
2131
+ const perMinute = recent / (PACE_MS2 / 6e4);
2132
+ const minutes = cap * (1 - pct / 100) / perMinute;
2133
+ const fitsBeforeReset = resetsAt === null || t + minutes * 6e4 < resetsAt;
2134
+ if (fitsBeforeReset && (minutesToLimit === null || minutes < minutesToLimit)) minutesToLimit = minutes;
2135
+ }
2136
+ }
2137
+ }
2138
+ const last = entries[entries.length - 1];
2139
+ if (last) observedAt = Math.max(observedAt, last.at);
2140
+ if (!windows.length) return null;
2141
+ return { tool: "claude-code", windows, observedAt: new Date(observedAt || t), minutesToLimit, plan: null, resetCredits: null };
2142
+ }
2143
+
2144
+ // ../../packages/core/src/usage/bridge.ts
2145
+ var import_promises12 = require("node:fs/promises");
2146
+ var import_node_path12 = require("node:path");
2147
+ var BRIDGE_MARKER = "# harlay-usage";
2148
+ function claudeSettingsPath(home2) {
2149
+ return (0, import_node_path12.join)(home2, ".claude", "settings.json");
2150
+ }
2151
+ function claudeStatusFile(dataDir2) {
2152
+ return (0, import_node_path12.join)(dataDir2, "claude-status.json");
2153
+ }
2154
+ var scriptPath = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.sh");
2155
+ var previousJson = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.previous.json");
2156
+ var previousCommand = (dataDir2) => (0, import_node_path12.join)(dataDir2, "claude-statusline.previous");
2157
+ var BRIDGE_SCRIPT = `#!/bin/sh
2158
+ # Harlay's Claude Code status line. Saves Claude Code's status line input (it includes your exact usage)
2159
+ # for Harlay, then shows your previous status line, or a short usage summary if you had none.
2160
+ # Turn it off in Harlay: Settings > Usage > Exact Claude usage.
2161
+ dir=$(dirname "$0")
2162
+ input=$(cat)
2163
+ printf '%s' "$input" > "$dir/claude-status.json.tmp" && mv -f "$dir/claude-status.json.tmp" "$dir/claude-status.json"
2164
+ if [ -s "$dir/claude-statusline.previous" ]; then
2165
+ printf '%s' "$input" | "\${SHELL:-/bin/sh}" -c "$(cat "$dir/claude-statusline.previous")"
2166
+ exit $?
2167
+ fi
2168
+ pct() {
2169
+ printf '%s' "$input" | tr -d '\\n' | sed -n "s/.*\\"$1\\"[^}]*\\"used_percentage\\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p"
2170
+ }
2171
+ five=$(pct five_hour)
2172
+ week=$(pct seven_day)
2173
+ out=""
2174
+ [ -n "$five" ] && out="5h $five%"
2175
+ [ -n "$week" ] && out="\${out:+$out \xB7 }wk $week%"
2176
+ printf '%s' "$out"
2177
+ `;
2178
+ function bridgeCommand(dataDir2) {
2179
+ const q = `'${scriptPath(dataDir2).replace(/'/g, `'\\''`)}'`;
2180
+ return `${q} ${BRIDGE_MARKER}`;
2181
+ }
2182
+ function isObj3(x) {
2183
+ return typeof x === "object" && x !== null && !Array.isArray(x);
2184
+ }
2185
+ function isOurs(statusLine) {
2186
+ return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
2187
+ }
2188
+ async function readSettings(path) {
2189
+ let text;
2190
+ try {
2191
+ text = await (0, import_promises12.readFile)(path, "utf8");
2192
+ } catch (e) {
2193
+ if (e.code === "ENOENT") return {};
2194
+ throw e;
2195
+ }
2196
+ const parsed = JSON.parse(text);
2197
+ if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
2198
+ return parsed;
2199
+ }
2200
+ async function writeAtomic(path, text) {
2201
+ const tmp = `${path}.harlay-tmp`;
2202
+ await (0, import_promises12.writeFile)(tmp, text);
2203
+ await (0, import_promises12.rename)(tmp, path);
2204
+ }
2205
+ async function bridgeState(home2) {
2206
+ try {
2207
+ return isOurs((await readSettings(claudeSettingsPath(home2))).statusLine) ? "on" : "off";
2208
+ } catch {
2209
+ return "unavailable";
2210
+ }
2211
+ }
2212
+ async function installBridge(home2, dataDir2) {
2213
+ const path = claudeSettingsPath(home2);
2214
+ const s = await readSettings(path);
2215
+ await (0, import_promises12.mkdir)(dataDir2, { recursive: true });
2216
+ await (0, import_promises12.writeFile)(scriptPath(dataDir2), BRIDGE_SCRIPT);
2217
+ await (0, import_promises12.chmod)(scriptPath(dataDir2), 493);
2218
+ if (isOurs(s.statusLine)) return;
2219
+ const prev = s.statusLine;
2220
+ if (prev !== void 0) {
2221
+ await writeAtomic(previousJson(dataDir2), JSON.stringify(prev, null, 2) + "\n");
2222
+ const cmd = isObj3(prev) && typeof prev.command === "string" ? prev.command : "";
2223
+ await writeAtomic(previousCommand(dataDir2), cmd);
2224
+ } else {
2225
+ await (0, import_promises12.rm)(previousJson(dataDir2), { force: true });
2226
+ await (0, import_promises12.rm)(previousCommand(dataDir2), { force: true });
2227
+ }
2228
+ s.statusLine = { ...isObj3(prev) ? prev : {}, type: "command", command: bridgeCommand(dataDir2) };
2229
+ await (0, import_promises12.mkdir)((0, import_node_path12.join)(home2, ".claude"), { recursive: true });
2230
+ await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2231
+ }
2232
+ async function removeBridge(home2, dataDir2) {
2233
+ const path = claudeSettingsPath(home2);
2234
+ const s = await readSettings(path);
2235
+ if (!isOurs(s.statusLine)) return;
2236
+ let prev;
2237
+ try {
2238
+ prev = JSON.parse(await (0, import_promises12.readFile)(previousJson(dataDir2), "utf8"));
2239
+ } catch {
2240
+ prev = void 0;
2241
+ }
2242
+ if (prev === void 0) delete s.statusLine;
2243
+ else s.statusLine = prev;
2244
+ await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2245
+ await (0, import_promises12.rm)(previousJson(dataDir2), { force: true });
2246
+ await (0, import_promises12.rm)(previousCommand(dataDir2), { force: true });
2247
+ }
2248
+
2249
+ // src/menu.ts
2250
+ var import_node_path14 = require("node:path");
2251
+
2252
+ // src/settings.ts
2253
+ var import_promises13 = require("node:fs/promises");
2254
+ var import_node_path13 = require("node:path");
1600
2255
  var THEMES = ["system", "light", "dark"];
1601
2256
  var isTheme = (x) => THEMES.includes(x);
1602
2257
  var DEFAULT_SETTINGS = {
@@ -1606,12 +2261,13 @@ var DEFAULT_SETTINGS = {
1606
2261
  usageThreshold: 90,
1607
2262
  terminalApp: "Terminal",
1608
2263
  openAtLogin: true,
1609
- theme: "system"
2264
+ theme: "system",
2265
+ claudeBridgeAsked: false
1610
2266
  };
1611
2267
  async function loadSettings(file) {
1612
2268
  let raw = {};
1613
2269
  try {
1614
- raw = JSON.parse(await (0, import_promises10.readFile)(file, "utf8"));
2270
+ raw = JSON.parse(await (0, import_promises13.readFile)(file, "utf8"));
1615
2271
  } catch {
1616
2272
  raw = {};
1617
2273
  }
@@ -1621,6 +2277,7 @@ async function loadSettings(file) {
1621
2277
  if (typeof r.autoContinue === "boolean") s.autoContinue = r.autoContinue;
1622
2278
  if (typeof r.openAtLogin === "boolean") s.openAtLogin = r.openAtLogin;
1623
2279
  if (isTheme(r.theme)) s.theme = r.theme;
2280
+ if (typeof r.claudeBridgeAsked === "boolean") s.claudeBridgeAsked = r.claudeBridgeAsked;
1624
2281
  if (typeof r.terminalApp === "string" && r.terminalApp.trim()) s.terminalApp = r.terminalApp.trim();
1625
2282
  if (typeof r.usageThreshold === "number" && r.usageThreshold > 0 && r.usageThreshold <= 100) s.usageThreshold = r.usageThreshold;
1626
2283
  if (typeof r.modes === "object" && r.modes !== null) {
@@ -1632,12 +2289,12 @@ async function loadSettings(file) {
1632
2289
  return s;
1633
2290
  }
1634
2291
  async function saveSettings(file, s) {
1635
- await (0, import_promises10.mkdir)((0, import_node_path10.dirname)(file), { recursive: true });
1636
- await (0, import_promises10.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
2292
+ await (0, import_promises13.mkdir)((0, import_node_path13.dirname)(file), { recursive: true });
2293
+ await (0, import_promises13.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
1637
2294
  }
1638
2295
 
1639
2296
  // src/state.ts
1640
- var import_promises11 = require("node:fs/promises");
2297
+ var import_promises14 = require("node:fs/promises");
1641
2298
  var DEFAULT_LIMIT_MS = 60 * 6e4;
1642
2299
  var sessionKey = (ref) => `${ref.tool}:${ref.id}`;
1643
2300
  function createState() {
@@ -1702,7 +2359,7 @@ function latestSession(state2) {
1702
2359
  async function existingBrief(t) {
1703
2360
  if (!t?.briefPath) return null;
1704
2361
  try {
1705
- await (0, import_promises11.access)(t.briefPath);
2362
+ await (0, import_promises14.access)(t.briefPath);
1706
2363
  return t.briefPath;
1707
2364
  } catch {
1708
2365
  return null;
@@ -1715,7 +2372,7 @@ var DOT = { limit: "\u25CF", warning: "\u25D0", idle: "\u25CB", handed_off: "\u2
1715
2372
  var MODES = ["app", "terminal"];
1716
2373
  var THEME_LABEL = { system: "System", light: "Light", dark: "Dark" };
1717
2374
  function sessionTitle(t) {
1718
- return t.ref.title ?? (t.ref.cwd ? (0, import_node_path11.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
2375
+ return t.ref.title ?? (t.ref.cwd ? (0, import_node_path14.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
1719
2376
  }
1720
2377
  function buildMenuModel(sessions, settings2, available2) {
1721
2378
  const nodes = [
@@ -1775,14 +2432,14 @@ function buildMenuModel(sessions, settings2, available2) {
1775
2432
  }
1776
2433
 
1777
2434
  // src/notifications.ts
1778
- var import_node_path12 = require("node:path");
2435
+ var import_node_path15 = require("node:path");
1779
2436
  function haltNotificationBody(t, first) {
1780
- const what = t.ref.cwd ? `${t.detail} \xB7 ${(0, import_node_path12.basename)(t.ref.cwd)}` : t.detail;
2437
+ const what = t.ref.cwd ? `${t.detail} \xB7 ${(0, import_node_path15.basename)(t.ref.cwd)}` : t.detail;
1781
2438
  return first ? `${what}. Click to continue in ${TOOL_LABEL[first]}.` : `${what}. No other agent is available.`;
1782
2439
  }
1783
2440
 
1784
2441
  // src/popover.ts
1785
- var import_node_path13 = require("node:path");
2442
+ var import_node_path16 = require("node:path");
1786
2443
  var POPOVER_WIDTH = 392;
1787
2444
  function timeAgo(d, now) {
1788
2445
  const s = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 1e3));
@@ -1793,7 +2450,44 @@ function timeAgo(d, now) {
1793
2450
  if (h < 24) return `${h}h ago`;
1794
2451
  return `${Math.floor(h / 24)}d ago`;
1795
2452
  }
1796
- function buildPopoverModel(sessions, settings2, available2, busy, now) {
2453
+ function resetsText(d, now) {
2454
+ if (!d) return null;
2455
+ const ms = d.getTime() - now.getTime();
2456
+ if (ms <= 0) return null;
2457
+ if (ms < 36e5) return `resets in ${Math.max(1, Math.round(ms / 6e4))}m`;
2458
+ const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
2459
+ return ms < 20 * 36e5 ? `resets ${time}` : `resets ${d.toLocaleDateString("en-US", { weekday: "short" })} ${time}`;
2460
+ }
2461
+ function paceText(minutes) {
2462
+ if (minutes === null) return null;
2463
+ const m = Math.max(1, Math.round(minutes));
2464
+ return m < 90 ? `\u2248 ${m} min to limit at this pace` : `\u2248 ${Math.round(m / 60)}h to limit at this pace`;
2465
+ }
2466
+ function buildUsageRows(snapshots, available2, bridge, now) {
2467
+ const rows = [];
2468
+ for (const tool of TOOLS) {
2469
+ const snap = snapshots[tool];
2470
+ const installed = available2[tool].app || available2[tool].terminal;
2471
+ if (!snap && !installed) continue;
2472
+ const row = { tool, label: TOOL_LABEL[tool], windows: [], pace: null, note: null, usagePage: false };
2473
+ if (snap) {
2474
+ row.windows = snap.windows.map((w) => ({ label: w.label, pct: Math.round(w.usedPercent), exact: w.exact, resets: resetsText(w.resetsAt, now) }));
2475
+ row.pace = paceText(snap.minutesToLimit);
2476
+ if (snap.resetCredits) row.note = `${snap.resetCredits} free limit reset${snap.resetCredits === 1 ? "" : "s"} available`;
2477
+ else if (tool === "claude-code" && snap.windows.some((w) => !w.exact)) row.note = "\u2248 estimated from your session logs";
2478
+ } else if (tool === "claude-code") {
2479
+ row.note = bridge === "on" ? "Updates after your next Claude Code reply in Terminal" : "Not measured yet. Turn on Exact Claude usage in Settings, or Harlay learns it at the next Claude limit.";
2480
+ } else if (tool === "codex") {
2481
+ row.note = "Updates after your next Codex turn";
2482
+ } else {
2483
+ row.note = "Cursor keeps no usage on this Mac";
2484
+ row.usagePage = true;
2485
+ }
2486
+ rows.push(row);
2487
+ }
2488
+ return rows;
2489
+ }
2490
+ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false) {
1797
2491
  const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
1798
2492
  return {
1799
2493
  sessions: sessions.map((t) => {
@@ -1805,13 +2499,15 @@ function buildPopoverModel(sessions, settings2, available2, busy, now) {
1805
2499
  status: t.status,
1806
2500
  detail: t.detail,
1807
2501
  cwd: t.ref.cwd ?? null,
1808
- repo: t.ref.cwd ? (0, import_node_path13.basename)(t.ref.cwd) : null,
2502
+ repo: t.ref.cwd ? (0, import_node_path16.basename)(t.ref.cwd) : null,
1809
2503
  ago: timeAgo(t.updatedAt, now),
1810
2504
  busy: busy.has(key),
1811
2505
  targets: order.filter((x) => x !== t.ref.tool).map((x) => ({ tool: x, label: TOOL_LABEL[x], enabled: available2[x][settings2.modes[x]], isDefault: x === settings2.defaultTarget }))
1812
2506
  };
1813
2507
  }),
1814
- settings: { defaultTarget: settings2.defaultTarget, autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme },
2508
+ usage: usage2,
2509
+ askClaudeBridge,
2510
+ settings: { defaultTarget: settings2.defaultTarget, autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme, claudeBridge: claudeBridge2 },
1815
2511
  tools: TOOLS.map((x) => ({ tool: x, label: TOOL_LABEL[x], mode: settings2.modes[x], ...available2[x] })),
1816
2512
  hotkey: HOTKEY.label
1817
2513
  };
@@ -1834,6 +2530,10 @@ function isMenuAction(x) {
1834
2530
  return isTool(a.target) && (a.mode === "app" || a.mode === "terminal");
1835
2531
  case "toggleAuto":
1836
2532
  case "toggleLogin":
2533
+ case "answerClaudeBridge":
2534
+ return typeof a.allow === "boolean";
2535
+ case "toggleClaudeBridge":
2536
+ case "openCursorUsage":
1837
2537
  case "quit":
1838
2538
  return true;
1839
2539
  default:
@@ -1847,6 +2547,163 @@ function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}
1847
2547
  return order.filter((t) => t !== source && available2[t][modes[t]] && !((limitedUntil[t]?.getTime() ?? 0) > now.getTime()));
1848
2548
  }
1849
2549
 
2550
+ // src/usage.ts
2551
+ var import_node_events2 = require("node:events");
2552
+ var import_node_fs3 = require("node:fs");
2553
+ var import_promises15 = require("node:fs/promises");
2554
+ var import_node_path17 = require("node:path");
2555
+ var CODEX_STALE_MS = 30 * 6e4;
2556
+ var CODEX_QUERY_GAP_MS = 30 * 6e4;
2557
+ var DEBOUNCE_MS = 2e3;
2558
+ var ETA_WARN_PCT = 50;
2559
+ var ETA_WARN_MIN = 20;
2560
+ function clock2(d, now) {
2561
+ const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
2562
+ return d.getTime() - now.getTime() < 20 * 36e5 ? time : `${d.toLocaleDateString([], { weekday: "short" })} ${time}`;
2563
+ }
2564
+ var NAME = { "claude-code": "Claude", codex: "Codex", cursor: "Cursor" };
2565
+ var UsageMonitor = class extends import_node_events2.EventEmitter {
2566
+ constructor(opts) {
2567
+ super();
2568
+ this.opts = opts;
2569
+ this.claudeLog = new ClaudeUsageLog(opts.home);
2570
+ }
2571
+ opts;
2572
+ claudeLog;
2573
+ watchers = [];
2574
+ timers = /* @__PURE__ */ new Map();
2575
+ alerted = /* @__PURE__ */ new Set();
2576
+ snapshots = {};
2577
+ samples = [];
2578
+ codexQuery = null;
2579
+ lastCodexQueryAt = 0;
2580
+ stopped = false;
2581
+ get statusFile() {
2582
+ return claudeStatusFile(this.opts.dataDir);
2583
+ }
2584
+ get samplesFile() {
2585
+ return (0, import_node_path17.join)(this.opts.dataDir, "usage-calibration.json");
2586
+ }
2587
+ current() {
2588
+ return this.snapshots;
2589
+ }
2590
+ async start() {
2591
+ await (0, import_promises15.mkdir)(this.opts.dataDir, { recursive: true });
2592
+ try {
2593
+ const raw = JSON.parse(await (0, import_promises15.readFile)(this.samplesFile, "utf8"));
2594
+ if (Array.isArray(raw)) this.samples = raw.filter((s) => typeof s?.capacity === "number" && typeof s?.at === "number" && typeof s?.kind === "string");
2595
+ } catch {
2596
+ this.samples = [];
2597
+ }
2598
+ const watchDir = (dir, tool, recursive) => {
2599
+ try {
2600
+ const w = (0, import_node_fs3.watch)(dir, { recursive }, (_e, name) => {
2601
+ if (!name || String(name).endsWith(".jsonl") || String(name).startsWith("claude-status")) this.schedule(tool);
2602
+ });
2603
+ w.on("error", () => void 0);
2604
+ this.watchers.push(w);
2605
+ } catch {
2606
+ }
2607
+ };
2608
+ watchDir((0, import_node_path17.join)(this.opts.home, ".codex", "sessions"), "codex", true);
2609
+ watchDir((0, import_node_path17.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
2610
+ watchDir(this.opts.dataDir, "claude-code", false);
2611
+ await Promise.all([this.update("codex"), this.update("claude-code")]);
2612
+ void this.topUpCodex();
2613
+ }
2614
+ stop() {
2615
+ this.stopped = true;
2616
+ for (const w of this.watchers) w.close();
2617
+ for (const t of this.timers.values()) clearTimeout(t);
2618
+ }
2619
+ /** Re-derives readings (e.g. reset times passing) from what is already loaded; cheap. */
2620
+ async refresh() {
2621
+ await Promise.all([this.update("codex"), this.update("claude-code")]);
2622
+ }
2623
+ /** Asks Codex directly if its logs have nothing recent; called when the popover opens. */
2624
+ async topUpCodex() {
2625
+ const now = Date.now();
2626
+ const snap = this.snapshots.codex;
2627
+ if (snap && now - snap.observedAt.getTime() < CODEX_STALE_MS) return;
2628
+ if (now - this.lastCodexQueryAt < CODEX_QUERY_GAP_MS) return;
2629
+ const bin = await this.opts.codexBin();
2630
+ if (!bin) return;
2631
+ this.lastCodexQueryAt = now;
2632
+ const reading = await queryCodexUsage(bin);
2633
+ if (reading) {
2634
+ this.codexQuery = reading;
2635
+ await this.update("codex");
2636
+ }
2637
+ }
2638
+ schedule(tool) {
2639
+ if (this.stopped) return;
2640
+ clearTimeout(this.timers.get(tool));
2641
+ this.timers.set(tool, setTimeout(() => void this.update(tool), DEBOUNCE_MS));
2642
+ }
2643
+ async update(tool) {
2644
+ const now = /* @__PURE__ */ new Date();
2645
+ let snap = null;
2646
+ try {
2647
+ if (tool === "codex") {
2648
+ snap = codexSnapshot(await readCodexReadings(this.opts.home), now, this.codexQuery);
2649
+ if (snap && snap.resetCredits === null && this.codexQuery) snap.resetCredits = this.codexQuery.resetCredits;
2650
+ } else if (tool === "claude-code") {
2651
+ snap = await this.claude(now);
2652
+ }
2653
+ } catch (e) {
2654
+ console.error("[harlay] usage update failed", e);
2655
+ return;
2656
+ }
2657
+ if (snap) this.snapshots[tool] = snap;
2658
+ else delete this.snapshots[tool];
2659
+ this.emit("update", this.snapshots);
2660
+ if (snap) this.checkAlerts(snap, now);
2661
+ }
2662
+ async claude(now) {
2663
+ await this.claudeLog.refresh(now);
2664
+ let exact = null;
2665
+ try {
2666
+ const s = await (0, import_promises15.stat)(this.statusFile);
2667
+ exact = parseClaudeStatusline(JSON.parse(await (0, import_promises15.readFile)(this.statusFile, "utf8")), s.mtimeMs);
2668
+ } catch {
2669
+ exact = null;
2670
+ }
2671
+ const entries = this.claudeLog.entries();
2672
+ const hits = this.claudeLog.limitHits();
2673
+ const merged = mergeSamples(this.samples, capacitySamples(entries, hits, exact), now);
2674
+ if (JSON.stringify(merged) !== JSON.stringify(this.samples)) {
2675
+ this.samples = merged;
2676
+ await (0, import_promises15.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
2677
+ }
2678
+ return claudeSnapshot({ entries, hits, exact, samples: this.samples, now });
2679
+ }
2680
+ checkAlerts(s, now) {
2681
+ const threshold = this.opts.threshold();
2682
+ const fire = (key, alert) => {
2683
+ if (this.alerted.has(key)) return;
2684
+ this.alerted.add(key);
2685
+ this.emit("alert", alert);
2686
+ };
2687
+ const resetKey = (w) => w.resetsAt ? Math.round(w.resetsAt.getTime() / 6e5) : "none";
2688
+ for (const w of s.windows) {
2689
+ if (w.usedPercent >= 100 || w.usedPercent < threshold) continue;
2690
+ fire(`${s.tool}|${w.kind}|${resetKey(w)}|level`, {
2691
+ tool: s.tool,
2692
+ title: `${NAME[s.tool]} is at ${w.exact ? "" : "\u2248"}${Math.round(w.usedPercent)}% of its ${w.label === "5h" ? "5-hour" : "weekly"} limit`,
2693
+ body: w.resetsAt ? `Resets ${clock2(w.resetsAt, now)}. Click to hand off the latest ${NAME[s.tool]} session.` : `Click to hand off the latest ${NAME[s.tool]} session.`
2694
+ });
2695
+ }
2696
+ const full = fullestWindow(s);
2697
+ if (full && s.minutesToLimit !== null && s.minutesToLimit <= ETA_WARN_MIN && full.usedPercent >= ETA_WARN_PCT && full.usedPercent < threshold) {
2698
+ fire(`${s.tool}|${full.kind}|${resetKey(full)}|pace`, {
2699
+ tool: s.tool,
2700
+ title: `${NAME[s.tool]} may hit its limit in about ${Math.max(1, Math.round(s.minutesToLimit))} min`,
2701
+ body: `At the current pace. It's at ${full.exact ? "" : "\u2248"}${Math.round(full.usedPercent)}% of its ${full.label === "5h" ? "5-hour" : "weekly"} limit.`
2702
+ });
2703
+ }
2704
+ }
2705
+ };
2706
+
1850
2707
  // src/main.ts
1851
2708
  var MAX_LIVE_NOTIFICATIONS = 50;
1852
2709
  var AUTO_HANDOFF_COOLDOWN_MS = 2 * 6e4;
@@ -1862,6 +2719,8 @@ var tray = null;
1862
2719
  var popover = null;
1863
2720
  var popoverHiddenAt = 0;
1864
2721
  var watcher = null;
2722
+ var usage = null;
2723
+ var claudeBridge = "off";
1865
2724
  var lastAutoHandoffAt = 0;
1866
2725
  var previewedBriefs = /* @__PURE__ */ new Map();
1867
2726
  var errorMessage = (e) => e instanceof Error ? e.message : String(e);
@@ -1881,7 +2740,11 @@ function toElectron(nodes) {
1881
2740
  }
1882
2741
  function refreshUi() {
1883
2742
  if (!popover || popover.isDestroyed()) return;
1884
- popover.webContents.send("harlay:model", buildPopoverModel(recentSessions(state, 8), settings, available, handoffsInFlight, /* @__PURE__ */ new Date()));
2743
+ const now = /* @__PURE__ */ new Date();
2744
+ const rows = buildUsageRows(usage?.current() ?? {}, available, claudeBridge, now);
2745
+ const claudeInstalled = available["claude-code"].app || available["claude-code"].terminal;
2746
+ const ask = !settings.claudeBridgeAsked && claudeBridge === "off" && claudeInstalled;
2747
+ popover.webContents.send("harlay:model", buildPopoverModel(recentSessions(state, 8), settings, available, handoffsInFlight, now, rows, claudeBridge, ask));
1885
2748
  }
1886
2749
  function nativeMenu() {
1887
2750
  return import_electron.Menu.buildFromTemplate(toElectron(buildMenuModel(recentSessions(state, 8), settings, available)));
@@ -1946,14 +2809,14 @@ async function previewBrief(ref) {
1946
2809
  path = res.written.latestPath;
1947
2810
  }
1948
2811
  previewedBriefs.set(key, path);
1949
- send({ path, text: await (0, import_promises12.readFile)(path, "utf8") });
2812
+ send({ path, text: await (0, import_promises16.readFile)(path, "utf8") });
1950
2813
  } catch (e) {
1951
2814
  send({ error: errorMessage(e) });
1952
2815
  }
1953
2816
  }
1954
2817
  async function copyBrief(key) {
1955
2818
  const path = previewedBriefs.get(key);
1956
- if (path) import_electron.clipboard.writeText(await (0, import_promises12.readFile)(path, "utf8"));
2819
+ if (path) import_electron.clipboard.writeText(await (0, import_promises16.readFile)(path, "utf8"));
1957
2820
  }
1958
2821
  async function persist() {
1959
2822
  await saveSettings(settingsFile, settings);
@@ -2002,6 +2865,24 @@ async function onAction(a) {
2002
2865
  if (import_electron.app.isPackaged) import_electron.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
2003
2866
  await persist();
2004
2867
  break;
2868
+ case "toggleClaudeBridge":
2869
+ case "answerClaudeBridge": {
2870
+ const on = a.type === "answerClaudeBridge" ? a.allow : claudeBridge !== "on";
2871
+ settings.claudeBridgeAsked = true;
2872
+ await saveSettings(settingsFile, settings);
2873
+ try {
2874
+ if (on) await installBridge(home, dataDir());
2875
+ else if (a.type === "toggleClaudeBridge") await removeBridge(home, dataDir());
2876
+ } catch (e) {
2877
+ notify("Harlay could not change Claude Code settings", errorMessage(e));
2878
+ }
2879
+ claudeBridge = await bridgeState(home);
2880
+ refreshUi();
2881
+ break;
2882
+ }
2883
+ case "openCursorUsage":
2884
+ await import_electron.shell.openExternal("https://cursor.com/dashboard?tab=usage");
2885
+ break;
2005
2886
  case "quit":
2006
2887
  import_electron.app.quit();
2007
2888
  break;
@@ -2024,33 +2905,36 @@ function onWatchEvent(ev) {
2024
2905
  void handoff(ev.ref, first);
2025
2906
  return;
2026
2907
  }
2027
- const n2 = new import_electron.Notification({
2908
+ const n = new import_electron.Notification({
2028
2909
  title: `${TOOL_LABEL[ev.ref.tool]} stopped: ${name}`,
2029
2910
  body: haltNotificationBody(tracked, first),
2030
2911
  actions: targets.map((t) => ({ type: "button", text: `Continue in ${TOOL_LABEL[t]}` }))
2031
2912
  });
2032
- keep(n2);
2033
- n2.on("action", (_e, index) => {
2913
+ keep(n);
2914
+ n.on("action", (_e, index) => {
2034
2915
  const t = targets[index];
2035
2916
  if (t) void handoff(ev.ref, t);
2036
2917
  });
2037
- n2.on("click", () => {
2918
+ n.on("click", () => {
2038
2919
  if (first) void handoff(ev.ref, first);
2039
2920
  });
2040
- n2.show();
2921
+ n.show();
2041
2922
  return;
2042
2923
  }
2043
- if (ev.event.usage.usedPercent >= 100) return;
2044
- const n = new import_electron.Notification({
2045
- title: `${TOOL_LABEL[ev.ref.tool]} is at ${Math.round(ev.event.usage.usedPercent)}% of its limit`,
2046
- body: first ? `${name}. Click to hand off to ${TOOL_LABEL[first]} now.` : name
2047
- });
2924
+ }
2925
+ function onUsageAlert(a) {
2926
+ const latest = recentSessions(state, 50).find((t) => t.ref.tool === a.tool && t.status !== "handed_off");
2927
+ const [target] = latest ? pickTargets(a.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, /* @__PURE__ */ new Date()) : [];
2928
+ const n = new import_electron.Notification({ title: a.title, body: latest && target ? a.body : a.body.replace(/ ?Click to hand off.*$/, "") });
2048
2929
  keep(n);
2049
2930
  n.on("click", () => {
2050
- if (first) void handoff(ev.ref, first);
2931
+ if (latest && target) void handoff(latest.ref, target);
2051
2932
  });
2052
2933
  n.show();
2053
2934
  }
2935
+ function dataDir() {
2936
+ return import_electron.app.getPath("userData");
2937
+ }
2054
2938
  function onHotkey() {
2055
2939
  const latest = latestSession(state);
2056
2940
  if (!latest) {
@@ -2088,13 +2972,13 @@ function createPopover() {
2088
2972
  fullscreenable: false,
2089
2973
  skipTaskbar: true,
2090
2974
  alwaysOnTop: true,
2091
- webPreferences: { preload: (0, import_node_path14.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true }
2975
+ webPreferences: { preload: (0, import_node_path18.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true }
2092
2976
  });
2093
2977
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
2094
2978
  win.on("blur", hidePopover);
2095
2979
  win.webContents.on("will-navigate", (e) => e.preventDefault());
2096
2980
  win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
2097
- void win.loadFile((0, import_node_path14.join)(__dirname, "..", "renderer", "popover.html"));
2981
+ void win.loadFile((0, import_node_path18.join)(__dirname, "..", "renderer", "popover.html"));
2098
2982
  return win;
2099
2983
  }
2100
2984
  function showPopover() {
@@ -2104,6 +2988,7 @@ function showPopover() {
2104
2988
  const x = Math.min(Math.max(t.x + t.width / 2 - POPOVER_WIDTH / 2, area.x), area.x + area.width - POPOVER_WIDTH);
2105
2989
  popover.setPosition(Math.round(x), Math.round(t.y + t.height));
2106
2990
  refreshUi();
2991
+ void usage?.topUpCodex();
2107
2992
  popover.show();
2108
2993
  popover.focus();
2109
2994
  popover.webContents.send("harlay:shown");
@@ -2142,7 +3027,7 @@ function listenToPopover() {
2142
3027
  });
2143
3028
  import_electron.ipcMain.on("harlay:action", (e, action) => {
2144
3029
  if (!fromPopover(e) || !isMenuAction(action)) return;
2145
- if (action.type === "handoff" || action.type === "openBrief") hidePopover();
3030
+ if (action.type === "handoff" || action.type === "openBrief" || action.type === "openCursorUsage") hidePopover();
2146
3031
  void onAction(action);
2147
3032
  });
2148
3033
  }
@@ -2155,12 +3040,12 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2155
3040
  import_electron.app.on("second-instance", revealPopover);
2156
3041
  import_electron.app.whenReady().then(async () => {
2157
3042
  import_electron.app.dock?.hide();
2158
- settingsFile = (0, import_node_path14.join)(import_electron.app.getPath("userData"), "settings.json");
3043
+ settingsFile = (0, import_node_path18.join)(import_electron.app.getPath("userData"), "settings.json");
2159
3044
  settings = await loadSettings(settingsFile);
2160
3045
  import_electron.nativeTheme.themeSource = settings.theme;
2161
3046
  if (import_electron.app.isPackaged) import_electron.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
2162
3047
  available = await availableTargets(defaultResolveOptions());
2163
- const iconPath = (0, import_node_path14.join)(__dirname, "..", "assets", "trayTemplate.png");
3048
+ const iconPath = (0, import_node_path18.join)(__dirname, "..", "assets", "trayTemplate.png");
2164
3049
  const icon = import_electron.nativeImage.createFromPath(iconPath);
2165
3050
  icon.setTemplateImage(true);
2166
3051
  tray = new import_electron.Tray(icon);
@@ -2175,11 +3060,25 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2175
3060
  listenToPopover();
2176
3061
  popover = createPopover();
2177
3062
  await refreshSessions();
2178
- setInterval(() => void refreshSessions(), 6e4);
3063
+ setInterval(() => {
3064
+ void refreshSessions();
3065
+ void usage?.refresh();
3066
+ }, 6e4);
2179
3067
  watcher = new Watcher(readers, { usageThreshold: settings.usageThreshold });
2180
3068
  watcher.on("event", onWatchEvent);
2181
3069
  watcher.on("error", (e) => console.error("[harlay] watcher error", e));
2182
3070
  await watcher.start();
3071
+ usage = new UsageMonitor({
3072
+ home,
3073
+ dataDir: dataDir(),
3074
+ codexBin: () => resolveBinary("codex", defaultResolveOptions()),
3075
+ threshold: () => settings.usageThreshold
3076
+ });
3077
+ usage.on("update", refreshUi);
3078
+ usage.on("alert", onUsageAlert);
3079
+ claudeBridge = await bridgeState(home);
3080
+ if (claudeBridge === "on") await installBridge(home, dataDir()).catch((e) => console.error("[harlay] status line refresh failed", e));
3081
+ await usage.start().catch((e) => console.error("[harlay] usage monitor failed to start", e));
2183
3082
  if (!import_electron.globalShortcut.register(HOTKEY.accelerator, onHotkey)) console.error(`[harlay] could not register ${HOTKEY.accelerator}`);
2184
3083
  if (!import_electron.app.getLoginItemSettings().wasOpenedAtLogin) revealPopover();
2185
3084
  }).catch((e) => {
@@ -2190,5 +3089,6 @@ if (!import_electron.app.requestSingleInstanceLock()) {
2190
3089
  import_electron.app.on("will-quit", () => {
2191
3090
  import_electron.globalShortcut.unregisterAll();
2192
3091
  watcher?.stop();
3092
+ usage?.stop();
2193
3093
  });
2194
3094
  }