harlay 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/dist/main.cjs CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/main.ts
4
4
  var import_electron2 = require("electron");
5
- var import_promises19 = require("node:fs/promises");
5
+ var import_promises20 = require("node:fs/promises");
6
6
  var import_node_os3 = require("node:os");
7
- var import_node_path20 = require("node:path");
7
+ var import_node_path24 = require("node:path");
8
8
 
9
9
  // ../../packages/core/src/types.ts
10
10
  var TOOLS = ["claude-code", "codex", "cursor"];
@@ -12,6 +12,20 @@ var TOOL_LABEL = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor
12
12
  function isTool(v) {
13
13
  return v === "claude-code" || v === "codex" || v === "cursor";
14
14
  }
15
+ var WEB_CHATS = ["claude-web", "chatgpt"];
16
+ var WEB_LABEL = { "claude-web": "claude.ai", chatgpt: "ChatGPT" };
17
+ function isWebChat(v) {
18
+ return v === "claude-web" || v === "chatgpt";
19
+ }
20
+ function isTarget(v) {
21
+ return isTool(v) || isWebChat(v);
22
+ }
23
+ function targetLabel(t) {
24
+ return isWebChat(t) ? WEB_LABEL[t] : TOOL_LABEL[t];
25
+ }
26
+ function speakerLabel(t) {
27
+ return t === "claude-web" ? "Claude" : targetLabel(t);
28
+ }
15
29
 
16
30
  // ../../packages/core/src/errors.ts
17
31
  var HarlayError = class extends Error {
@@ -126,7 +140,7 @@ function slugify(s, max = 40) {
126
140
 
127
141
  // ../../packages/core/src/sources/claude.ts
128
142
  var import_promises5 = require("node:fs/promises");
129
- var import_node_path5 = require("node:path");
143
+ var import_node_path6 = require("node:path");
130
144
 
131
145
  // ../../packages/core/src/util/files.ts
132
146
  var import_node_fs = require("node:fs");
@@ -188,18 +202,18 @@ function blockText(content) {
188
202
  if (typeof content === "string") return content;
189
203
  if (!Array.isArray(content)) return "";
190
204
  const parts = [];
191
- for (const b of content) if (isObj(b) && b.type === "text" && typeof b.text === "string") parts.push(b.text);
205
+ for (const b of content) if (isObj(b) && (b.type === "text" || b.type === "Text") && typeof b.text === "string") parts.push(b.text);
192
206
  return parts.join("\n");
193
207
  }
194
208
 
195
209
  // ../../packages/core/src/sources/folder.ts
196
210
  var import_promises4 = require("node:fs/promises");
197
- var import_node_path4 = require("node:path");
211
+ var import_node_path5 = require("node:path");
198
212
 
199
213
  // ../../packages/core/src/brief/write.ts
200
214
  var import_node_crypto = require("node:crypto");
201
215
  var import_promises3 = require("node:fs/promises");
202
- var import_node_path3 = require("node:path");
216
+ var import_node_path4 = require("node:path");
203
217
 
204
218
  // ../../packages/core/src/launch/launchers.ts
205
219
  var import_node_child_process = require("node:child_process");
@@ -351,6 +365,7 @@ var Redactor = class {
351
365
  };
352
366
 
353
367
  // ../../packages/core/src/brief/record.ts
368
+ var import_node_path3 = require("node:path");
354
369
  var RECORD_SCHEMA = "harlay/handoff@6";
355
370
  var MESSAGE_SCHEMAS = /* @__PURE__ */ new Set([RECORD_SCHEMA, "harlay/handoff@5"]);
356
371
  var OLD_SCHEMAS = /* @__PURE__ */ new Set(["harlay/handoff@4", "harlay/handoff@3", "harlay/handoff@2"]);
@@ -365,7 +380,8 @@ var REASON_TEXT = {
365
380
  manual: "the user moved it here"
366
381
  };
367
382
  function harlayAbout(from, reason, root) {
368
- return `Harlay context: the user was working with ${TOOL_LABEL[from]} in ${root}, and that chat stopped (${REASON_TEXT[reason]}). This file is what happened there: everything the user typed, word for word and oldest first (task.userMessages), where it stopped and their latest request (state), a command that still fails (lastFailure), how each command went (results), what the previous agent read, was given and looked up (references), and the recent activity and diff.`;
383
+ const where = isWebChat(from) ? `chatting with ${targetLabel(from)} in their browser (this project is ${root})` : `working with ${targetLabel(from)} in ${root}`;
384
+ return `Harlay context: the user was ${where}, and that chat stopped (${REASON_TEXT[reason]}). This file is what happened there: everything the user typed, word for word and oldest first (task.userMessages), where it stopped and their latest request (state), a command that still fails (lastFailure), how each command went (results), what the previous agent read, was given and looked up (references), and the recent activity and diff.`;
369
385
  }
370
386
  function harlayInstructions(root, createdAt, head) {
371
387
  return [
@@ -377,6 +393,18 @@ function harlayInstructions(root, createdAt, head) {
377
393
  "Open what references lists only if you need it."
378
394
  ];
379
395
  }
396
+ function webAbout(from, reason, root) {
397
+ if (isWebChat(from))
398
+ return `Harlay context: the user was chatting with ${targetLabel(from)}, and that chat stopped (${REASON_TEXT[reason]}). This file is that conversation: everything the user typed, word for word and oldest first (task.userMessages), the last reply (state.whereItStopped) and the end of the conversation (recentActivity).`;
399
+ return `Harlay context: the user was working with ${targetLabel(from)} on a coding task in ${(0, import_node_path3.basename)(root)}, and that chat stopped (${REASON_TEXT[reason]}). This file is what happened there: everything the user typed, word for word and oldest first (task.userMessages), where it stopped (state), a command that still fails (lastFailure), the files changed (files), and the recent activity and diff.`;
400
+ }
401
+ function webInstructions(from) {
402
+ return [
403
+ "Reply with a short summary of where things stand (two or three lines), then carry on: if the user's last message has no answer yet, answer it; otherwise ask what they want to do next.",
404
+ "What the user asked for and decided is in task.userMessages, in their own words: follow it.",
405
+ ...isWebChat(from) ? [] : ["You can't see the user's files: ask them to paste any you need."]
406
+ ];
407
+ }
380
408
  var LINE_MAX = 500;
381
409
  function lines(s) {
382
410
  const out = [];
@@ -396,27 +424,27 @@ function lines(s) {
396
424
  }
397
425
  function tailLines(s, max) {
398
426
  if (s.length <= max) return lines(s);
399
- const cut = s.slice(s.length - max);
400
- const nl = cut.indexOf("\n");
401
- return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut : cut.slice(nl + 1))];
427
+ const cut2 = s.slice(s.length - max);
428
+ const nl = cut2.indexOf("\n");
429
+ return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut2 : cut2.slice(nl + 1))];
402
430
  }
403
431
  function headLines(s, max, more) {
404
432
  if (s.length <= max) return lines(s.replace(/\n$/, ""));
405
- const cut = s.slice(0, max);
406
- const nl = cut.lastIndexOf("\n");
407
- return [...lines(nl === -1 ? cut : cut.slice(0, nl)), more];
433
+ const cut2 = s.slice(0, max);
434
+ const nl = cut2.lastIndexOf("\n");
435
+ return [...lines(nl === -1 ? cut2 : cut2.slice(0, nl)), more];
408
436
  }
409
437
  function buildRecord(d, id, previousId, delivery, r) {
410
438
  const a = (s) => wellFormed(r.apply(stripControl(s)));
411
439
  const files = d.git?.files ?? [];
412
440
  const failure = d.lastFailure ? a(d.lastFailure.output).split("\n").slice(-FAILURE_OUTPUT_LINES).join("\n") : "";
413
441
  return {
414
- harlay: harlayAbout(d.from.tool, d.from.reason, d.workdir),
442
+ harlay: isWebChat(delivery.target) ? webAbout(d.from.tool, d.from.reason, d.workdir) : harlayAbout(d.from.tool, d.from.reason, d.workdir),
415
443
  // The id stays near the start: an agent may read only the file's first lines, and its copy is matched by the id.
416
444
  schema: RECORD_SCHEMA,
417
445
  id,
418
446
  createdAt: d.createdAt.toISOString(),
419
- instructions: harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
447
+ instructions: isWebChat(delivery.target) ? webInstructions(d.from.tool) : harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
420
448
  task: {
421
449
  title: a(d.title),
422
450
  originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
@@ -471,7 +499,7 @@ function messagesOf(schema, task) {
471
499
  function validateRecord(raw) {
472
500
  if (!isObj2(raw) || !(MESSAGE_SCHEMAS.has(raw.schema) || OLD_SCHEMAS.has(raw.schema)) || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
473
501
  const { task, from } = raw;
474
- if (!isObj2(from) || !isTool(from.tool)) return null;
502
+ if (!isObj2(from) || !isTarget(from.tool)) return null;
475
503
  if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
476
504
  const messages = messagesOf(raw.schema, task);
477
505
  if (!messages) return null;
@@ -486,16 +514,16 @@ function validateRecord(raw) {
486
514
 
487
515
  // ../../packages/core/src/brief/write.ts
488
516
  function handoffStore(home2) {
489
- return (0, import_node_path3.join)(home2, "Library", "Application Support", "Harlay", "handoffs");
517
+ return (0, import_node_path4.join)(home2, "Library", "Application Support", "Harlay", "handoffs");
490
518
  }
491
519
  function repoFolder(store, root) {
492
- return (0, import_node_path3.join)(store, `${slugify((0, import_node_path3.basename)(root), 30)}-${(0, import_node_crypto.createHash)("sha256").update(root).digest("hex").slice(0, 8)}`);
520
+ return (0, import_node_path4.join)(store, `${slugify((0, import_node_path4.basename)(root), 30)}-${(0, import_node_crypto.createHash)("sha256").update(root).digest("hex").slice(0, 8)}`);
493
521
  }
494
522
  var LEGACY_FILE = "handoff.harlay";
495
523
  async function readHandoffState(workdir) {
496
524
  let raw;
497
525
  try {
498
- raw = JSON.parse(await (0, import_promises3.readFile)((0, import_node_path3.join)(workdir, ".harlay", "state.json"), "utf8"));
526
+ raw = JSON.parse(await (0, import_promises3.readFile)((0, import_node_path4.join)(workdir, ".harlay", "state.json"), "utf8"));
499
527
  } catch {
500
528
  return null;
501
529
  }
@@ -540,7 +568,7 @@ async function newestIn(dir) {
540
568
  let best = null;
541
569
  for (const name of names) {
542
570
  if (!name.endsWith(".harlay") || name.startsWith(".")) continue;
543
- const file = (0, import_node_path3.join)(dir, name);
571
+ const file = (0, import_node_path4.join)(dir, name);
544
572
  try {
545
573
  const at = (await (0, import_promises3.stat)(file)).mtimeMs;
546
574
  if (!best || at > best.at) best = { file, at };
@@ -551,7 +579,7 @@ async function newestIn(dir) {
551
579
  }
552
580
  async function readPreviousHandoff(store, workdir) {
553
581
  const newest = await newestIn(repoFolder(store, workdir));
554
- for (const file of newest ? [newest] : [(0, import_node_path3.join)(workdir, LEGACY_FILE), (0, import_node_path3.join)(workdir, ".harlay", "handoff.json")]) {
582
+ for (const file of newest ? [newest] : [(0, import_node_path4.join)(workdir, LEGACY_FILE), (0, import_node_path4.join)(workdir, ".harlay", "handoff.json")]) {
555
583
  const raw = await readIfExists(file);
556
584
  if (raw === null) continue;
557
585
  const rec = parseRecord(raw);
@@ -567,7 +595,7 @@ function addCounts(a, b) {
567
595
  }
568
596
  var writing = /* @__PURE__ */ new Map();
569
597
  function writeHandoff(d, o) {
570
- const dir = repoFolder(o.store, d.workdir);
598
+ const dir = o.dir ?? repoFolder(o.store, d.workdir);
571
599
  const run = (writing.get(dir) ?? Promise.resolve()).catch(() => void 0).then(() => write(d, o, dir));
572
600
  writing.set(dir, run);
573
601
  void run.finally(() => {
@@ -585,14 +613,14 @@ async function exists(file) {
585
613
  }
586
614
  async function write(d, o, dir) {
587
615
  await (0, import_promises3.mkdir)(dir, { recursive: true });
588
- const tmp = (0, import_node_path3.join)(dir, `.tmp-${process.pid}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}.harlay`);
616
+ const tmp = (0, import_node_path4.join)(dir, `.tmp-${process.pid}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}.harlay`);
589
617
  try {
590
618
  for (let n = 1; ; n++) {
591
619
  const id = n === 1 ? d.baseId : `${d.baseId}-${n}`;
592
- const path = (0, import_node_path3.join)(dir, `${id}.harlay`);
620
+ const path = (0, import_node_path4.join)(dir, `${id}.harlay`);
593
621
  if (await exists(path)) continue;
594
622
  const r = new Redactor();
595
- const prompt = launchPrompt(path);
623
+ const prompt = o.prompt ?? launchPrompt(path);
596
624
  const record = buildRecord(d, id, o.previousId, { target: o.target, mode: o.mode, prompt }, r);
597
625
  const text = JSON.stringify(record, null, 2) + "\n";
598
626
  await (0, import_promises3.writeFile)(tmp, text);
@@ -625,7 +653,7 @@ async function handoffRepo(file) {
625
653
  }
626
654
  const root = raw === null ? null : parseRecord(raw)?.root;
627
655
  if (root && await isDir(root)) return root;
628
- return (0, import_node_path4.basename)(file) === LEGACY_FILE && await isDir((0, import_node_path4.dirname)(file)) ? (0, import_node_path4.dirname)(file) : null;
656
+ return (0, import_node_path5.basename)(file) === LEGACY_FILE && await isDir((0, import_node_path5.dirname)(file)) ? (0, import_node_path5.dirname)(file) : null;
629
657
  }
630
658
  async function sessionFolder(opened, firstPrompt2) {
631
659
  const file = firstPrompt2 === null ? null : harlayFileIn(firstPrompt2);
@@ -813,7 +841,7 @@ function normalizeClaude(records, ref, invalid = 0) {
813
841
  }
814
842
  async function loadDesktopTitles(dir) {
815
843
  const out = /* @__PURE__ */ new Map();
816
- const files = await listSessionFiles(dir, (f) => (0, import_node_path5.basename)(f).startsWith("local_") && f.endsWith(".json"));
844
+ const files = await listSessionFiles(dir, (f) => (0, import_node_path6.basename)(f).startsWith("local_") && f.endsWith(".json"));
817
845
  for (const f of files) {
818
846
  try {
819
847
  const d = JSON.parse(await (0, import_promises5.readFile)(f.path, "utf8"));
@@ -826,9 +854,9 @@ async function loadDesktopTitles(dir) {
826
854
  return out;
827
855
  }
828
856
  function createClaudeReader(o) {
829
- const root = (0, import_node_path5.join)(o.home, ".claude", "projects");
830
- const indexDir = (0, import_node_path5.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
831
- const isSessionFile = (file) => file.startsWith(root + import_node_path5.sep) && file.endsWith(".jsonl") && (0, import_node_path5.relative)(root, file).split(import_node_path5.sep).length === 2;
857
+ const root = (0, import_node_path6.join)(o.home, ".claude", "projects");
858
+ const indexDir = (0, import_node_path6.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
859
+ const isSessionFile = (file) => file.startsWith(root + import_node_path6.sep) && file.endsWith(".jsonl") && (0, import_node_path6.relative)(root, file).split(import_node_path6.sep).length === 2;
832
860
  const cache = /* @__PURE__ */ new Map();
833
861
  async function refForFile(file, titles) {
834
862
  let st;
@@ -884,7 +912,7 @@ function createClaudeReader(o) {
884
912
  entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
885
913
  cache.set(file, entry);
886
914
  }
887
- const id = (0, import_node_path5.basename)(file, ".jsonl");
915
+ const id = (0, import_node_path6.basename)(file, ".jsonl");
888
916
  const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
889
917
  if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
890
918
  return entry.ref;
@@ -893,7 +921,7 @@ function createClaudeReader(o) {
893
921
  tool: "claude-code",
894
922
  root,
895
923
  isSessionFile,
896
- idFromPath: (file) => isSessionFile(file) ? (0, import_node_path5.basename)(file, ".jsonl") : null,
924
+ idFromPath: (file) => isSessionFile(file) ? (0, import_node_path6.basename)(file, ".jsonl") : null,
897
925
  refForFile: (file) => refForFile(file),
898
926
  async listSessions(limit = 20) {
899
927
  const files = await listSessionFiles(root, isSessionFile);
@@ -921,7 +949,7 @@ function createClaudeReader(o) {
921
949
 
922
950
  // ../../packages/core/src/sources/codex.ts
923
951
  var import_promises6 = require("node:fs/promises");
924
- var import_node_path6 = require("node:path");
952
+ var import_node_path7 = require("node:path");
925
953
  var OUTPUT_TAIL2 = 2e4;
926
954
  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;
927
955
  var KIND = { add: "create", update: "modify", delete: "delete" };
@@ -989,8 +1017,9 @@ function normalizeCodex(records, ref, invalid = 0) {
989
1017
  sawItemPrompt = true;
990
1018
  }
991
1019
  } else if (it.type === "AgentMessage") {
992
- const text = blockText(it.content).trim();
1020
+ const { text, calls } = externalCalls(blockText(it.content));
993
1021
  if (text) timeline.push({ kind: "assistant", text });
1022
+ timeline.push(...calls);
994
1023
  halt = null;
995
1024
  } else if (it.type === "CommandExecution") {
996
1025
  const cmd = codexCommand(it.command);
@@ -1034,6 +1063,25 @@ function parseSessionMeta(line) {
1034
1063
  const id = str(p.id);
1035
1064
  return id ? { id, cwd: str(p.cwd) } : null;
1036
1065
  }
1066
+ var EXTERNAL_CALL_RE = /\[external_agent_tool_call: ([\w-]+)\]\n?([\s\S]*?)\[\/external_agent_tool_call\]/g;
1067
+ function externalCalls(raw) {
1068
+ const calls = [];
1069
+ const text = raw.replace(EXTERNAL_CALL_RE, (_m, name, body) => {
1070
+ const command = /^command: (.+)$/m.exec(body)?.[1];
1071
+ let input = {};
1072
+ try {
1073
+ const parsed = JSON.parse(/^input: (\{.*\})$/m.exec(body)?.[1] ?? "{}");
1074
+ if (isObj(parsed)) input = parsed;
1075
+ } catch {
1076
+ }
1077
+ const path = str(input.path) ?? str(input.file_path) ?? str(input.target_file);
1078
+ if (name === "Shell" && command) calls.push({ kind: "command", run: { cmd: command, exitCode: null, output: "" } });
1079
+ else if (name === "Read" && path) calls.push({ kind: "read", path });
1080
+ else if (/^(Edit|Write|StrReplace|MultiEdit|Delete)$/.test(name) && path) calls.push({ kind: "edit", edit: { path, kind: name === "Delete" ? "delete" : "modify" } });
1081
+ return "";
1082
+ });
1083
+ return { text: text.replace(/\n{3,}/g, "\n\n").trim(), calls };
1084
+ }
1037
1085
  function typedPrompt(line) {
1038
1086
  if (!line.includes('"user_message"') && !line.includes('"UserMessage"')) return null;
1039
1087
  let r;
@@ -1062,9 +1110,9 @@ async function loadTitles(file) {
1062
1110
  return out;
1063
1111
  }
1064
1112
  function createCodexReader(o) {
1065
- const root = (0, import_node_path6.join)(o.home, ".codex", "sessions");
1066
- const indexFile = (0, import_node_path6.join)(o.home, ".codex", "session_index.jsonl");
1067
- const isSessionFile = (file) => file.startsWith(root + import_node_path6.sep) && (0, import_node_path6.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
1113
+ const root = (0, import_node_path7.join)(o.home, ".codex", "sessions");
1114
+ const indexFile = (0, import_node_path7.join)(o.home, ".codex", "session_index.jsonl");
1115
+ const isSessionFile = (file) => file.startsWith(root + import_node_path7.sep) && (0, import_node_path7.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
1068
1116
  const cache = /* @__PURE__ */ new Map();
1069
1117
  async function refForFile(file, titles) {
1070
1118
  let st;
@@ -1100,7 +1148,7 @@ function createCodexReader(o) {
1100
1148
  tool: "codex",
1101
1149
  root,
1102
1150
  isSessionFile,
1103
- idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path6.basename)(file))?.[1] ?? null : null,
1151
+ idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path7.basename)(file))?.[1] ?? null : null,
1104
1152
  refForFile: (file) => refForFile(file),
1105
1153
  async listSessions(limit = 20) {
1106
1154
  const files = await listSessionFiles(root, isSessionFile);
@@ -1140,7 +1188,7 @@ function createCodexReader(o) {
1140
1188
 
1141
1189
  // ../../packages/core/src/sources/cursor.ts
1142
1190
  var import_promises7 = require("node:fs/promises");
1143
- var import_node_path7 = require("node:path");
1191
+ var import_node_path8 = require("node:path");
1144
1192
  var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
1145
1193
  async function resolveCursorSlug(slug, rootDir = "/") {
1146
1194
  if (!slug || /^\d+$/.test(slug)) return null;
@@ -1151,9 +1199,9 @@ async function resolveCursorSlug(slug, rootDir = "/") {
1151
1199
  for (const e of entries) {
1152
1200
  if (!e.isDirectory()) continue;
1153
1201
  const n = norm(e.name);
1154
- if (rest === n) return (0, import_node_path7.join)(dir, e.name);
1202
+ if (rest === n) return (0, import_node_path8.join)(dir, e.name);
1155
1203
  if (rest.startsWith(n + "-")) {
1156
- const found = await walk((0, import_node_path7.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
1204
+ const found = await walk((0, import_node_path8.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
1157
1205
  if (found) return found;
1158
1206
  }
1159
1207
  }
@@ -1219,7 +1267,7 @@ function normalizeCursor(records, ref, invalid = 0) {
1219
1267
  return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
1220
1268
  }
1221
1269
  function createCursorReader(o) {
1222
- const root = (0, import_node_path7.join)(o.home, ".cursor", "projects");
1270
+ const root = (0, import_node_path8.join)(o.home, ".cursor", "projects");
1223
1271
  const slugCache = /* @__PURE__ */ new Map();
1224
1272
  const cwdFor = (slug) => {
1225
1273
  let p = slugCache.get(slug);
@@ -1230,14 +1278,14 @@ function createCursorReader(o) {
1230
1278
  return p;
1231
1279
  };
1232
1280
  const isSessionFile = (file) => {
1233
- if (!file.startsWith(root + import_node_path7.sep) || !file.endsWith(".jsonl")) return false;
1234
- const parts = (0, import_node_path7.relative)(root, file).split(import_node_path7.sep);
1281
+ if (!file.startsWith(root + import_node_path8.sep) || !file.endsWith(".jsonl")) return false;
1282
+ const parts = (0, import_node_path8.relative)(root, file).split(import_node_path8.sep);
1235
1283
  return parts.length === 4 && parts[1] === "agent-transcripts" && parts[3] === `${parts[2]}.jsonl`;
1236
1284
  };
1237
1285
  const cache = /* @__PURE__ */ new Map();
1238
1286
  async function refForFile(file) {
1239
1287
  if (!isSessionFile(file)) return null;
1240
- const parts = (0, import_node_path7.relative)(root, file).split(import_node_path7.sep);
1288
+ const parts = (0, import_node_path8.relative)(root, file).split(import_node_path8.sep);
1241
1289
  let st;
1242
1290
  try {
1243
1291
  st = await (0, import_promises7.stat)(file);
@@ -1274,7 +1322,7 @@ function createCursorReader(o) {
1274
1322
  tool: "cursor",
1275
1323
  root,
1276
1324
  isSessionFile,
1277
- idFromPath: (file) => isSessionFile(file) ? (0, import_node_path7.relative)(root, file).split(import_node_path7.sep)[2] : null,
1325
+ idFromPath: (file) => isSessionFile(file) ? (0, import_node_path8.relative)(root, file).split(import_node_path8.sep)[2] : null,
1278
1326
  refForFile,
1279
1327
  async listSessions(limit = 20) {
1280
1328
  const files = await listSessionFiles(root, isSessionFile);
@@ -1302,10 +1350,22 @@ function createCursorReader(o) {
1302
1350
  // ../../packages/core/src/git/snapshot.ts
1303
1351
  var import_node_child_process2 = require("node:child_process");
1304
1352
  var import_node_crypto2 = require("node:crypto");
1353
+ var import_promises8 = require("node:fs/promises");
1354
+ var import_node_path9 = require("node:path");
1305
1355
  var import_node_util2 = require("node:util");
1306
1356
  var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
1307
1357
  var MAX_DIFF_CHARS = 2e6;
1308
1358
  var MAX_FILES = 500;
1359
+ async function inGitRepo(dir) {
1360
+ for (let d = dir; ; d = (0, import_node_path9.dirname)(d)) {
1361
+ try {
1362
+ await (0, import_promises8.stat)((0, import_node_path9.join)(d, ".git"));
1363
+ return true;
1364
+ } catch {
1365
+ }
1366
+ if ((0, import_node_path9.dirname)(d) === d) return false;
1367
+ }
1368
+ }
1309
1369
  async function git(cwd, args) {
1310
1370
  const { stdout } = await pexec2("git", args, {
1311
1371
  cwd,
@@ -1382,7 +1442,7 @@ async function takeGitSnapshot(cwd) {
1382
1442
  }
1383
1443
 
1384
1444
  // ../../packages/core/src/extract/extract.ts
1385
- var import_node_path8 = require("node:path");
1445
+ var import_node_path10 = require("node:path");
1386
1446
 
1387
1447
  // ../../packages/core/src/extract/recent.ts
1388
1448
  var USER_MESSAGES = { budgetChars: 16e3, messageChars: 1500 };
@@ -1473,7 +1533,7 @@ function splitShell(cmd, pipes = false) {
1473
1533
  var isCd = (s) => /^cd(\s|$)/.test(s);
1474
1534
  function isProbeStage(stage) {
1475
1535
  const words = stage.trim().split(/\s+/);
1476
- const first = (0, import_node_path8.basename)(words[0] ?? "");
1536
+ const first = (0, import_node_path10.basename)(words[0] ?? "");
1477
1537
  if (first !== "git") return PROBES.has(first);
1478
1538
  let i = 1;
1479
1539
  while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
@@ -1564,18 +1624,18 @@ function buildTranscript(s, title, rel, redactor) {
1564
1624
  }
1565
1625
  const out = lines2.join("\n");
1566
1626
  if (out.length <= TRANSCRIPT_MAX) return out;
1567
- const cut = out.slice(out.length - TRANSCRIPT_MAX);
1568
- const nl = cut.indexOf("\n");
1569
- return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
1627
+ const cut2 = out.slice(out.length - TRANSCRIPT_MAX);
1628
+ const nl = cut2.indexOf("\n");
1629
+ return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut2 : cut2.slice(nl + 1));
1570
1630
  }
1571
1631
  function extractHandoff(s, git2, o) {
1572
1632
  const cwd = s.ref.cwd;
1573
1633
  if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
1574
1634
  const workdir = git2?.root ?? cwd;
1575
1635
  const rel = (p) => {
1576
- if (!(0, import_node_path8.isAbsolute)(p)) return p;
1577
- const r = (0, import_node_path8.relative)(workdir, p);
1578
- return r.startsWith("..") || (0, import_node_path8.isAbsolute)(r) ? p : r;
1636
+ if (!(0, import_node_path10.isAbsolute)(p)) return p;
1637
+ const r = (0, import_node_path10.relative)(workdir, p);
1638
+ return r.startsWith("..") || (0, import_node_path10.isAbsolute)(r) ? p : r;
1579
1639
  };
1580
1640
  const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
1581
1641
  const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
@@ -1613,13 +1673,13 @@ function extractHandoff(s, git2, o) {
1613
1673
  const filesTouched = [];
1614
1674
  for (const e of [...edits].reverse()) {
1615
1675
  const r = rel(e.path);
1616
- if (!(0, import_node_path8.isAbsolute)(r) && !filesTouched.includes(r)) filesTouched.push(r);
1676
+ if (!(0, import_node_path10.isAbsolute)(r) && !filesTouched.includes(r)) filesTouched.push(r);
1617
1677
  if (filesTouched.length === 12) break;
1618
1678
  }
1619
1679
  const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
1620
- const pad = (n2) => String(n2).padStart(2, "0");
1680
+ const pad2 = (n2) => String(n2).padStart(2, "0");
1621
1681
  const n = o.now;
1622
- const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
1682
+ const baseId = `${pad2(n.getMonth() + 1)}${pad2(n.getDate())}-${pad2(n.getHours())}${pad2(n.getMinutes())}-${slugify(redactor.apply(title))}`;
1623
1683
  return {
1624
1684
  baseId,
1625
1685
  title,
@@ -1653,9 +1713,9 @@ function extractHandoff(s, git2, o) {
1653
1713
 
1654
1714
  // ../../packages/core/src/launch/resolve.ts
1655
1715
  var import_node_fs2 = require("node:fs");
1656
- var import_promises8 = require("node:fs/promises");
1716
+ var import_promises9 = require("node:fs/promises");
1657
1717
  var import_node_os = require("node:os");
1658
- var import_node_path9 = require("node:path");
1718
+ var import_node_path11 = require("node:path");
1659
1719
  function defaultResolveOptions(env = process.env) {
1660
1720
  return {
1661
1721
  home: env.HARLAY_HOME ?? (0, import_node_os.homedir)(),
@@ -1669,20 +1729,20 @@ var APP_BUNDLES = { "claude-code": ["Claude.app"], codex: ["ChatGPT.app", "Codex
1669
1729
  function knownPaths(tool, o) {
1670
1730
  switch (tool) {
1671
1731
  case "claude-code":
1672
- return [(0, import_node_path9.join)(o.home, ".local", "bin", "claude")];
1732
+ return [(0, import_node_path11.join)(o.home, ".local", "bin", "claude")];
1673
1733
  case "codex":
1674
1734
  return [
1675
- (0, import_node_path9.join)(o.applicationsDir, "ChatGPT.app", "Contents", "Resources", "codex-cli", "bin", "codex"),
1676
- (0, import_node_path9.join)(o.applicationsDir, "Codex.app", "Contents", "Resources", "codex-cli", "bin", "codex")
1735
+ (0, import_node_path11.join)(o.applicationsDir, "ChatGPT.app", "Contents", "Resources", "codex-cli", "bin", "codex"),
1736
+ (0, import_node_path11.join)(o.applicationsDir, "Codex.app", "Contents", "Resources", "codex-cli", "bin", "codex")
1677
1737
  ];
1678
1738
  case "cursor":
1679
- return [(0, import_node_path9.join)(o.home, ".local", "bin", "cursor-agent")];
1739
+ return [(0, import_node_path11.join)(o.home, ".local", "bin", "cursor-agent")];
1680
1740
  }
1681
1741
  }
1682
1742
  async function isExecutable(p) {
1683
1743
  try {
1684
- if (!(await (0, import_promises8.stat)(p)).isFile()) return false;
1685
- await (0, import_promises8.access)(p, import_node_fs2.constants.X_OK);
1744
+ if (!(await (0, import_promises9.stat)(p)).isFile()) return false;
1745
+ await (0, import_promises9.access)(p, import_node_fs2.constants.X_OK);
1686
1746
  return true;
1687
1747
  } catch {
1688
1748
  return false;
@@ -1690,7 +1750,7 @@ async function isExecutable(p) {
1690
1750
  }
1691
1751
  async function exists2(p) {
1692
1752
  try {
1693
- await (0, import_promises8.access)(p);
1753
+ await (0, import_promises9.access)(p);
1694
1754
  return true;
1695
1755
  } catch {
1696
1756
  return false;
@@ -1699,7 +1759,7 @@ async function exists2(p) {
1699
1759
  async function resolveBinary(tool, o) {
1700
1760
  const dirs = [...(o.pathEnv ?? "").split(":").filter(Boolean), ...o.systemDirs];
1701
1761
  for (const d of dirs) {
1702
- const p = (0, import_node_path9.join)(d, BIN_NAME[tool]);
1762
+ const p = (0, import_node_path11.join)(d, BIN_NAME[tool]);
1703
1763
  if (await isExecutable(p)) return p;
1704
1764
  }
1705
1765
  for (const p of knownPaths(tool, o)) if (await isExecutable(p)) return p;
@@ -1718,12 +1778,178 @@ async function availableTargets(o) {
1718
1778
  const out = {};
1719
1779
  for (const t of TOOLS) {
1720
1780
  let app2 = false;
1721
- for (const b of APP_BUNDLES[t]) if (await exists2((0, import_node_path9.join)(o.applicationsDir, b))) app2 = true;
1781
+ for (const b of APP_BUNDLES[t]) if (await exists2((0, import_node_path11.join)(o.applicationsDir, b))) app2 = true;
1722
1782
  out[t] = { app: app2, terminal: bins[t] !== void 0 };
1723
1783
  }
1724
1784
  return out;
1725
1785
  }
1726
1786
 
1787
+ // ../../packages/core/src/launch/web.ts
1788
+ var import_node_path12 = require("node:path");
1789
+ var NEW_CHAT_URL = { "claude-web": "https://claude.ai/new?q=", chatgpt: "https://chatgpt.com/?q=" };
1790
+ var ATTACHED = {
1791
+ usage_limit: (c) => `My chat with ${c} stopped on a usage limit, and the attached file is that chat.`,
1792
+ crash: (c) => `My chat with ${c} stopped with an error, and the attached file is that chat.`,
1793
+ manual: (c) => `I'm moving my chat with ${c} here, and the attached file is that chat.`
1794
+ };
1795
+ function webAttachPrompt(from, reason) {
1796
+ return `This is the context from Harlay. ${ATTACHED[reason](targetLabel(from))} Read it and pick up where it left off.`;
1797
+ }
1798
+ function webChatUrl(chat, text) {
1799
+ return NEW_CHAT_URL[chat] + encodeURIComponent(text);
1800
+ }
1801
+ var WEB_MAX_BYTES = 10500;
1802
+ var bytes = (s) => encodeURIComponent(s).length;
1803
+ function clipBytes(s, max) {
1804
+ if (bytes(s) <= max) return s;
1805
+ let lo = 0;
1806
+ let hi = s.length;
1807
+ while (lo < hi) {
1808
+ const mid = Math.ceil((lo + hi) / 2);
1809
+ if (bytes(cut(s, mid) + " \u2026") <= max) lo = mid;
1810
+ else hi = mid - 1;
1811
+ }
1812
+ const head = cut(s, lo);
1813
+ const space = head.search(/\s\S*$/);
1814
+ return `${(space > head.length * 0.8 ? head.slice(0, space) : head).trimEnd()} \u2026`;
1815
+ }
1816
+ function cut(s, n) {
1817
+ const c = s.charCodeAt(n - 1);
1818
+ return s.slice(0, c >= 55296 && c <= 56319 ? n - 1 : n);
1819
+ }
1820
+ function chatKind(s, inRepo = false) {
1821
+ for (const t of s.timeline) {
1822
+ if (t.kind === "user" && splitHandoffPrompt(t.text).handoff) return "coding";
1823
+ if (t.kind === "edit") return "coding";
1824
+ if (t.kind === "command" && !/\.harlay\b/.test(t.run.cmd)) return "coding";
1825
+ if (t.kind === "read" && t.how !== "fetched" && !t.path.endsWith(".harlay")) return "coding";
1826
+ }
1827
+ return inRepo ? "coding" : "chat";
1828
+ }
1829
+ var WHY = {
1830
+ usage_limit: (a) => `My chat with ${a} stopped on a usage limit.`,
1831
+ crash: (a) => `My chat with ${a} stopped with an error.`,
1832
+ manual: (a) => `I'm moving my chat with ${a} here.`
1833
+ };
1834
+ var USER_MAX = 2e3;
1835
+ var AGENT_MAX = 1600;
1836
+ var LAST_AGENT_MAX = 3200;
1837
+ var SEP = "\n\n";
1838
+ var DIFF_MIN = 2500;
1839
+ function fitList(items, budget, note, sep4 = SEP) {
1840
+ const gap = bytes(sep4);
1841
+ const size = (xs) => xs.reduce((n, x) => n + bytes(x) + gap, 0);
1842
+ if (size(items) <= budget) return items;
1843
+ const first = items[0];
1844
+ const noteSize = bytes(note(items.length)) + gap;
1845
+ const keepFirst = items.length > 1 && bytes(first) + gap + noteSize <= budget / 3;
1846
+ let room = budget - noteSize - (keepFirst ? bytes(first) + gap : 0);
1847
+ const latest = [];
1848
+ for (let i = items.length - 1; i >= (keepFirst ? 1 : 0); i--) {
1849
+ const c = bytes(items[i]) + gap;
1850
+ if (c > room) {
1851
+ if (!latest.length) latest.unshift(clipBytes(items[i], Math.max(0, room - gap)));
1852
+ break;
1853
+ }
1854
+ room -= c;
1855
+ latest.unshift(items[i]);
1856
+ }
1857
+ const left = items.length - latest.length - (keepFirst ? 1 : 0);
1858
+ return [...keepFirst ? [first] : [], ...left ? [note(left)] : [], ...latest];
1859
+ }
1860
+ function webMessage(d, s, kind, max = WEB_MAX_BYTES) {
1861
+ const r = new Redactor();
1862
+ const a = (x) => wellFormed(r.apply(stripControl(x))).trim();
1863
+ const agent = speakerLabel(d.from.tool);
1864
+ const turns = conversation(s, a);
1865
+ const unanswered = turns.at(-1)?.who === "me" ? `(The chat stopped here, before ${agent} answered.)` : null;
1866
+ const parts = [`This is the context from Harlay. ${WHY[d.from.reason](targetLabel(d.from.tool))} Pick up where it left off. Don't start over and don't repeat what was already said.`];
1867
+ const room = (rest) => max - bytes([...parts, ...rest].join(SEP)) - 2 * bytes(SEP);
1868
+ const done = () => ({ text: clipBytes(parts.join(SEP), max), redactions: { ...r.counts } });
1869
+ const left = (n) => `(${n} earlier message${n === 1 ? "" : "s"} left out)`;
1870
+ if (kind === "chat") {
1871
+ const items = turns.map(
1872
+ (t, i) => t.who === "me" ? `Me: ${clipBytes(t.text, USER_MAX)}` : `${agent}: ${clipBytes(t.text, i === turns.length - 1 ? LAST_AGENT_MAX : AGENT_MAX)}`
1873
+ );
1874
+ const tail2 = unanswered ? [unanswered] : [];
1875
+ if (items.length) parts.push("Conversation so far:");
1876
+ parts.push(...fitList(items, room(tail2), left), ...tail2);
1877
+ return done();
1878
+ }
1879
+ const branch = d.git?.branch ? ` on branch ${d.git.branch}` : "";
1880
+ parts.push(`It's a coding task in my project ${(0, import_node_path12.basename)(d.workdir)}${branch}. You can't see my files, so ask me to paste any you need.`);
1881
+ parts.push(`Task: ${a(d.title)}`);
1882
+ const facts = [];
1883
+ if (d.whereItStopped && !unanswered) facts.push(`${agent}'s last message:
1884
+ ${clipBytes(a(d.whereItStopped), LAST_AGENT_MAX)}`);
1885
+ if (unanswered) facts.push(workedAfterLastMessage(s) ? `(It stopped while working on my last message, before ${agent} replied.)` : unanswered);
1886
+ const changed = [.../* @__PURE__ */ new Set([...d.git?.files.map((f) => f.path) ?? [], ...d.filesTouched])];
1887
+ if (changed.length) facts.push(`Files changed: ${changed.slice(0, 15).map(a).join(", ")}${changed.length > 15 ? ` and ${changed.length - 15} more` : ""}`);
1888
+ if (d.lastFailure) {
1889
+ const out = d.lastFailure.output.trim().split("\n").slice(-25).join("\n");
1890
+ facts.push(`Still failing: \`${a(d.lastFailure.cmd)}\` (exit ${d.lastFailure.exitCode})${out ? `
1891
+ ${clipBytes(a(out), 1500)}` : ""}`);
1892
+ }
1893
+ if (d.todos.length) facts.push(`Still to do:
1894
+ ${d.todos.map((t) => `- ${a(t.text)}${t.status === "in_progress" ? " (in progress)" : ""}`).join("\n")}`);
1895
+ const diff = d.git?.diff ? a(d.git.diff) : "";
1896
+ const heading = "What I asked, oldest first:";
1897
+ if (d.userMessages.length) {
1898
+ const asked = d.userMessages.map((m) => `- ${clipBytes(a(m), USER_MAX)}`);
1899
+ const free = room([heading, ...facts]);
1900
+ const budget = diff ? Math.max(free - DIFF_MIN, free * 0.6) : free;
1901
+ const list = fitList(asked, budget, (n) => `- ${left(n + d.omittedMessages)}`, "\n");
1902
+ if (d.omittedMessages && list.length === asked.length) list.splice(1, 0, `- ${left(d.omittedMessages)}`);
1903
+ parts.push(`${heading}
1904
+ ${list.join("\n")}`);
1905
+ }
1906
+ parts.push(...facts);
1907
+ const diffHead = "Uncommitted changes (git diff):";
1908
+ const diffRoom = room([diffHead]) - bytes("\n");
1909
+ if (diff && diffRoom >= 600) parts.push(`${diffHead}
1910
+ ${bytes(diff) <= diffRoom ? diff : clipLines(diff, diffRoom)}`);
1911
+ return done();
1912
+ }
1913
+ function clipLines(s, max) {
1914
+ const out = [];
1915
+ let n = bytes("\n\u2026(cut)");
1916
+ for (const line of s.split("\n")) {
1917
+ const c = bytes(`${line}
1918
+ `);
1919
+ if (n + c > max) break;
1920
+ n += c;
1921
+ out.push(line);
1922
+ }
1923
+ return [...out, "\u2026(cut)"].join("\n");
1924
+ }
1925
+ function workedAfterLastMessage(s) {
1926
+ for (let i = s.timeline.length - 1; i >= 0; i--) {
1927
+ const t = s.timeline[i];
1928
+ if (t.kind === "user") return false;
1929
+ if (t.kind === "command" || t.kind === "edit") return true;
1930
+ }
1931
+ return false;
1932
+ }
1933
+ function conversation(s, a) {
1934
+ const turns = [];
1935
+ for (const t of s.timeline) {
1936
+ if (t.kind === "user") {
1937
+ const text = a(splitHandoffPrompt(t.text).rest);
1938
+ if (!text || /^\/[\w:-]+$/.test(text.split(/\s/)[0]) && text.length < 200) continue;
1939
+ turns.push({ who: "me", text });
1940
+ } else if (t.kind === "assistant") {
1941
+ const text = a(t.text);
1942
+ if (!text) continue;
1943
+ const last = turns.at(-1);
1944
+ if (last?.who === "agent") last.text += `
1945
+
1946
+ ${text}`;
1947
+ else turns.push({ who: "agent", text });
1948
+ }
1949
+ }
1950
+ return turns;
1951
+ }
1952
+
1727
1953
  // ../../packages/core/src/sources/index.ts
1728
1954
  function createReaders(o) {
1729
1955
  return [createClaudeReader({ home: o.home }), createCodexReader({ home: o.home }), createCursorReader({ home: o.home, slugRoot: o.slugRoot })];
@@ -1758,17 +1984,22 @@ function parseSessionSpec(spec) {
1758
1984
  }
1759
1985
 
1760
1986
  // ../../packages/core/src/handoff.ts
1761
- var import_promises9 = require("node:fs/promises");
1987
+ var import_promises10 = require("node:fs/promises");
1762
1988
  var import_node_os2 = require("node:os");
1763
- var import_node_path10 = require("node:path");
1989
+ var import_node_path13 = require("node:path");
1764
1990
  async function launchHandoff(o) {
1991
+ if (isWebChat(o.target)) {
1992
+ const plan2 = { target: o.target, mode: "web", steps: [{ kind: "open", args: [webChatUrl(o.target, o.prompt)] }] };
1993
+ await executeLaunch(plan2, o.runner);
1994
+ return plan2;
1995
+ }
1765
1996
  const binaries = await resolveBinaries(o.resolve ?? defaultResolveOptions());
1766
1997
  const plan = planLaunch({
1767
1998
  target: o.target,
1768
1999
  mode: o.mode,
1769
2000
  cwd: o.cwd,
1770
2001
  prompt: o.prompt,
1771
- scriptDir: (0, import_node_path10.join)(o.scriptDir ?? (0, import_node_path10.join)((0, import_node_os2.tmpdir)(), "harlay"), o.id),
2002
+ scriptDir: (0, import_node_path13.join)(o.scriptDir ?? (0, import_node_path13.join)((0, import_node_os2.tmpdir)(), "harlay"), o.id),
1772
2003
  binaries,
1773
2004
  terminalApp: o.terminalApp ?? "Terminal"
1774
2005
  });
@@ -1779,7 +2010,7 @@ async function runHandoff(o) {
1779
2010
  const cwd = o.session.cwd;
1780
2011
  if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
1781
2012
  try {
1782
- if (!(await (0, import_promises9.stat)(cwd)).isDirectory()) throw new Error("not a directory");
2013
+ if (!(await (0, import_promises10.stat)(cwd)).isDirectory()) throw new Error("not a directory");
1783
2014
  } catch {
1784
2015
  throw new HarlayError(`The session's folder no longer exists: ${cwd}`);
1785
2016
  }
@@ -1793,9 +2024,11 @@ async function runHandoff(o) {
1793
2024
  filesChanged: git2 ? git2.files.length : data.filesTouched.length,
1794
2025
  chars: sessionChars(session)
1795
2026
  });
1796
- const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
1797
- o.onProgress?.({ stage: "written", path: written.path, chars: written.chars });
1798
- if (o.launch === false) return { written, plan: null, data };
2027
+ const kind = chatKind(session, git2 !== null);
2028
+ const web = isWebChat(o.target) ? o.attach ? webAttachPrompt(data.from.tool, data.from.reason) : webMessage(data, session, kind).text : null;
2029
+ const written = await writeHandoff(data, { target: o.target, mode: web === null ? o.mode : "web", previousId: data.carriedFrom, store: o.store, prompt: web ?? void 0 });
2030
+ o.onProgress?.({ stage: "written", path: written.path, chars: web !== null && !o.attach ? web.length : written.chars });
2031
+ if (o.launch === false) return { written, plan: null, data, kind };
1799
2032
  let plan;
1800
2033
  try {
1801
2034
  plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
@@ -1803,7 +2036,7 @@ async function runHandoff(o) {
1803
2036
  const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
1804
2037
  throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
1805
2038
  }
1806
- return { written, plan, data };
2039
+ return { written, plan, data, kind };
1807
2040
  }
1808
2041
  function sessionChars(s) {
1809
2042
  let n = 0;
@@ -1851,8 +2084,8 @@ async function checkDelivery(readers2, h) {
1851
2084
  // ../../packages/core/src/watch/watcher.ts
1852
2085
  var import_node_events = require("node:events");
1853
2086
  var import_node_fs3 = require("node:fs");
1854
- var import_promises10 = require("node:fs/promises");
1855
- var import_node_path11 = require("node:path");
2087
+ var import_promises11 = require("node:fs/promises");
2088
+ var import_node_path14 = require("node:path");
1856
2089
  var import_node_string_decoder = require("node:string_decoder");
1857
2090
  var Watcher = class extends import_node_events.EventEmitter {
1858
2091
  constructor(readers2, opts = {}) {
@@ -1876,14 +2109,14 @@ var Watcher = class extends import_node_events.EventEmitter {
1876
2109
  for (const r of this.readers) {
1877
2110
  for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
1878
2111
  try {
1879
- this.offsets.set(f.path, (await (0, import_promises10.stat)(f.path)).size);
2112
+ this.offsets.set(f.path, (await (0, import_promises11.stat)(f.path)).size);
1880
2113
  } catch {
1881
2114
  }
1882
2115
  }
1883
2116
  if (this.opts.fsWatch === false) continue;
1884
2117
  try {
1885
2118
  const w = (0, import_node_fs3.watch)(r.root, { recursive: true }, (_event, name) => {
1886
- if (name) this.schedule((0, import_node_path11.join)(r.root, name.toString()));
2119
+ if (name) this.schedule((0, import_node_path14.join)(r.root, name.toString()));
1887
2120
  });
1888
2121
  w.on("error", (e) => this.emit("error", e));
1889
2122
  this.watchers.push(w);
@@ -1904,7 +2137,7 @@ var Watcher = class extends import_node_events.EventEmitter {
1904
2137
  for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
1905
2138
  let size;
1906
2139
  try {
1907
- size = (await (0, import_promises10.stat)(f.path)).size;
2140
+ size = (await (0, import_promises11.stat)(f.path)).size;
1908
2141
  } catch {
1909
2142
  continue;
1910
2143
  }
@@ -1961,7 +2194,7 @@ var Watcher = class extends import_node_events.EventEmitter {
1961
2194
  if (!reader) return [];
1962
2195
  let size;
1963
2196
  try {
1964
- size = (await (0, import_promises10.stat)(file)).size;
2197
+ size = (await (0, import_promises11.stat)(file)).size;
1965
2198
  } catch {
1966
2199
  return [];
1967
2200
  }
@@ -1973,7 +2206,7 @@ var Watcher = class extends import_node_events.EventEmitter {
1973
2206
  }
1974
2207
  if (size === offset) return [];
1975
2208
  const buf = Buffer.alloc(size - offset);
1976
- const fh = await (0, import_promises10.open)(file, "r");
2209
+ const fh = await (0, import_promises11.open)(file, "r");
1977
2210
  try {
1978
2211
  await fh.read(buf, 0, buf.length, offset);
1979
2212
  } finally {
@@ -2050,8 +2283,8 @@ function fullestWindow(s) {
2050
2283
 
2051
2284
  // ../../packages/core/src/usage/codex.ts
2052
2285
  var import_node_child_process3 = require("node:child_process");
2053
- var import_promises11 = require("node:fs/promises");
2054
- var import_node_path12 = require("node:path");
2286
+ var import_promises12 = require("node:fs/promises");
2287
+ var import_node_path15 = require("node:path");
2055
2288
  var PACE_MS = 60 * 6e4;
2056
2289
  var TAIL_BYTES = 1024 * 1024;
2057
2290
  function num(x) {
@@ -2075,11 +2308,11 @@ function planOf(rl) {
2075
2308
  const p = rl.plan_type ?? rl.planType;
2076
2309
  return typeof p === "string" ? p : null;
2077
2310
  }
2078
- async function readTail(file, bytes) {
2079
- const fh = await (0, import_promises11.open)(file, "r");
2311
+ async function readTail(file, bytes2) {
2312
+ const fh = await (0, import_promises12.open)(file, "r");
2080
2313
  try {
2081
2314
  const { size } = await fh.stat();
2082
- const start = Math.max(0, size - bytes);
2315
+ const start = Math.max(0, size - bytes2);
2083
2316
  const buf = Buffer.alloc(size - start);
2084
2317
  await fh.read(buf, 0, buf.length, start);
2085
2318
  const text = buf.toString("utf8");
@@ -2089,7 +2322,7 @@ async function readTail(file, bytes) {
2089
2322
  }
2090
2323
  }
2091
2324
  async function readCodexReadings(home2, files = 8) {
2092
- const logs = (await listSessionFiles((0, import_node_path12.join)(home2, ".codex", "sessions"), (f) => f.endsWith(".jsonl"))).slice(0, files);
2325
+ const logs = (await listSessionFiles((0, import_node_path15.join)(home2, ".codex", "sessions"), (f) => f.endsWith(".jsonl"))).slice(0, files);
2093
2326
  const out = [];
2094
2327
  for (const { path } of logs) {
2095
2328
  let text;
@@ -2193,8 +2426,8 @@ function queryCodexUsage(codexBin, timeoutMs = 15e3) {
2193
2426
  }
2194
2427
 
2195
2428
  // ../../packages/core/src/usage/claude.ts
2196
- var import_promises12 = require("node:fs/promises");
2197
- var import_node_path13 = require("node:path");
2429
+ var import_promises13 = require("node:fs/promises");
2430
+ var import_node_path16 = require("node:path");
2198
2431
  var HOUR = 36e5;
2199
2432
  var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
2200
2433
  var DISPROVE_FACTOR = 1.15;
@@ -2222,18 +2455,18 @@ var ClaudeUsageLog = class {
2222
2455
  replies = /* @__PURE__ */ new Map();
2223
2456
  hits = /* @__PURE__ */ new Map();
2224
2457
  async refresh(now = /* @__PURE__ */ new Date(), maxAgeMs = 8 * 24 * HOUR) {
2225
- const root = (0, import_node_path13.join)(this.home, ".claude", "projects");
2458
+ const root = (0, import_node_path16.join)(this.home, ".claude", "projects");
2226
2459
  let names;
2227
2460
  try {
2228
- names = await (0, import_promises12.readdir)(root, { recursive: true });
2461
+ names = await (0, import_promises13.readdir)(root, { recursive: true });
2229
2462
  } catch {
2230
2463
  return;
2231
2464
  }
2232
2465
  for (const name of names) {
2233
2466
  if (!name.endsWith(".jsonl")) continue;
2234
- const path = (0, import_node_path13.join)(root, name);
2467
+ const path = (0, import_node_path16.join)(root, name);
2235
2468
  try {
2236
- const s = await (0, import_promises12.stat)(path);
2469
+ const s = await (0, import_promises13.stat)(path);
2237
2470
  if (now.getTime() - s.mtimeMs > maxAgeMs) continue;
2238
2471
  await this.readFrom(path, s.size);
2239
2472
  } catch {
@@ -2247,7 +2480,7 @@ var ClaudeUsageLog = class {
2247
2480
  const st = this.files.get(path) ?? { offset: 0, partial: "" };
2248
2481
  if (size < st.offset) Object.assign(st, { offset: 0, partial: "" });
2249
2482
  if (size === st.offset) return;
2250
- const fh = await (0, import_promises12.open)(path, "r");
2483
+ const fh = await (0, import_promises13.open)(path, "r");
2251
2484
  try {
2252
2485
  const buf = Buffer.alloc(size - st.offset);
2253
2486
  await fh.read(buf, 0, buf.length, st.offset);
@@ -2452,18 +2685,18 @@ function claudeSnapshot({ entries, hits, exact, samples, now }) {
2452
2685
  }
2453
2686
 
2454
2687
  // ../../packages/core/src/usage/bridge.ts
2455
- var import_promises13 = require("node:fs/promises");
2456
- var import_node_path14 = require("node:path");
2688
+ var import_promises14 = require("node:fs/promises");
2689
+ var import_node_path17 = require("node:path");
2457
2690
  var BRIDGE_MARKER = "# harlay-usage";
2458
2691
  function claudeSettingsPath(home2) {
2459
- return (0, import_node_path14.join)(home2, ".claude", "settings.json");
2692
+ return (0, import_node_path17.join)(home2, ".claude", "settings.json");
2460
2693
  }
2461
2694
  function claudeStatusFile(dataDir2) {
2462
- return (0, import_node_path14.join)(dataDir2, "claude-status.json");
2695
+ return (0, import_node_path17.join)(dataDir2, "claude-status.json");
2463
2696
  }
2464
- var scriptPath = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.sh");
2465
- var previousJson = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.previous.json");
2466
- var previousCommand = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.previous");
2697
+ var scriptPath = (dataDir2) => (0, import_node_path17.join)(dataDir2, "claude-statusline.sh");
2698
+ var previousJson = (dataDir2) => (0, import_node_path17.join)(dataDir2, "claude-statusline.previous.json");
2699
+ var previousCommand = (dataDir2) => (0, import_node_path17.join)(dataDir2, "claude-statusline.previous");
2467
2700
  var BRIDGE_SCRIPT = `#!/bin/sh
2468
2701
  # Harlay's Claude Code status line. Saves Claude Code's status line input (it includes your exact usage)
2469
2702
  # for Harlay, then shows your previous status line, or a short usage summary if you had none.
@@ -2498,7 +2731,7 @@ function isOurs(statusLine) {
2498
2731
  async function readSettings(path) {
2499
2732
  let text;
2500
2733
  try {
2501
- text = await (0, import_promises13.readFile)(path, "utf8");
2734
+ text = await (0, import_promises14.readFile)(path, "utf8");
2502
2735
  } catch (e) {
2503
2736
  if (e.code === "ENOENT") return {};
2504
2737
  throw e;
@@ -2509,8 +2742,8 @@ async function readSettings(path) {
2509
2742
  }
2510
2743
  async function writeAtomic(path, text) {
2511
2744
  const tmp = `${path}.harlay-tmp`;
2512
- await (0, import_promises13.writeFile)(tmp, text);
2513
- await (0, import_promises13.rename)(tmp, path);
2745
+ await (0, import_promises14.writeFile)(tmp, text);
2746
+ await (0, import_promises14.rename)(tmp, path);
2514
2747
  }
2515
2748
  async function bridgeState(home2) {
2516
2749
  try {
@@ -2522,9 +2755,9 @@ async function bridgeState(home2) {
2522
2755
  async function installBridge(home2, dataDir2) {
2523
2756
  const path = claudeSettingsPath(home2);
2524
2757
  const s = await readSettings(path);
2525
- await (0, import_promises13.mkdir)(dataDir2, { recursive: true });
2526
- await (0, import_promises13.writeFile)(scriptPath(dataDir2), BRIDGE_SCRIPT);
2527
- await (0, import_promises13.chmod)(scriptPath(dataDir2), 493);
2758
+ await (0, import_promises14.mkdir)(dataDir2, { recursive: true });
2759
+ await (0, import_promises14.writeFile)(scriptPath(dataDir2), BRIDGE_SCRIPT);
2760
+ await (0, import_promises14.chmod)(scriptPath(dataDir2), 493);
2528
2761
  if (isOurs(s.statusLine)) return;
2529
2762
  const prev = s.statusLine;
2530
2763
  if (prev !== void 0) {
@@ -2532,11 +2765,11 @@ async function installBridge(home2, dataDir2) {
2532
2765
  const cmd = isObj3(prev) && typeof prev.command === "string" ? prev.command : "";
2533
2766
  await writeAtomic(previousCommand(dataDir2), cmd);
2534
2767
  } else {
2535
- await (0, import_promises13.rm)(previousJson(dataDir2), { force: true });
2536
- await (0, import_promises13.rm)(previousCommand(dataDir2), { force: true });
2768
+ await (0, import_promises14.rm)(previousJson(dataDir2), { force: true });
2769
+ await (0, import_promises14.rm)(previousCommand(dataDir2), { force: true });
2537
2770
  }
2538
2771
  s.statusLine = { ...isObj3(prev) ? prev : {}, type: "command", command: bridgeCommand(dataDir2) };
2539
- await (0, import_promises13.mkdir)((0, import_node_path14.join)(home2, ".claude"), { recursive: true });
2772
+ await (0, import_promises14.mkdir)((0, import_node_path17.join)(home2, ".claude"), { recursive: true });
2540
2773
  await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2541
2774
  }
2542
2775
  async function removeBridge(home2, dataDir2) {
@@ -2545,19 +2778,19 @@ async function removeBridge(home2, dataDir2) {
2545
2778
  if (!isOurs(s.statusLine)) return;
2546
2779
  let prev;
2547
2780
  try {
2548
- prev = JSON.parse(await (0, import_promises13.readFile)(previousJson(dataDir2), "utf8"));
2781
+ prev = JSON.parse(await (0, import_promises14.readFile)(previousJson(dataDir2), "utf8"));
2549
2782
  } catch {
2550
2783
  prev = void 0;
2551
2784
  }
2552
2785
  if (prev === void 0) delete s.statusLine;
2553
2786
  else s.statusLine = prev;
2554
2787
  await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
2555
- await (0, import_promises13.rm)(previousJson(dataDir2), { force: true });
2556
- await (0, import_promises13.rm)(previousCommand(dataDir2), { force: true });
2788
+ await (0, import_promises14.rm)(previousJson(dataDir2), { force: true });
2789
+ await (0, import_promises14.rm)(previousCommand(dataDir2), { force: true });
2557
2790
  }
2558
2791
 
2559
2792
  // ../../packages/core/src/health/context.ts
2560
- var import_promises14 = require("node:fs/promises");
2793
+ var import_promises15 = require("node:fs/promises");
2561
2794
  var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
2562
2795
  function parse(line) {
2563
2796
  try {
@@ -2591,7 +2824,7 @@ function scanLine(tool, line, s) {
2591
2824
  }
2592
2825
  }
2593
2826
  async function readFrom(file, start, end) {
2594
- const fh = await (0, import_promises14.open)(file, "r");
2827
+ const fh = await (0, import_promises15.open)(file, "r");
2595
2828
  try {
2596
2829
  const buf = Buffer.alloc(end - start);
2597
2830
  const { bytesRead } = await fh.read(buf, 0, buf.length, start);
@@ -2606,7 +2839,7 @@ var ContextTracker = class {
2606
2839
  if (ref.tool === "cursor") return null;
2607
2840
  let size;
2608
2841
  try {
2609
- size = (await (0, import_promises14.stat)(ref.file)).size;
2842
+ size = (await (0, import_promises15.stat)(ref.file)).size;
2610
2843
  } catch {
2611
2844
  return null;
2612
2845
  }
@@ -2628,10 +2861,10 @@ var ContextTracker = class {
2628
2861
  };
2629
2862
 
2630
2863
  // ../../packages/core/src/health/tail.ts
2631
- var import_promises15 = require("node:fs/promises");
2864
+ var import_promises16 = require("node:fs/promises");
2632
2865
  var TAIL_BYTES2 = 15e5;
2633
2866
  async function readSessionTail(ref, maxBytes = TAIL_BYTES2) {
2634
- const fh = await (0, import_promises15.open)(ref.file, "r");
2867
+ const fh = await (0, import_promises16.open)(ref.file, "r");
2635
2868
  let text;
2636
2869
  try {
2637
2870
  const { size } = await fh.stat();
@@ -2651,7 +2884,7 @@ async function readSessionTail(ref, maxBytes = TAIL_BYTES2) {
2651
2884
  }
2652
2885
 
2653
2886
  // ../../packages/core/src/health/signals.ts
2654
- var import_node_path15 = require("node:path");
2887
+ var import_node_path18 = require("node:path");
2655
2888
 
2656
2889
  // ../../packages/core/src/extract/rules.ts
2657
2890
  var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
@@ -2842,7 +3075,7 @@ function rereadSignals(t, cwd = null) {
2842
3075
  }
2843
3076
  }
2844
3077
  const [path, n] = [...reads].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
2845
- if (n >= SIGNALS.rereads) return [{ weight: 1, behavior: true, text: `Re-read ${(0, import_node_path15.basename)(path)} ${n} times without changing it` }];
3078
+ if (n >= SIGNALS.rereads) return [{ weight: 1, behavior: true, text: `Re-read ${(0, import_node_path18.basename)(path)} ${n} times without changing it` }];
2846
3079
  const [cmd, m] = [...lookups].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
2847
3080
  return m >= SIGNALS.rereads ? [{ weight: 1, behavior: true, text: `Ran \`${oneLine(shown.get(cmd) ?? cmd, 40)}\` ${m} times` }] : [];
2848
3081
  }
@@ -2851,7 +3084,7 @@ function churnSignals(t) {
2851
3084
  const edits = /* @__PURE__ */ new Map();
2852
3085
  for (const i of t) if (i.kind === "edit") edits.set(i.edit.path, (edits.get(i.edit.path) ?? 0) + 1);
2853
3086
  const [path, n] = [...edits].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
2854
- return n >= SIGNALS.churnEdits ? [{ weight: 1, behavior: true, text: `Edited ${(0, import_node_path15.basename)(path)} ${n} times while things kept failing` }] : [];
3087
+ return n >= SIGNALS.churnEdits ? [{ weight: 1, behavior: true, text: `Edited ${(0, import_node_path18.basename)(path)} ${n} times while things kept failing` }] : [];
2855
3088
  }
2856
3089
  var NEGATIVE_RE = /\b(never|don'?t|do not|avoid|stop|no)\b/i;
2857
3090
  var JS_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
@@ -2888,7 +3121,7 @@ function ruleCheck(rule) {
2888
3121
  const targets = [...rule.matchAll(PATH_RE)].map((m) => (m[1] ?? m[2]).trim()).filter((x) => x.length > 1);
2889
3122
  if (/\b(edit|touch|modify|change|delete|remove|rename|overwrite)\b/.test(r) && targets.some((x) => /[./]/.test(x))) {
2890
3123
  const files = targets.filter((x) => /[./]/.test(x));
2891
- return (i) => i.kind === "edit" && files.some((f) => i.edit.path.endsWith(f) || i.edit.path.includes(`/${f}`)) ? `edited ${(0, import_node_path15.basename)(i.edit.path)}` : null;
3124
+ return (i) => i.kind === "edit" && files.some((f) => i.edit.path.endsWith(f) || i.edit.path.includes(`/${f}`)) ? `edited ${(0, import_node_path18.basename)(i.edit.path)}` : null;
2892
3125
  }
2893
3126
  const quoted = [...rule.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim());
2894
3127
  if (quoted.length && /\b(run|use|call|execute)\b/.test(r)) return (i) => i.kind === "command" && quoted.some((q) => bare(i.run.cmd).startsWith(q)) ? ran(i) : null;
@@ -2960,12 +3193,125 @@ function assessHealth(s, context) {
2960
3193
  return { state: state2, reasons, context };
2961
3194
  }
2962
3195
 
3196
+ // ../../packages/core/src/webchat.ts
3197
+ var import_node_path19 = require("node:path");
3198
+ function isLimitText(s) {
3199
+ const t = s.replace(/\s+/g, " ");
3200
+ if (t.length > 400) return false;
3201
+ if (!/\blimits?\b|out of (?:free )?messages/i.test(t)) return false;
3202
+ return /\b(?:hit|reached|exceeded|out of|resets?|reset at|until|try again)\b/i.test(t);
3203
+ }
3204
+ function limitResetsAt(s, now) {
3205
+ const inHours = /\bin (\d+) hours?\b/i.exec(s);
3206
+ if (inHours) return new Date(now.getTime() + Number(inHours[1]) * 36e5);
3207
+ const inMinutes = /\bin (\d+) min(?:ute)?s?\b/i.exec(s);
3208
+ if (inMinutes) return new Date(now.getTime() + Number(inMinutes[1]) * 6e4);
3209
+ const m = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i.exec(s);
3210
+ if (!m) return null;
3211
+ let h = Number(m[1]) % 12;
3212
+ if (m[3].toLowerCase() === "pm") h += 12;
3213
+ const at = new Date(now);
3214
+ at.setHours(h, m[2] ? Number(m[2]) : 0, 0, 0);
3215
+ if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 1);
3216
+ return at;
3217
+ }
3218
+ function webChatTitle(c) {
3219
+ const t = c.title.replace(/\s*[-|–]\s*(?:Claude|ChatGPT(?: Mobile)?)\s*$/i, "").trim();
3220
+ if (t && !/^(?:Claude|ChatGPT|New chat|New session)$/i.test(t)) return t;
3221
+ const first = c.turns.find((x) => x.who === "me");
3222
+ return first ? oneLine(first.text, 60) : "Web chat";
3223
+ }
3224
+ var pad = (n) => String(n).padStart(2, "0");
3225
+ function webChatData(c, o) {
3226
+ const r = new Redactor();
3227
+ const a = (s) => wellFormed(r.apply(stripControl(s))).trim();
3228
+ const turns = c.turns.map((t) => ({ who: t.who, text: a(t.text) })).filter((t) => t.text);
3229
+ const mine = turns.filter((t) => t.who === "me").map((t) => t.text);
3230
+ const picked = pickUserMessages(mine);
3231
+ const lastReply = [...turns].reverse().find((t) => t.who === "agent") ?? null;
3232
+ const title = a(webChatTitle(c));
3233
+ const speaker = speakerLabel(c.chat);
3234
+ const n = o.now;
3235
+ return {
3236
+ baseId: `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(title)}`,
3237
+ title,
3238
+ createdAt: n,
3239
+ from: {
3240
+ tool: c.chat,
3241
+ model: null,
3242
+ session: c.url,
3243
+ reason: c.limit ? "usage_limit" : "manual",
3244
+ resetsAt: c.limit ? limitResetsAt(c.limit, n) : null,
3245
+ message: c.limit
3246
+ },
3247
+ workdir: o.git?.root ?? o.workdir,
3248
+ git: o.git,
3249
+ originalGoal: mine[0] ?? null,
3250
+ userMessages: picked.messages,
3251
+ omittedMessages: picked.omitted,
3252
+ whereItStopped: lastReply && turns.at(-1)?.who === "agent" ? clipWords(lastReply.text, 150) : null,
3253
+ lastRequest: picked.messages.at(-1) ?? null,
3254
+ todos: [],
3255
+ results: [],
3256
+ references: { read: [], attached: [], urls: [c.url] },
3257
+ filesTouched: [],
3258
+ lastFailure: null,
3259
+ transcript: [`# Conversation: ${title}`, "", `Source: ${targetLabel(c.chat)}, ${c.url}`, "", ...turns.map((t) => `**${t.who === "me" ? "User" : speaker}:**
3260
+ ${t.text}
3261
+ `)].join("\n"),
3262
+ unknownRecords: 0,
3263
+ redactions: { ...r.counts },
3264
+ carriedFrom: null
3265
+ };
3266
+ }
3267
+ function webChatTimeline(c) {
3268
+ return c.turns.map((t) => t.who === "me" ? { kind: "user", text: t.text } : { kind: "assistant", text: t.text });
3269
+ }
3270
+ async function runWebChatHandoff(o) {
3271
+ const now = o.now ?? /* @__PURE__ */ new Date();
3272
+ if (isWebChat(o.target)) {
3273
+ if (o.target === o.capture.chat) throw new HarlayError(`This chat is already in ${targetLabel(o.target)}.`);
3274
+ const d2 = webChatData(o.capture, { now, workdir: "", git: null });
3275
+ let written2 = null;
3276
+ let prompt;
3277
+ if (o.attach) {
3278
+ prompt = webAttachPrompt(o.capture.chat, d2.from.reason);
3279
+ written2 = await writeHandoff(d2, { target: o.target, mode: "web", previousId: null, store: o.store, prompt, dir: (0, import_node_path19.join)(o.store, "web") });
3280
+ } else prompt = webMessage(d2, { timeline: webChatTimeline(o.capture) }, "chat").text;
3281
+ if (o.launch === false) return { written: written2, prompt, plan: null, data: d2 };
3282
+ const plan = await launchHandoff({ target: o.target, mode: o.mode, cwd: d2.workdir, id: d2.baseId, prompt, runner: o.runner });
3283
+ return { written: written2, prompt, plan, data: d2 };
3284
+ }
3285
+ if (!o.workdir) throw new HarlayError(`Pick a folder to open ${targetLabel(o.target)} in.`);
3286
+ const git2 = await takeGitSnapshot(o.workdir);
3287
+ const d = webChatData(o.capture, { now, workdir: o.workdir, git: git2 });
3288
+ const written = await writeHandoff(d, { target: o.target, mode: o.mode, previousId: null, store: o.store });
3289
+ if (o.launch === false) return { written, prompt: written.prompt, plan: null, data: d };
3290
+ try {
3291
+ const plan = await launchHandoff({
3292
+ target: o.target,
3293
+ mode: o.mode,
3294
+ cwd: d.workdir,
3295
+ id: written.id,
3296
+ prompt: written.prompt,
3297
+ runner: o.runner,
3298
+ resolve: o.resolve,
3299
+ terminalApp: o.terminalApp,
3300
+ scriptDir: o.scriptDir
3301
+ });
3302
+ return { written, prompt: written.prompt, plan, data: d };
3303
+ } catch (e) {
3304
+ const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
3305
+ throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
3306
+ }
3307
+ }
3308
+
2963
3309
  // src/menu.ts
2964
- var import_node_path17 = require("node:path");
3310
+ var import_node_path21 = require("node:path");
2965
3311
 
2966
3312
  // src/settings.ts
2967
- var import_promises16 = require("node:fs/promises");
2968
- var import_node_path16 = require("node:path");
3313
+ var import_promises17 = require("node:fs/promises");
3314
+ var import_node_path20 = require("node:path");
2969
3315
  var THEMES = ["system", "light", "dark"];
2970
3316
  var isTheme = (x) => THEMES.includes(x);
2971
3317
  var MAX_PINNED = 12;
@@ -2983,12 +3329,14 @@ var DEFAULT_SETTINGS = {
2983
3329
  openAtLogin: true,
2984
3330
  theme: "system",
2985
3331
  claudeBridgeAsked: false,
2986
- pinned: []
3332
+ pinned: [],
3333
+ webChats: false,
3334
+ agentFolder: null
2987
3335
  };
2988
3336
  async function loadSettings(file) {
2989
3337
  let raw = {};
2990
3338
  try {
2991
- raw = JSON.parse(await (0, import_promises16.readFile)(file, "utf8"));
3339
+ raw = JSON.parse(await (0, import_promises17.readFile)(file, "utf8"));
2992
3340
  } catch {
2993
3341
  raw = {};
2994
3342
  }
@@ -3000,6 +3348,8 @@ async function loadSettings(file) {
3000
3348
  if (typeof r.openAtLogin === "boolean") s.openAtLogin = r.openAtLogin;
3001
3349
  if (isTheme(r.theme)) s.theme = r.theme;
3002
3350
  if (typeof r.claudeBridgeAsked === "boolean") s.claudeBridgeAsked = r.claudeBridgeAsked;
3351
+ if (typeof r.webChats === "boolean") s.webChats = r.webChats;
3352
+ if (typeof r.agentFolder === "string" && r.agentFolder.startsWith("/")) s.agentFolder = r.agentFolder;
3003
3353
  if (typeof r.terminalApp === "string" && r.terminalApp.trim()) s.terminalApp = r.terminalApp.trim();
3004
3354
  if (typeof r.usageThreshold === "number" && r.usageThreshold > 0 && r.usageThreshold <= 100) s.usageThreshold = r.usageThreshold;
3005
3355
  if (typeof r.modes === "object" && r.modes !== null) {
@@ -3011,8 +3361,8 @@ async function loadSettings(file) {
3011
3361
  return s;
3012
3362
  }
3013
3363
  async function saveSettings(file, s) {
3014
- await (0, import_promises16.mkdir)((0, import_node_path16.dirname)(file), { recursive: true });
3015
- await (0, import_promises16.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
3364
+ await (0, import_promises17.mkdir)((0, import_node_path20.dirname)(file), { recursive: true });
3365
+ await (0, import_promises17.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
3016
3366
  }
3017
3367
 
3018
3368
  // src/state.ts
@@ -3074,8 +3424,8 @@ function markHandedOff(state2, key, target, now, delivery) {
3074
3424
  if (!t) return null;
3075
3425
  t.status = "handed_off";
3076
3426
  t.updatedAt = now;
3077
- t.delivery = delivery ? { ...delivery, target, sentAt: now, state: "waiting" } : void 0;
3078
- t.detail = t.delivery ? deliveryDetail(t.delivery) : `Handed off to ${TOOL_LABEL[target]}`;
3427
+ t.delivery = delivery && !isWebChat(target) ? { ...delivery, target, sentAt: now, state: "waiting" } : void 0;
3428
+ t.detail = t.delivery ? deliveryDetail(t.delivery) : `Handed off to ${targetLabel(target)}${isWebChat(target) ? " \xB7 press Send there" : ""}`;
3079
3429
  return t;
3080
3430
  }
3081
3431
  function pendingDeliveries(state2, now) {
@@ -3113,6 +3463,30 @@ function latestSession(state2) {
3113
3463
  }
3114
3464
  return best;
3115
3465
  }
3466
+ var MAX_WEB_CHATS = 4;
3467
+ var webKey = (url) => `web:${url}`;
3468
+ var isWebKey = (key) => key.startsWith("web:");
3469
+ function rememberWebChat(state2, chats, capture, now) {
3470
+ const key = webKey(capture.url);
3471
+ const resets = capture.limit ? limitResetsAt(capture.limit, now) : null;
3472
+ const entry = {
3473
+ key,
3474
+ capture,
3475
+ status: capture.limit ? "limit" : "idle",
3476
+ detail: capture.limit ? `Limit hit${resets ? ` \xB7 resets ${clock(resets)}` : ""}` : "Idle",
3477
+ updatedAt: now
3478
+ };
3479
+ if (capture.limit && capture.chat === "claude-web") state2.limitedUntil["claude-code"] = resets ?? new Date(now.getTime() + DEFAULT_LIMIT_MS);
3480
+ chats.delete(key);
3481
+ chats.set(key, entry);
3482
+ while (chats.size > MAX_WEB_CHATS) chats.delete(chats.keys().next().value);
3483
+ return entry;
3484
+ }
3485
+ function markWebHandedOff(e, target, now) {
3486
+ e.status = "handed_off";
3487
+ e.updatedAt = now;
3488
+ e.detail = `Handed off to ${targetLabel(target)}${isWebChat(target) ? " \xB7 press Send there" : ""}`;
3489
+ }
3116
3490
 
3117
3491
  // src/menu.ts
3118
3492
  var HOTKEY = { accelerator: "Alt+H", label: "\u2325H" };
@@ -3120,7 +3494,7 @@ var DOT = { limit: "\u25CF", warning: "\u25D0", idle: "\u25CB", handed_off: "\u2
3120
3494
  var MODES = ["app", "terminal"];
3121
3495
  var THEME_LABEL = { system: "System", light: "Light", dark: "Dark" };
3122
3496
  function sessionTitle(t) {
3123
- return t.ref.title ?? (t.ref.cwd ? (0, import_node_path17.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
3497
+ return t.ref.title ?? (t.ref.cwd ? (0, import_node_path21.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
3124
3498
  }
3125
3499
  function buildMenuModel(sessions, settings2, available2) {
3126
3500
  const nodes = [
@@ -3137,7 +3511,8 @@ function buildMenuModel(sessions, settings2, available2) {
3137
3511
  { type: "separator" },
3138
3512
  ...TOOLS.filter((x) => x !== t.ref.tool).map(
3139
3513
  (x) => ({ label: `Hand off to ${TOOL_LABEL[x]}`, enabled: available2[x][settings2.modes[x]], action: { type: "handoff", key, target: x } })
3140
- )
3514
+ ),
3515
+ ...WEB_CHATS.map((x) => ({ label: `Continue in ${WEB_LABEL[x]}`, action: { type: "handoff", key, target: x } }))
3141
3516
  ]
3142
3517
  });
3143
3518
  }
@@ -3178,33 +3553,502 @@ function buildMenuModel(sessions, settings2, available2) {
3178
3553
  }
3179
3554
 
3180
3555
  // src/health.ts
3181
- var import_promises17 = require("node:fs/promises");
3556
+ var import_promises18 = require("node:fs/promises");
3182
3557
  var HealthMonitor = class {
3183
3558
  context = new ContextTracker();
3184
3559
  cache = /* @__PURE__ */ new Map();
3560
+ /** Whether each chat folder is in a git repo (see chatKind), looked up once. */
3561
+ repos = /* @__PURE__ */ new Map();
3185
3562
  get(key) {
3186
3563
  return this.cache.get(key)?.health ?? null;
3187
3564
  }
3188
- /** Reads the given chats (one at a time, to stay light) and returns those whose state changed. */
3565
+ kind(key) {
3566
+ return this.cache.get(key)?.kind ?? null;
3567
+ }
3568
+ /** Reads the given chats (one at a time, to stay light) and returns those whose state (or kind) changed. */
3189
3569
  async update(refs) {
3190
3570
  const changes = [];
3191
3571
  for (const ref of refs) {
3192
3572
  const key = sessionKey(ref);
3193
3573
  try {
3194
- const st = await (0, import_promises17.stat)(ref.file);
3574
+ const st = await (0, import_promises18.stat)(ref.file);
3195
3575
  const cached = this.cache.get(key);
3196
3576
  if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) continue;
3197
- const health2 = assessHealth(await readSessionTail(ref), await this.context.read(ref));
3198
- this.cache.set(key, { size: st.size, mtimeMs: st.mtimeMs, health: health2 });
3199
- if (cached?.health.state !== health2.state) changes.push({ ref, before: cached?.health.state ?? null, after: health2 });
3577
+ const session = await readSessionTail(ref);
3578
+ const health2 = assessHealth(session, await this.context.read(ref));
3579
+ const kind = chatKind(session, ref.cwd ? await this.inRepo(ref.cwd) : false);
3580
+ this.cache.set(key, { size: st.size, mtimeMs: st.mtimeMs, health: health2, kind });
3581
+ if (cached?.health.state !== health2.state || cached?.kind !== kind) changes.push({ ref, before: cached?.health.state ?? null, after: health2 });
3200
3582
  } catch {
3201
3583
  this.cache.delete(key);
3202
3584
  }
3203
3585
  }
3204
3586
  return changes;
3205
3587
  }
3588
+ inRepo(dir) {
3589
+ let known = this.repos.get(dir);
3590
+ if (!known) {
3591
+ known = inGitRepo(dir);
3592
+ this.repos.set(dir, known);
3593
+ }
3594
+ return known;
3595
+ }
3596
+ };
3597
+
3598
+ // src/browser.ts
3599
+ var import_node_child_process4 = require("node:child_process");
3600
+ var import_node_events2 = require("node:events");
3601
+ function scanPage(root, ax, watch3, isLimit) {
3602
+ const SPEAKER = /^(?:You said|Claude responded|ChatGPT said)\b:?/i;
3603
+ const ME = /^You said\b/i;
3604
+ const SKIP = ["AXButton", "AXPopUpButton", "AXMenuButton", "AXCheckBox", "AXRadioButton", "AXToolbar", "AXTextArea", "AXTextField", "AXComboBox", "AXImage", "AXMenu", "AXMenuBar"];
3605
+ const SIDE = ["AXLandmarkNavigation", "AXLandmarkComplementary"];
3606
+ const LIST = /^(?:Chat messages|Conversation)$/;
3607
+ const AGO = /^\d+ (?:second|minute|hour|day|week|month|year)s? ago$/;
3608
+ const turns = [];
3609
+ let limit = null;
3610
+ const headingText = (h) => {
3611
+ const own = ax.text(h);
3612
+ if (own) return own;
3613
+ const first = ax.kids(h).find((k) => ax.role(k) === "AXStaticText");
3614
+ return first && ax.text(first) || "";
3615
+ };
3616
+ const isSpeaker = (n) => ax.role(n) === "AXHeading" && SPEAKER.test(headingText(n).trim());
3617
+ const speakerOf = (n) => {
3618
+ const article = ax.subrole(n) === "AXDocumentArticle";
3619
+ const search = (m, depth) => {
3620
+ for (const k of ax.kids(m)) {
3621
+ if (isSpeaker(k)) return k;
3622
+ if (article && depth < 3 && ax.role(k) === "AXGroup") {
3623
+ const found = search(k, depth + 1);
3624
+ if (found) return found;
3625
+ }
3626
+ }
3627
+ return null;
3628
+ };
3629
+ return search(n, 0);
3630
+ };
3631
+ const message = (container, heading) => {
3632
+ const who = ME.test(headingText(heading).trim()) ? "me" : "agent";
3633
+ const read = (withHeading) => {
3634
+ let parts = [];
3635
+ const status = /* @__PURE__ */ new Set();
3636
+ const announced = (n) => {
3637
+ const t = ax.role(n) === "AXStaticText" ? ax.text(n)?.trim() : null;
3638
+ if (t) status.add(t);
3639
+ for (const k of ax.kids(n)) announced(k);
3640
+ };
3641
+ let ids = 0;
3642
+ const walk2 = (n, parent) => {
3643
+ const role = ax.role(n);
3644
+ const bubble = who === "me" && (role === "AXPopUpButton" || role === "AXButton");
3645
+ if (role && SKIP.includes(role) && !bubble) return;
3646
+ const sub = ax.subrole(n);
3647
+ if (sub === "AXApplicationGroup" && /actions/i.test(ax.desc(n) ?? "")) return;
3648
+ if (role === "AXGroup") {
3649
+ const isFile = (s) => !!s && /^[^\s/]+\.[A-Za-z0-9]{1,8}$/.test(s.trim());
3650
+ const label = sub === "AXApplicationGroup" ? ax.desc(n) : null;
3651
+ const button = ax.kids(n).find((k) => (ax.role(k) === "AXButton" || ax.role(k) === "AXPopUpButton") && isFile(ax.text(k)));
3652
+ const name = isFile(label) ? label : button ? ax.text(button) : null;
3653
+ if (name) {
3654
+ parts.push({ text: `(attached: ${name.trim()})`, parent: ++ids });
3655
+ return;
3656
+ }
3657
+ }
3658
+ if (sub === "AXApplicationStatus") return announced(n);
3659
+ if (n === heading) {
3660
+ if (!withHeading) return;
3661
+ const id2 = ++ids;
3662
+ for (const k of ax.kids(n)) {
3663
+ if (ax.role(k) === "AXStaticText" && SPEAKER.test((ax.text(k) ?? "").trim())) continue;
3664
+ walk2(k, id2);
3665
+ }
3666
+ return;
3667
+ }
3668
+ if (role === "AXStaticText" || role === "AXListMarker") {
3669
+ const t = ax.text(n);
3670
+ if (t && !AGO.test(t.trim())) parts.push({ text: t, parent });
3671
+ return;
3672
+ }
3673
+ const id = ++ids;
3674
+ for (const k of ax.kids(n)) walk2(k, id);
3675
+ };
3676
+ walk2(container, 0);
3677
+ parts = parts.filter((p) => !status.has(p.text.trim()));
3678
+ let out = "";
3679
+ for (let i = 0; i < parts.length; i++) {
3680
+ const p = parts[i];
3681
+ if (i > 0) {
3682
+ const prev = parts[i - 1];
3683
+ const spaced = /\s$/.test(prev.text) || /^[\s.,;:!?)\]]/.test(p.text);
3684
+ if (!spaced) out += prev.parent === p.parent ? " " : "\n";
3685
+ }
3686
+ out += p.text;
3687
+ }
3688
+ return out.replace(//g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
3689
+ };
3690
+ const text = who === "me" ? read(true) : read(false) || read(true);
3691
+ return { who, text };
3692
+ };
3693
+ let last = null;
3694
+ const lastMessage = (list) => {
3695
+ let cur = list;
3696
+ for (let depth = 0; depth < 8; depth++) {
3697
+ const kids = ax.kids(cur);
3698
+ let next = null;
3699
+ for (let i = kids.length - 1; i >= 0; i--) {
3700
+ const h = speakerOf(kids[i]);
3701
+ if (h) {
3702
+ last = { n: kids[i], h };
3703
+ return;
3704
+ }
3705
+ if (!next && ax.kids(kids[i]).length) next = kids[i];
3706
+ }
3707
+ if (!next) return;
3708
+ cur = next;
3709
+ }
3710
+ };
3711
+ const walk = (n, depth) => {
3712
+ if (depth > 60) return;
3713
+ const role = ax.role(n);
3714
+ if (role && SKIP.includes(role)) return;
3715
+ const sub = ax.subrole(n);
3716
+ if (sub && SIDE.includes(sub)) return;
3717
+ const h = speakerOf(n);
3718
+ if (h) {
3719
+ if (watch3) last = { n, h };
3720
+ else turns.push(message(n, h));
3721
+ return;
3722
+ }
3723
+ if (role === "AXStaticText" || sub === "AXApplicationAlert" || sub === "AXApplicationStatus") {
3724
+ const t = (role === "AXStaticText" ? ax.text(n) : ax.desc(n))?.trim();
3725
+ if (t && !limit && isLimit(t)) limit = t;
3726
+ if (role === "AXStaticText") return;
3727
+ }
3728
+ if (watch3 && LIST.test(ax.desc(n) ?? "")) {
3729
+ lastMessage(n);
3730
+ return;
3731
+ }
3732
+ for (const k of ax.kids(n)) walk(k, depth + 1);
3733
+ };
3734
+ walk(root, 0);
3735
+ const NOTICE = /^(?:you['’]?ve|you have|you['’]re|you are|usage limit|message limit|rate limit|limit reached)\b/i;
3736
+ const tail2 = watch3 ? last ? message(last.n, last.h) : void 0 : turns.at(-1);
3737
+ if (tail2 && tail2.who === "agent" && tail2.text.length < 250 && NOTICE.test(tail2.text) && isLimit(tail2.text)) {
3738
+ if (!limit) limit = tail2.text;
3739
+ if (!watch3) turns.pop();
3740
+ }
3741
+ return { turns: turns.filter((t) => t.text), limit };
3742
+ }
3743
+ var BROWSERS = {
3744
+ "com.google.Chrome": { name: "Google Chrome", chromium: true },
3745
+ "com.google.Chrome.beta": { name: "Google Chrome Beta", chromium: true },
3746
+ "com.google.Chrome.canary": { name: "Google Chrome Canary", chromium: true },
3747
+ "company.thebrowser.Browser": { name: "Arc", chromium: true },
3748
+ "company.thebrowser.dia": { name: "Dia", chromium: true },
3749
+ "com.brave.Browser": { name: "Brave Browser", chromium: true },
3750
+ "com.microsoft.edgemac": { name: "Microsoft Edge", chromium: true },
3751
+ "com.vivaldi.Vivaldi": { name: "Vivaldi", chromium: true },
3752
+ "com.operasoftware.Opera": { name: "Opera", chromium: true },
3753
+ "ai.perplexity.comet": { name: "Comet", chromium: true },
3754
+ "com.openai.atlas": { name: "ChatGPT Atlas", chromium: true },
3755
+ "com.apple.Safari": { name: "Safari", chromium: false },
3756
+ "com.apple.SafariTechnologyPreview": { name: "Safari Technology Preview", chromium: false },
3757
+ "org.mozilla.firefox": { name: "Firefox", chromium: false }
3758
+ };
3759
+ var CHAT_HOSTS = { "claude.ai": "claude-web", "chatgpt.com": "chatgpt", "chat.openai.com": "chatgpt" };
3760
+ function chatHosts(env = process.env) {
3761
+ const out = { ...CHAT_HOSTS };
3762
+ for (const pair of (env.HARLAY_WEBCHAT_TEST_HOSTS ?? "").split(",").filter(Boolean)) {
3763
+ const i = pair.lastIndexOf("=");
3764
+ const chat = pair.slice(i + 1);
3765
+ if (i > 0 && (chat === "claude-web" || chat === "chatgpt")) out[pair.slice(0, i)] = chat;
3766
+ }
3767
+ return out;
3768
+ }
3769
+ var SCRIPT = `
3770
+ ObjC.import('Cocoa');
3771
+ ObjC.import('CoreGraphics');
3772
+ ObjC.bindFunction('AXIsProcessTrusted', ['bool', []]);
3773
+ ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['int']]);
3774
+ ObjC.bindFunction('AXUIElementCopyAttributeValue', ['int', ['id', 'id', 'id*']]);
3775
+ ObjC.bindFunction('AXUIElementSetAttributeValue', ['int', ['id', 'id', 'id']]);
3776
+ ObjC.bindFunction('getppid', ['int', []]);
3777
+ const isLimitText = ${isLimitText.toString()};
3778
+ const scanPage = ${scanPage.toString()};
3779
+ function get(el, name) { const r = Ref(); return $.AXUIElementCopyAttributeValue(el, name, r) === 0 ? r[0] : null; }
3780
+ function str(v) { if (!v) return null; try { const s = ObjC.unwrap(v); return typeof s === 'string' ? s : null; } catch (e) { return null; } }
3781
+ function node(el) { return { el: el, k: null, r: undefined, s: undefined, t: undefined, d: undefined }; }
3782
+ const ax = {
3783
+ role: (n) => (n.r === undefined ? (n.r = str(get(n.el, 'AXRole'))) : n.r),
3784
+ subrole: (n) => (n.s === undefined ? (n.s = str(get(n.el, 'AXSubrole'))) : n.s),
3785
+ desc: (n) => (n.d === undefined ? (n.d = str(get(n.el, 'AXDescription'))) : n.d),
3786
+ text: (n) => (n.t === undefined ? (n.t = str(get(n.el, 'AXValue')) || str(get(n.el, 'AXTitle')) || ax.desc(n)) : n.t),
3787
+ kids: (n) => {
3788
+ if (n.k) return n.k;
3789
+ const v = get(n.el, 'AXChildren');
3790
+ const out = [];
3791
+ if (v) for (let i = 0; i < v.count; i++) out.push(node(v.objectAtIndex(i)));
3792
+ return (n.k = out);
3793
+ },
3794
+ };
3795
+ function hostOf(url) { const m = /^https?:\\/\\/([^/?#]+)/i.exec(url || ''); return m ? m[1].toLowerCase() : ''; }
3796
+ function webArea(win, hosts) {
3797
+ let first = null;
3798
+ const find = (n, depth) => {
3799
+ if (depth > 14) return null;
3800
+ if (ax.role(n) === 'AXWebArea') {
3801
+ const u = get(n.el, 'AXURL');
3802
+ const url = u ? ObjC.unwrap(u.absoluteString) : '';
3803
+ if (hosts[hostOf(url)]) return { n: n, url: url };
3804
+ if (!first) first = { n: n, url: url };
3805
+ return null;
3806
+ }
3807
+ for (const k of ax.kids(n)) { const f = find(k, depth + 1); if (f) return f; }
3808
+ return null;
3809
+ };
3810
+ return find(win, 0) || first;
3811
+ }
3812
+ /** The web chat in the front browser window: its page, URL and title; or why there's none. */
3813
+ function front(cfg) {
3814
+ const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
3815
+ const id = app ? ObjC.unwrap(app.bundleIdentifier) : null;
3816
+ const browser = id && cfg.browsers[id];
3817
+ if (!browser) return { ok: false, reason: 'not-browser' };
3818
+ const el = $.AXUIElementCreateApplication(app.processIdentifier);
3819
+ if (browser.chromium) $.AXUIElementSetAttributeValue(el, 'AXManualAccessibility', $.NSNumber.numberWithBool(true));
3820
+ const win = get(el, 'AXFocusedWindow');
3821
+ if (!win) return { ok: false, reason: 'no-window' };
3822
+ const area = webArea(node(win), cfg.hosts);
3823
+ const chat = area && cfg.hosts[hostOf(area.url)];
3824
+ if (!chat) return { ok: false, reason: 'not-chat' };
3825
+ const title = str(get(area.n.el, 'AXTitle')) || ax.desc(area.n) || '';
3826
+ return { ok: true, browser: browser.name, chat: chat, url: area.url, title: title, page: area.n };
3827
+ }
3828
+ function wait(seconds) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(seconds)); }
3829
+ /** The page the browser's focused element is in, and whether that element takes text. */
3830
+ function focusedField(pid) {
3831
+ let n = get($.AXUIElementCreateApplication(pid), 'AXFocusedUIElement');
3832
+ const role = n ? str(get(n, 'AXRole')) : null;
3833
+ for (let i = 0; n && i < 40; i++) {
3834
+ if (str(get(n, 'AXRole')) === 'AXWebArea') {
3835
+ const u = get(n, 'AXURL');
3836
+ return { text: role === 'AXTextArea' || role === 'AXTextField', url: u ? ObjC.unwrap(u.absoluteString) : '' };
3837
+ }
3838
+ n = get(n, 'AXParent');
3839
+ }
3840
+ return null;
3841
+ }
3842
+ /**
3843
+ * Attaches a file to the new chat Harlay just opened: once the browser is in front with the cursor in that chat's
3844
+ * message box (its site, on a new chat's page), it puts the file on the clipboard, presses \u2318V there, and puts the
3845
+ * clipboard back. Otherwise the file is left on the clipboard for the user to paste.
3846
+ */
3847
+ function paste(cfg) {
3848
+ if (!$.NSFileManager.defaultManager.fileExistsAtPath(cfg.path)) return { ok: false, reason: 'no-file' };
3849
+ const pb = $.NSPasteboard.generalPasteboard;
3850
+ const end = Date.now() + cfg.timeout * 1000;
3851
+ let pid = null;
3852
+ while (Date.now() < end) {
3853
+ const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
3854
+ const id = app ? ObjC.unwrap(app.bundleIdentifier) : null;
3855
+ if (id && cfg.browsers[id]) {
3856
+ const f = focusedField(app.processIdentifier);
3857
+ const m = f && /^https?:\\/\\/([^/?#]+)(\\/[^?#]*)?/i.exec(f.url);
3858
+ if (f && f.text && m && cfg.hosts[m[1].toLowerCase()] === cfg.chat && cfg.paths.indexOf(m[2] || '/') !== -1) {
3859
+ pid = app.processIdentifier;
3860
+ break;
3861
+ }
3862
+ }
3863
+ wait(0.25);
3864
+ }
3865
+ const saved = [];
3866
+ if (pid) {
3867
+ const items = pb.pasteboardItems;
3868
+ for (let i = 0; i < items.count; i++) {
3869
+ const item = items.objectAtIndex(i);
3870
+ const kept = [];
3871
+ for (let j = 0; j < item.types.count; j++) {
3872
+ const t = item.types.objectAtIndex(j);
3873
+ const d = item.dataForType(t);
3874
+ if (d) kept.push([t, d]);
3875
+ }
3876
+ saved.push(kept);
3877
+ }
3878
+ }
3879
+ pb.clearContents;
3880
+ pb.writeObjects($([$.NSURL.fileURLWithPath(cfg.path)]));
3881
+ if (!pid) return { ok: false, reason: 'no-message-box' };
3882
+ // The page fills its message box as it loads: give it a moment.
3883
+ wait(0.6);
3884
+ for (const down of [true, false]) {
3885
+ const e = $.CGEventCreateKeyboardEvent($(), 9, down);
3886
+ $.CGEventSetFlags(e, $.kCGEventFlagMaskCommand);
3887
+ $.CGEventPostToPid(pid, e);
3888
+ }
3889
+ wait(1.5);
3890
+ pb.clearContents;
3891
+ const back = saved.map((kept) => {
3892
+ const item = $.NSPasteboardItem.alloc.init;
3893
+ for (const [t, d] of kept) item.setDataForType(d, t);
3894
+ return item;
3895
+ });
3896
+ if (back.length) pb.writeObjects($(back));
3897
+ return { ok: true };
3898
+ }
3899
+ function write(s) {
3900
+ $.NSFileHandle.fileHandleWithStandardOutput.writeData($(s + '\\n').dataUsingEncoding($.NSUTF8StringEncoding));
3901
+ }
3902
+ function run(argv) {
3903
+ const cfg = JSON.parse(argv[0]);
3904
+ if (!$.AXIsProcessTrusted()) return JSON.stringify({ ok: false, reason: 'untrusted' });
3905
+ if (cfg.mode === 'paste') return JSON.stringify(paste(cfg));
3906
+ if (cfg.mode === 'read') {
3907
+ const f = front(cfg);
3908
+ if (!f.ok) return JSON.stringify(f);
3909
+ const scan = scanPage(f.page, ax, false, isLimitText);
3910
+ return JSON.stringify({ ok: true, browser: f.browser, chat: f.chat, url: f.url, title: f.title, turns: scan.turns, limit: scan.limit });
3911
+ }
3912
+ const parent = $.getppid();
3913
+ let last = '';
3914
+ let said = '';
3915
+ for (;;) {
3916
+ if ($.getppid() !== parent) return '';
3917
+ if (!$.AXIsProcessTrusted()) { write(JSON.stringify({ event: 'untrusted' })); return ''; }
3918
+ let key = '';
3919
+ try {
3920
+ const f = front(cfg);
3921
+ // HARLAY_DEBUG: what it sees, each time that changes.
3922
+ const now = f.ok ? f.url : f.reason;
3923
+ if (cfg.debug && now !== said) write(JSON.stringify({ event: 'status', front: now }));
3924
+ said = now;
3925
+ if (f.ok) {
3926
+ const scan = scanPage(f.page, ax, true, isLimitText);
3927
+ if (scan.limit) {
3928
+ key = f.url + '\\n' + scan.limit;
3929
+ if (key !== last) write(JSON.stringify({ event: 'limit', chat: f.chat, url: f.url, title: f.title, limit: scan.limit }));
3930
+ }
3931
+ }
3932
+ } catch (e) {
3933
+ // a window that closed mid-read: try again next time
3934
+ if (cfg.debug) write(JSON.stringify({ event: 'status', error: String(e) }));
3935
+ }
3936
+ last = key;
3937
+ // Waits by running the event loop, not delay(): that's how macOS tells this process which app is in front now.
3938
+ $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(cfg.interval));
3939
+ }
3940
+ }
3941
+ `;
3942
+ function config(mode, hosts, interval = 2.5) {
3943
+ return JSON.stringify({ mode, hosts, browsers: BROWSERS, interval, debug: !!process.env.HARLAY_DEBUG });
3944
+ }
3945
+ function readFrontChat(hosts = chatHosts(), timeoutMs = 8e3) {
3946
+ return new Promise((resolve) => {
3947
+ (0, import_node_child_process4.execFile)("osascript", ["-l", "JavaScript", "-e", SCRIPT, config("read", hosts)], { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }, (err, out) => {
3948
+ if (err) return resolve({ ok: false, reason: "failed" });
3949
+ try {
3950
+ const r = JSON.parse(out);
3951
+ if (r.ok !== true) return resolve({ ok: false, reason: r.reason ?? "failed" });
3952
+ const turns = Array.isArray(r.turns) ? r.turns.filter((t) => (t.who === "me" || t.who === "agent") && typeof t.text === "string") : [];
3953
+ resolve({
3954
+ ok: true,
3955
+ browser: String(r.browser),
3956
+ capture: { chat: r.chat, url: String(r.url), title: String(r.title ?? ""), turns, limit: typeof r.limit === "string" ? r.limit : null }
3957
+ });
3958
+ } catch {
3959
+ resolve({ ok: false, reason: "failed" });
3960
+ }
3961
+ });
3962
+ });
3963
+ }
3964
+ var NEW_CHAT_PATHS = { "claude-web": ["/new"], chatgpt: ["/"] };
3965
+ function pasteFileInto(chat, file, hosts = chatHosts(), timeoutSec = 15) {
3966
+ const paths = Object.entries(hosts).some(([h, c]) => c === chat && !(h in CHAT_HOSTS)) ? [...NEW_CHAT_PATHS[chat], "/"] : NEW_CHAT_PATHS[chat];
3967
+ const cfg = JSON.stringify({ mode: "paste", chat, path: file, hosts, browsers: BROWSERS, paths, timeout: timeoutSec });
3968
+ return new Promise((resolve) => {
3969
+ (0, import_node_child_process4.execFile)("osascript", ["-l", "JavaScript", "-e", SCRIPT, cfg], { timeout: (timeoutSec + 10) * 1e3 }, (err, out) => {
3970
+ if (err) return resolve({ ok: false, reason: "failed" });
3971
+ try {
3972
+ resolve(JSON.parse(out));
3973
+ } catch {
3974
+ resolve({ ok: false, reason: "failed" });
3975
+ }
3976
+ });
3977
+ });
3978
+ }
3979
+ var BrowserWatcher = class extends import_node_events2.EventEmitter {
3980
+ constructor(hosts = chatHosts()) {
3981
+ super();
3982
+ this.hosts = hosts;
3983
+ }
3984
+ hosts;
3985
+ child = null;
3986
+ wanted = false;
3987
+ timer = null;
3988
+ start() {
3989
+ this.wanted = true;
3990
+ if (this.child) return;
3991
+ const child = (0, import_node_child_process4.spawn)("osascript", ["-l", "JavaScript", "-e", SCRIPT, config("watch", this.hosts)], { stdio: ["ignore", "pipe", "ignore"] });
3992
+ this.child = child;
3993
+ let buf = "";
3994
+ let untrusted = false;
3995
+ child.stdout?.setEncoding("utf8");
3996
+ child.stdout?.on("data", (chunk) => {
3997
+ buf += chunk;
3998
+ for (let nl = buf.indexOf("\n"); nl !== -1; nl = buf.indexOf("\n")) {
3999
+ const line = buf.slice(0, nl);
4000
+ buf = buf.slice(nl + 1);
4001
+ try {
4002
+ const ev = JSON.parse(line);
4003
+ if (ev.event === "untrusted") untrusted = true;
4004
+ else if (ev.event === "status") this.emit("status", ev);
4005
+ else if (ev.event === "limit") this.emit("limit", { chat: ev.chat, url: ev.url, title: ev.title, limit: ev.limit });
4006
+ } catch {
4007
+ }
4008
+ }
4009
+ });
4010
+ child.on("exit", () => {
4011
+ this.child = null;
4012
+ if (untrusted) this.emit("untrusted");
4013
+ if (this.wanted && !untrusted) this.timer = setTimeout(() => this.start(), 5e3);
4014
+ else this.wanted = false;
4015
+ });
4016
+ }
4017
+ get running() {
4018
+ return this.child !== null;
4019
+ }
4020
+ stop() {
4021
+ this.wanted = false;
4022
+ if (this.timer) clearTimeout(this.timer);
4023
+ this.timer = null;
4024
+ this.child?.kill();
4025
+ this.child = null;
4026
+ }
3206
4027
  };
3207
4028
 
4029
+ // src/targets.ts
4030
+ function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
4031
+ const order = [defaultTarget, ...TOOLS.filter((t) => t !== defaultTarget)];
4032
+ return order.filter((t) => t !== source && available2[t][modes[t]] && !((limitedUntil[t]?.getTime() ?? 0) > now.getTime()));
4033
+ }
4034
+ var SHARES_USAGE = { "claude-web": "claude-code" };
4035
+ function webTargets(source, hitLimit, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
4036
+ const out = [];
4037
+ for (const w of ["claude-web", "chatgpt"]) {
4038
+ if (w === source) continue;
4039
+ const shared = SHARES_USAGE[w];
4040
+ if (shared && (hitLimit && shared === source || (limitedUntil[shared]?.getTime() ?? 0) > now.getTime())) continue;
4041
+ out.push(w);
4042
+ }
4043
+ return out;
4044
+ }
4045
+ function orderTargets(agents, web, kind) {
4046
+ return kind === "chat" ? [...web, ...agents] : [...agents, ...web];
4047
+ }
4048
+ function isPrimary(t, kind) {
4049
+ return isWebChat(t) === (kind === "chat");
4050
+ }
4051
+
3208
4052
  // src/popover.ts
3209
4053
  var POPOVER_WIDTH = 392;
3210
4054
  var PER_AGENT = 3;
@@ -3263,11 +4107,14 @@ function shownByAgent(sessions, settings2) {
3263
4107
  }
3264
4108
  return out;
3265
4109
  }
3266
- function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false, icons = {}, health2 = () => null) {
4110
+ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false, icons = {}, health2 = () => null, kind = () => null, limitedUntil = {}, web = { chats: [], folder: null, allowed: false }) {
3267
4111
  const pinned = new Set(settings2.pinned);
3268
4112
  const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
3269
4113
  const toSession = (t) => {
3270
4114
  const key = sessionKey(t.ref);
4115
+ const k = kind(key);
4116
+ const agents = order.filter((x) => x !== t.ref.tool && available2[x][settings2.modes[x]]);
4117
+ const web2 = webTargets(t.ref.tool, t.status === "limit", limitedUntil, now);
3271
4118
  return {
3272
4119
  key,
3273
4120
  title: sessionTitle(t),
@@ -3277,7 +4124,8 @@ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 =
3277
4124
  ago: timeAgo(t.updatedAt, now),
3278
4125
  busy: busy.has(key),
3279
4126
  pinned: pinned.has(key),
3280
- targets: order.filter((x) => x !== t.ref.tool && available2[x][settings2.modes[x]]).map((x) => ({ tool: x, label: TOOL_LABEL[x], mode: settings2.modes[x] })),
4127
+ targets: orderTargets(agents, web2, k).map((x) => ({ tool: x, label: targetLabel(x), mode: isWebChat(x) ? "web" : settings2.modes[x], primary: isPrimary(x, k) })),
4128
+ kind: k,
3281
4129
  health: health2(key),
3282
4130
  canStartFresh: available2[t.ref.tool][settings2.modes[t.ref.tool]],
3283
4131
  delivery: t.status === "handed_off" ? t.delivery?.state ?? null : null
@@ -3294,20 +4142,53 @@ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 =
3294
4142
  usage: usage2,
3295
4143
  icons,
3296
4144
  askClaudeBridge,
3297
- settings: { autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme, claudeBridge: claudeBridge2 },
4145
+ settings: {
4146
+ autoContinue: settings2.autoContinue,
4147
+ openAtLogin: settings2.openAtLogin,
4148
+ theme: settings2.theme,
4149
+ claudeBridge: claudeBridge2,
4150
+ webChats: settings2.webChats,
4151
+ webChatsAllowed: web.allowed
4152
+ },
4153
+ webChats: web.chats,
4154
+ agentFolder: web.folder,
3298
4155
  hotkey: HOTKEY.label
3299
4156
  };
3300
4157
  }
4158
+ function buildWebSessions(entries, settings2, available2, busy, now, limitedUntil = {}) {
4159
+ const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
4160
+ const agents = order.filter((x) => available2[x][settings2.modes[x]] && !((limitedUntil[x]?.getTime() ?? 0) > now.getTime()));
4161
+ return [...entries].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).map((e) => {
4162
+ const web = webTargets(e.capture.chat, e.status === "limit", limitedUntil, now);
4163
+ return {
4164
+ key: e.key,
4165
+ title: webChatTitle(e.capture),
4166
+ status: e.status,
4167
+ detail: e.detail,
4168
+ cwd: null,
4169
+ ago: timeAgo(e.updatedAt, now),
4170
+ busy: busy.has(e.key),
4171
+ pinned: false,
4172
+ targets: orderTargets(agents, web, "chat").map((x) => ({ tool: x, label: targetLabel(x), mode: isWebChat(x) ? "web" : settings2.modes[x], primary: isPrimary(x, "chat") })),
4173
+ kind: "chat",
4174
+ health: null,
4175
+ canStartFresh: false,
4176
+ delivery: null,
4177
+ web: e.capture.chat
4178
+ };
4179
+ });
4180
+ }
3301
4181
  function isMenuAction(x) {
3302
4182
  if (typeof x !== "object" || x === null) return false;
3303
4183
  const a = x;
3304
4184
  switch (a.type) {
3305
4185
  case "handoff":
3306
- return typeof a.key === "string" && isTool(a.target);
4186
+ return typeof a.key === "string" && isTarget(a.target);
3307
4187
  case "unpinChat":
3308
4188
  case "freshStart":
3309
4189
  case "resendHandoff":
3310
4190
  case "copyPrompt":
4191
+ case "pickFolder":
3311
4192
  return typeof a.key === "string";
3312
4193
  case "setTheme":
3313
4194
  return isTheme(a.theme);
@@ -3320,6 +4201,7 @@ function isMenuAction(x) {
3320
4201
  case "toggleAuto":
3321
4202
  case "toggleLogin":
3322
4203
  case "toggleClaudeBridge":
4204
+ case "toggleWebChats":
3323
4205
  case "openCursorUsage":
3324
4206
  case "quit":
3325
4207
  return true;
@@ -3336,7 +4218,7 @@ function panelPosition(icon, iconArea, userArea, width) {
3336
4218
 
3337
4219
  // src/overlay.ts
3338
4220
  var import_electron = require("electron");
3339
- var import_node_path18 = require("node:path");
4221
+ var import_node_path22 = require("node:path");
3340
4222
  var MAX_SHOW_MS = 16e3;
3341
4223
  var Overlay = class {
3342
4224
  win = null;
@@ -3392,21 +4274,21 @@ var Overlay = class {
3392
4274
  type: "panel",
3393
4275
  enableLargerThanScreen: true,
3394
4276
  alwaysOnTop: true,
3395
- webPreferences: { preload: (0, import_node_path18.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
4277
+ webPreferences: { preload: (0, import_node_path22.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
3396
4278
  });
3397
4279
  win.setAlwaysOnTop(true, "screen-saver");
3398
4280
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
3399
4281
  win.setIgnoreMouseEvents(true);
3400
4282
  win.webContents.on("will-navigate", (e) => e.preventDefault());
3401
4283
  win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
3402
- void win.loadFile((0, import_node_path18.join)(__dirname, "..", "renderer", "overlay.html"));
4284
+ void win.loadFile((0, import_node_path22.join)(__dirname, "..", "renderer", "overlay.html"));
3403
4285
  this.win = win;
3404
4286
  return win;
3405
4287
  }
3406
4288
  };
3407
4289
 
3408
4290
  // src/show.ts
3409
- var import_node_child_process4 = require("node:child_process");
4291
+ var import_node_child_process5 = require("node:child_process");
3410
4292
  var tokensIn = (chars) => Math.max(1, Math.round(chars / 4));
3411
4293
  var plural = (n, one) => `${n.toLocaleString("en-US")} ${one}${n === 1 ? "" : "s"}`;
3412
4294
  function startView(o) {
@@ -3415,14 +4297,14 @@ function startView(o) {
3415
4297
  const why = stopped || o.status === "warning" ? o.detail.replace(/^Limit hit/, "usage limit").replace(/ · /g, ", ") : fresh ? "new chat, same task" : "moved by you";
3416
4298
  return {
3417
4299
  key: o.key,
3418
- from: { tool: o.from, label: TOOL_LABEL[o.from] },
3419
- to: { tool: o.to, label: fresh ? "New chat" : TOOL_LABEL[o.to], mode: o.mode },
4300
+ from: { tool: o.from, label: targetLabel(o.from) },
4301
+ to: { tool: o.to, label: fresh ? "New chat" : targetLabel(o.to), mode: o.mode },
3420
4302
  title: o.title,
3421
4303
  tag: stopped ? "stopped" : "moving",
3422
4304
  steps: [
3423
4305
  { name: "Detect", state: "run", detail: why.charAt(0).toLowerCase() + why.slice(1) },
3424
4306
  { name: "Extract", state: "wait", detail: "reading the chat and git" },
3425
- { name: "Distill", state: "wait", detail: "into one .harlay file" },
4307
+ { name: "Distill", state: "wait", detail: isWebChat(o.to) ? "into one message" : "into one .harlay file" },
3426
4308
  { name: "Rehydrate", state: "wait", detail: rehydrateDetail(o.to, o.mode, fresh, false) }
3427
4309
  ],
3428
4310
  count: null,
@@ -3431,6 +4313,7 @@ function startView(o) {
3431
4313
  };
3432
4314
  }
3433
4315
  function rehydrateDetail(to, mode, fresh, done) {
4316
+ if (isWebChat(to)) return done ? `${targetLabel(to)} \xB7 press \u23CE to send` : `opening ${targetLabel(to)} in your browser`;
3434
4317
  const where = `${fresh ? `new ${TOOL_LABEL[to]} chat` : TOOL_LABEL[to]}${mode === "terminal" ? " in Terminal" : ""}`;
3435
4318
  if (!done) return `opening ${where}`;
3436
4319
  return mode === "app" ? `${where} \xB7 press \u23CE to send` : `${where} \xB7 picking it up`;
@@ -3475,7 +4358,7 @@ JSON.stringify(l.filter(function (w) { return w.kCGWindowLayer === 0 && w.kCGWin
3475
4358
  }));`;
3476
4359
  function listWindows() {
3477
4360
  return new Promise((resolve) => {
3478
- (0, import_node_child_process4.execFile)("osascript", ["-l", "JavaScript", "-e", LIST_WINDOWS], { timeout: 2e3 }, (err, out) => {
4361
+ (0, import_node_child_process5.execFile)("osascript", ["-l", "JavaScript", "-e", LIST_WINDOWS], { timeout: 2e3 }, (err, out) => {
3479
4362
  if (err) return resolve([]);
3480
4363
  try {
3481
4364
  const list = JSON.parse(out);
@@ -3486,7 +4369,9 @@ function listWindows() {
3486
4369
  });
3487
4370
  });
3488
4371
  }
4372
+ var BROWSERS2 = ["Google Chrome", "Safari", "Arc", "Firefox", "Brave Browser", "Microsoft Edge", "Dia", "Comet", "Opera", "Vivaldi", "Orion", "Zen", "ChatGPT Atlas"];
3489
4373
  function windowOwners(target, mode, terminalApp) {
4374
+ if (isWebChat(target)) return BROWSERS2;
3490
4375
  return mode === "terminal" ? [terminalApp] : APP_BUNDLES[target].map((b) => b.replace(/\.app$/, ""));
3491
4376
  }
3492
4377
  function ownedBy(app2, owners) {
@@ -3497,6 +4382,10 @@ function ownedBy(app2, owners) {
3497
4382
  });
3498
4383
  }
3499
4384
  var isMain = (w) => w.width >= 320 && w.height >= 240;
4385
+ async function frontWindow() {
4386
+ const w = (await listWindows()).find(isMain);
4387
+ return w ? rect(w) : null;
4388
+ }
3500
4389
  async function windowIds() {
3501
4390
  return new Set((await listWindows()).map((w) => w.id));
3502
4391
  }
@@ -3507,24 +4396,33 @@ async function findTargetWindow(owners, o) {
3507
4396
  const all = (await listWindows()).filter(isMain);
3508
4397
  const mine = all.filter((w) => ownedBy(w.app, owners));
3509
4398
  const found = before ? mine.find((w) => !before.has(w.id)) : all[0] && ownedBy(all[0].app, owners) ? all[0] : void 0;
3510
- if (found) return rect(found);
3511
- if (Date.now() >= end) return mine[0] ? rect(mine[0]) : null;
3512
- await sleep(250);
3513
- }
3514
- }
4399
+ const r = found ? await settled(found) : null;
4400
+ if (r) return r;
4401
+ if (Date.now() >= end) return mine[0] ? settled(mine[0]) : null;
4402
+ await sleep(found ? 50 : 250);
4403
+ }
4404
+ }
4405
+ async function settled(w) {
4406
+ let last = rect(w);
4407
+ const end = Date.now() + SETTLE_MS;
4408
+ while (Date.now() < end) {
4409
+ await sleep(150);
4410
+ const now = (await listWindows()).find((x) => x.id === w.id);
4411
+ if (!now) return null;
4412
+ const r = rect(now);
4413
+ if (r.x === last.x && r.y === last.y && r.width === last.width && r.height === last.height) return r;
4414
+ last = r;
4415
+ }
4416
+ return last;
4417
+ }
4418
+ var SETTLE_MS = 2e3;
3515
4419
  var rect = (w) => ({ x: w.x, y: w.y, width: w.width, height: w.height });
3516
4420
 
3517
- // src/targets.ts
3518
- function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
3519
- const order = [defaultTarget, ...TOOLS.filter((t) => t !== defaultTarget)];
3520
- return order.filter((t) => t !== source && available2[t][modes[t]] && !((limitedUntil[t]?.getTime() ?? 0) > now.getTime()));
3521
- }
3522
-
3523
4421
  // src/usage.ts
3524
- var import_node_events2 = require("node:events");
4422
+ var import_node_events3 = require("node:events");
3525
4423
  var import_node_fs4 = require("node:fs");
3526
- var import_promises18 = require("node:fs/promises");
3527
- var import_node_path19 = require("node:path");
4424
+ var import_promises19 = require("node:fs/promises");
4425
+ var import_node_path23 = require("node:path");
3528
4426
  var CODEX_STALE_MS = 30 * 6e4;
3529
4427
  var CODEX_QUERY_GAP_MS = 30 * 6e4;
3530
4428
  var DEBOUNCE_MS = 2e3;
@@ -3535,7 +4433,7 @@ function clock2(d, now) {
3535
4433
  return d.getTime() - now.getTime() < 20 * 36e5 ? time : `${d.toLocaleDateString([], { weekday: "short" })} ${time}`;
3536
4434
  }
3537
4435
  var NAME = { "claude-code": "Claude", codex: "Codex", cursor: "Cursor" };
3538
- var UsageMonitor = class extends import_node_events2.EventEmitter {
4436
+ var UsageMonitor = class extends import_node_events3.EventEmitter {
3539
4437
  constructor(opts) {
3540
4438
  super();
3541
4439
  this.opts = opts;
@@ -3555,15 +4453,15 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
3555
4453
  return claudeStatusFile(this.opts.dataDir);
3556
4454
  }
3557
4455
  get samplesFile() {
3558
- return (0, import_node_path19.join)(this.opts.dataDir, "usage-calibration.json");
4456
+ return (0, import_node_path23.join)(this.opts.dataDir, "usage-calibration.json");
3559
4457
  }
3560
4458
  current() {
3561
4459
  return this.snapshots;
3562
4460
  }
3563
4461
  async start() {
3564
- await (0, import_promises18.mkdir)(this.opts.dataDir, { recursive: true });
4462
+ await (0, import_promises19.mkdir)(this.opts.dataDir, { recursive: true });
3565
4463
  try {
3566
- const raw = JSON.parse(await (0, import_promises18.readFile)(this.samplesFile, "utf8"));
4464
+ const raw = JSON.parse(await (0, import_promises19.readFile)(this.samplesFile, "utf8"));
3567
4465
  if (Array.isArray(raw)) this.samples = raw.filter((s) => typeof s?.capacity === "number" && typeof s?.at === "number" && typeof s?.kind === "string");
3568
4466
  } catch {
3569
4467
  this.samples = [];
@@ -3578,8 +4476,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
3578
4476
  } catch {
3579
4477
  }
3580
4478
  };
3581
- watchDir((0, import_node_path19.join)(this.opts.home, ".codex", "sessions"), "codex", true);
3582
- watchDir((0, import_node_path19.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
4479
+ watchDir((0, import_node_path23.join)(this.opts.home, ".codex", "sessions"), "codex", true);
4480
+ watchDir((0, import_node_path23.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
3583
4481
  watchDir(this.opts.dataDir, "claude-code", false);
3584
4482
  await Promise.all([this.update("codex"), this.update("claude-code")]);
3585
4483
  void this.topUpCodex();
@@ -3636,8 +4534,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
3636
4534
  await this.claudeLog.refresh(now);
3637
4535
  let exact = null;
3638
4536
  try {
3639
- const s = await (0, import_promises18.stat)(this.statusFile);
3640
- exact = parseClaudeStatusline(JSON.parse(await (0, import_promises18.readFile)(this.statusFile, "utf8")), s.mtimeMs);
4537
+ const s = await (0, import_promises19.stat)(this.statusFile);
4538
+ exact = parseClaudeStatusline(JSON.parse(await (0, import_promises19.readFile)(this.statusFile, "utf8")), s.mtimeMs);
3641
4539
  } catch {
3642
4540
  exact = null;
3643
4541
  }
@@ -3646,7 +4544,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
3646
4544
  const merged = mergeSamples(this.samples, capacitySamples(entries, hits, exact), now);
3647
4545
  if (JSON.stringify(merged) !== JSON.stringify(this.samples)) {
3648
4546
  this.samples = merged;
3649
- await (0, import_promises18.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
4547
+ await (0, import_promises19.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
3650
4548
  }
3651
4549
  return claudeSnapshot({ entries, hits, exact, samples: this.samples, now });
3652
4550
  }
@@ -3681,7 +4579,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
3681
4579
  var MAX_LIVE_NOTIFICATIONS = 50;
3682
4580
  var AUTO_HANDOFF_COOLDOWN_MS = 2 * 6e4;
3683
4581
  var home = process.env.HARLAY_HOME ?? (0, import_node_os3.homedir)();
3684
- if (process.env.HARLAY_HOME) import_electron2.app.setPath("userData", (0, import_node_path20.join)(home, "Library", "Application Support", "Harlay"));
4582
+ if (process.env.HARLAY_HOME) import_electron2.app.setPath("userData", (0, import_node_path24.join)(home, "Library", "Application Support", "Harlay"));
3685
4583
  var readers = createReaders({ home });
3686
4584
  var state = createState();
3687
4585
  var liveNotifications = /* @__PURE__ */ new Set();
@@ -3698,6 +4596,9 @@ var claudeBridge = "off";
3698
4596
  var lastAutoHandoffAt = 0;
3699
4597
  var agentIcons = {};
3700
4598
  var health = new HealthMonitor();
4599
+ var webChats = /* @__PURE__ */ new Map();
4600
+ var browserWatcher = new BrowserWatcher();
4601
+ var browserAllowed = false;
3701
4602
  var overlay = new Overlay();
3702
4603
  var showing = null;
3703
4604
  var errorMessage = (e) => e instanceof Error ? e.message : String(e);
@@ -3721,7 +4622,21 @@ function refreshUi() {
3721
4622
  const rows = buildUsageRows(usage?.current() ?? {}, available, claudeBridge, now);
3722
4623
  const claudeInstalled = available["claude-code"].app || available["claude-code"].terminal;
3723
4624
  const ask = !settings.claudeBridgeAsked && claudeBridge === "off" && claudeInstalled;
3724
- const model = buildPopoverModel([...state.sessions.values()], settings, available, handoffsInFlight, now, rows, claudeBridge, ask, agentIcons, (k) => health.get(k));
4625
+ const model = buildPopoverModel(
4626
+ [...state.sessions.values()],
4627
+ settings,
4628
+ available,
4629
+ handoffsInFlight,
4630
+ now,
4631
+ rows,
4632
+ claudeBridge,
4633
+ ask,
4634
+ agentIcons,
4635
+ (k) => health.get(k),
4636
+ (k) => health.kind(k),
4637
+ state.limitedUntil,
4638
+ { chats: buildWebSessions([...webChats.values()], settings, available, handoffsInFlight, now, state.limitedUntil), folder: agentFolder(), allowed: browserAllowed }
4639
+ );
3725
4640
  popover.webContents.send("harlay:model", model);
3726
4641
  }
3727
4642
  function nativeMenu() {
@@ -3750,13 +4665,16 @@ async function handoff(ref, target) {
3750
4665
  handoffsInFlight.add(key);
3751
4666
  refreshUi();
3752
4667
  const since = /* @__PURE__ */ new Date();
3753
- const mode = settings.modes[target];
3754
- const show = presentHandoff(ref, target, mode);
4668
+ const mode = isWebChat(target) ? "web" : settings.modes[target];
4669
+ const t = state.sessions.get(key);
4670
+ const show = presentHandoff({ key, tool: ref.tool, title: t ? sessionTitle(t) : "This chat", status: t?.status ?? "idle", detail: t?.detail ?? "" }, target, mode);
3755
4671
  try {
3756
4672
  const res = await runHandoff({
3757
4673
  session: ref,
3758
4674
  target,
3759
- mode,
4675
+ // A web chat opens in the browser whatever this says.
4676
+ mode: mode === "web" ? "app" : mode,
4677
+ attach: canAttach(),
3760
4678
  readers,
3761
4679
  store: handoffStore(home),
3762
4680
  terminalApp: settings.terminalApp,
@@ -3764,9 +4682,10 @@ async function handoff(ref, target) {
3764
4682
  onProgress: show.progress
3765
4683
  });
3766
4684
  const { id, path, prompt } = res.written;
3767
- markHandedOff(state, key, target, /* @__PURE__ */ new Date(), { id, path, prompt, mode, cwd: res.data.workdir, since });
4685
+ markHandedOff(state, key, target, /* @__PURE__ */ new Date(), mode === "web" ? void 0 : { id, path, prompt, mode, cwd: res.data.workdir, since });
3768
4686
  closeResolvedAlert();
3769
4687
  void show.launched();
4688
+ if (isWebChat(target) && canAttach()) void attach(target, path);
3770
4689
  } catch (e) {
3771
4690
  show.failed(errorMessage(e));
3772
4691
  notify("Harlay could not hand off", errorMessage(e));
@@ -3775,10 +4694,9 @@ async function handoff(ref, target) {
3775
4694
  }
3776
4695
  refreshUi();
3777
4696
  }
3778
- function presentHandoff(ref, target, mode) {
3779
- const key = sessionKey(ref);
3780
- const t = state.sessions.get(key);
3781
- let view = startView({ key, from: ref.tool, to: target, mode, title: t ? sessionTitle(t) : "This chat", status: t?.status ?? "idle", detail: t?.detail ?? "" });
4697
+ function presentHandoff(src, target, mode) {
4698
+ const { key } = src;
4699
+ let view = startView({ key, from: src.tool, to: target, mode, title: src.title, status: src.status, detail: src.detail });
3782
4700
  const set = (v) => {
3783
4701
  view = v;
3784
4702
  if (showing === key && popover && !popover.isDestroyed()) popover.webContents.send("harlay:handoff", v);
@@ -3802,16 +4720,17 @@ function presentHandoff(ref, target, mode) {
3802
4720
  const to = tokensIn(p.chars);
3803
4721
  const from = fromTokens ?? to;
3804
4722
  if (from > to) void pace.next(0, () => set({ ...view, count: { from, to } }));
3805
- const size = from > to ? void 0 : `${to.toLocaleString("en-US")} tokens in one .harlay file`;
4723
+ const size = from > to ? void 0 : `${to.toLocaleString("en-US")} tokens in one ${mode === "web" ? "message" : ".harlay file"}`;
3806
4724
  void pace.next(from > to ? 950 : 500, () => set(withStep(withStep(view, "Distill", "done", size), "Rehydrate", "run")));
3807
4725
  window = findTargetWindow(windowOwners(target, mode, settings.terminalApp), { newSince: before, timeoutMs: 4e3 });
3808
4726
  },
3809
4727
  launched: async () => {
3810
- await pace.next(450, () => set({ ...withStep(view, "Rehydrate", "done", rehydrateDetail(target, mode, ref.tool === target, true)), tag: "done" }));
4728
+ await pace.next(450, () => set({ ...withStep(view, "Rehydrate", "done", rehydrateDetail(target, mode, src.tool === target, true)), tag: "done" }));
3811
4729
  await pace.next(800, () => set({ ...view, leaving: true }));
3812
4730
  await pace.next(200, () => void 0);
3813
4731
  if (showing !== key) {
3814
- notify(`Continuing in ${TOOL_LABEL[target]}`, mode === "app" ? "The prompt is typed in: press Enter there to send it." : "It picks up where this chat left off.");
4732
+ const typedIn = mode === "app" || mode === "web";
4733
+ notify(`Continuing in ${targetLabel(target)}`, typedIn ? "The prompt is typed in: press Enter there to send it." : "It picks up where this chat left off.");
3815
4734
  return;
3816
4735
  }
3817
4736
  showing = null;
@@ -3823,8 +4742,8 @@ function presentHandoff(ref, target, mode) {
3823
4742
  key,
3824
4743
  from: tb ? { x: tb.x + tb.width / 2, y: tb.y + tb.height / 2 } : { x: 0, y: 0 },
3825
4744
  window: found,
3826
- to: { tool: target, label: TOOL_LABEL[target], icon: agentIcons[target] ?? null, mode },
3827
- fromLabel: TOOL_LABEL[ref.tool],
4745
+ to: { tool: target, label: targetLabel(target), icon: agentIcons[target] ?? null, mode },
4746
+ fromLabel: targetLabel(src.tool),
3828
4747
  // The agent may have picked it up already (a terminal starts at once).
3829
4748
  picked: delivered === "sent" || delivered === "read"
3830
4749
  });
@@ -3883,10 +4802,30 @@ async function onAction(a) {
3883
4802
  try {
3884
4803
  switch (a.type) {
3885
4804
  case "handoff": {
4805
+ if (isWebKey(a.key)) {
4806
+ await handoffWeb(a.key, a.target);
4807
+ break;
4808
+ }
3886
4809
  const t = state.sessions.get(a.key);
3887
4810
  if (t) await handoff(t.ref, a.target);
3888
4811
  break;
3889
4812
  }
4813
+ case "toggleWebChats":
4814
+ settings.webChats = !settings.webChats;
4815
+ await saveSettings(settingsFile, settings);
4816
+ if (settings.webChats) browserAllowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(true);
4817
+ syncBrowserWatch();
4818
+ refreshUi();
4819
+ break;
4820
+ case "pickFolder": {
4821
+ const r = await import_electron2.dialog.showOpenDialog({ title: "Where agents open web chats", defaultPath: agentFolder() ?? (0, import_node_os3.homedir)(), properties: ["openDirectory", "createDirectory"] });
4822
+ if (!r.canceled && r.filePaths[0]) {
4823
+ settings.agentFolder = r.filePaths[0];
4824
+ await saveSettings(settingsFile, settings);
4825
+ }
4826
+ revealPopover(webChats.has(a.key) ? a.key : null, "user");
4827
+ break;
4828
+ }
3890
4829
  case "setTheme":
3891
4830
  settings.theme = a.theme;
3892
4831
  import_electron2.nativeTheme.themeSource = a.theme;
@@ -3958,8 +4897,9 @@ function onWatchEvent(ev) {
3958
4897
  const now = /* @__PURE__ */ new Date();
3959
4898
  const tracked = applyWatchEvent(state, ev, now);
3960
4899
  refreshUi();
3961
- const targets = pickTargets(ev.ref.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, now);
3962
- const first = targets[0];
4900
+ const agents = pickTargets(ev.ref.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, now);
4901
+ const web = webTargets(ev.ref.tool, ev.event.kind === "halt" && ev.event.halt.reason === "usage_limit", state.limitedUntil, now);
4902
+ const first = orderTargets(agents, web, health.kind(sessionKey(ev.ref)))[0];
3963
4903
  if (ev.event.kind === "halt") {
3964
4904
  if (settings.autoContinue && first && now.getTime() - lastAutoHandoffAt >= AUTO_HANDOFF_COOLDOWN_MS) {
3965
4905
  lastAutoHandoffAt = now.getTime();
@@ -3983,14 +4923,106 @@ function onUsageAlert(a) {
3983
4923
  function dataDir() {
3984
4924
  return import_electron2.app.getPath("userData");
3985
4925
  }
3986
- function onHotkey() {
4926
+ async function onHotkey() {
3987
4927
  if (popover?.isVisible()) {
3988
4928
  hidePopover();
3989
4929
  return;
3990
4930
  }
4931
+ if (settings.webChats && browserAllowed) {
4932
+ const r = await readFrontChat();
4933
+ debug("front chat", r.ok ? { url: r.capture.url, turns: r.capture.turns.length, limit: r.capture.limit } : r.reason);
4934
+ if (r.ok && r.capture.turns.length) {
4935
+ const e = rememberWebChat(state, webChats, r.capture, /* @__PURE__ */ new Date());
4936
+ revealPopover(e.key, "user");
4937
+ return;
4938
+ }
4939
+ }
3991
4940
  const latest = latestSession(state);
3992
4941
  revealPopover(latest ? sessionKey(latest.ref) : null, "user");
3993
4942
  }
4943
+ function canAttach() {
4944
+ return import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
4945
+ }
4946
+ async function attach(chat, file) {
4947
+ const dir = (0, import_node_path24.join)((0, import_node_os3.tmpdir)(), "harlay", "attach");
4948
+ await (0, import_promises20.mkdir)(dir, { recursive: true });
4949
+ const named = (0, import_node_path24.join)(dir, (0, import_node_path24.basename)(file).replace(/^\d{4}-\d{4}-/, ""));
4950
+ await (0, import_promises20.copyFile)(file, named);
4951
+ const r = await pasteFileInto(chat, named);
4952
+ debug("attach", chat, file, r);
4953
+ if (!r.ok) notify(`Attach the handoff in ${targetLabel(chat)}`, `It's on your clipboard: press \u2318V in ${targetLabel(chat)}, then send.`);
4954
+ }
4955
+ function agentFolder() {
4956
+ return settings.agentFolder ?? latestSession(state)?.ref.cwd ?? null;
4957
+ }
4958
+ function syncBrowserWatch() {
4959
+ if (settings.webChats && browserAllowed) browserWatcher.start();
4960
+ else browserWatcher.stop();
4961
+ }
4962
+ var webAlerts = /* @__PURE__ */ new Map();
4963
+ var WEB_ALERT_AGAIN_MS = 15 * 6e4;
4964
+ async function onWebLimit(ev) {
4965
+ const seen = `${ev.url}
4966
+ ${ev.limit}`;
4967
+ const now = Date.now();
4968
+ if (now - (webAlerts.get(seen) ?? 0) < WEB_ALERT_AGAIN_MS) return;
4969
+ const r = await readFrontChat();
4970
+ if (!r.ok || r.capture.url !== ev.url || !r.capture.turns.length) {
4971
+ debug("web limit not read", r.ok ? { url: r.capture.url, turns: r.capture.turns.length } : r.reason);
4972
+ return;
4973
+ }
4974
+ webAlerts.set(seen, now);
4975
+ const near = await frontWindow();
4976
+ const e = rememberWebChat(state, webChats, { ...r.capture, limit: r.capture.limit ?? ev.limit }, /* @__PURE__ */ new Date());
4977
+ debug("web limit", ev.url, ev.limit);
4978
+ refreshUi();
4979
+ const [first] = buildWebSessions([e], settings, available, handoffsInFlight, /* @__PURE__ */ new Date(), state.limitedUntil)[0].targets;
4980
+ if (settings.autoContinue && first && now - lastAutoHandoffAt >= AUTO_HANDOFF_COOLDOWN_MS) {
4981
+ lastAutoHandoffAt = now;
4982
+ void handoffWeb(e.key, first.tool);
4983
+ return;
4984
+ }
4985
+ alertPopover(e.key, near);
4986
+ }
4987
+ async function handoffWeb(key, target) {
4988
+ const w = webChats.get(key);
4989
+ if (!w || handoffsInFlight.has(key)) return;
4990
+ const folder = isWebChat(target) ? null : agentFolder();
4991
+ if (!isWebChat(target)) {
4992
+ const ok = folder ? await (0, import_promises20.stat)(folder).then((s) => s.isDirectory(), () => false) : false;
4993
+ if (!ok) {
4994
+ notify("Harlay needs a folder", `Pick the folder ${targetLabel(target)} should open this chat in (Change, under the choices).`);
4995
+ return;
4996
+ }
4997
+ }
4998
+ handoffsInFlight.add(key);
4999
+ refreshUi();
5000
+ const mode = isWebChat(target) ? "web" : settings.modes[target];
5001
+ const show = presentHandoff({ key, tool: w.capture.chat, title: webChatTitle(w.capture), status: w.status, detail: w.detail }, target, mode);
5002
+ try {
5003
+ show.progress({ stage: "extracted", messages: w.capture.turns.length, filesChanged: 0, chars: w.capture.turns.reduce((n, t) => n + t.text.length, 0) });
5004
+ const res = await runWebChatHandoff({
5005
+ capture: w.capture,
5006
+ target,
5007
+ mode: mode === "web" ? "app" : mode,
5008
+ attach: canAttach(),
5009
+ workdir: folder,
5010
+ store: handoffStore(home),
5011
+ terminalApp: settings.terminalApp,
5012
+ resolve: defaultResolveOptions()
5013
+ });
5014
+ show.progress({ stage: "written", path: res.written?.path ?? "", chars: res.written ? res.written.chars : res.prompt.length });
5015
+ markWebHandedOff(w, target, /* @__PURE__ */ new Date());
5016
+ void show.launched();
5017
+ if (isWebChat(target) && res.written) void attach(target, res.written.path);
5018
+ } catch (e) {
5019
+ show.failed(errorMessage(e));
5020
+ notify("Harlay could not hand off", errorMessage(e));
5021
+ } finally {
5022
+ handoffsInFlight.delete(key);
5023
+ }
5024
+ refreshUi();
5025
+ }
3994
5026
  async function trackPinned() {
3995
5027
  const refs = await Promise.all(
3996
5028
  settings.pinned.filter((k) => !state.sessions.has(k)).map(async (k) => {
@@ -4001,7 +5033,7 @@ async function trackPinned() {
4001
5033
  seedSessions(state, refs.filter((r) => r !== null));
4002
5034
  }
4003
5035
  async function addChat(tool, raw) {
4004
- const id = (0, import_node_path20.basename)(raw.trim()).replace(/\.jsonl?$/, "");
5036
+ const id = (0, import_node_path24.basename)(raw.trim()).replace(/\.jsonl?$/, "");
4005
5037
  if (!/^[\w.-]{4,200}$/.test(id)) return { ok: false, error: "That doesn\u2019t look like a chat ID." };
4006
5038
  const ref = await findSessionById(readers, tool, id).catch(() => null);
4007
5039
  if (!ref) return { ok: false, error: `No ${TOOL_LABEL[tool]} chat with that ID on this Mac.` };
@@ -4019,9 +5051,9 @@ async function loadAgentIcons() {
4019
5051
  const dir = defaultResolveOptions().applicationsDir;
4020
5052
  for (const tool of TOOLS) {
4021
5053
  for (const bundle of APP_BUNDLES[tool]) {
4022
- const path = (0, import_node_path20.join)(dir, bundle);
5054
+ const path = (0, import_node_path24.join)(dir, bundle);
4023
5055
  try {
4024
- await (0, import_promises19.access)(path);
5056
+ await (0, import_promises20.access)(path);
4025
5057
  const thumb = await import_electron2.nativeImage.createThumbnailFromPath(path, { width: 64, height: 64 });
4026
5058
  agentIcons[tool] = thumb.resize({ width: 36, height: 36, quality: "best" }).toDataURL();
4027
5059
  break;
@@ -4029,7 +5061,17 @@ async function loadAgentIcons() {
4029
5061
  }
4030
5062
  }
4031
5063
  }
5064
+ for (const [chat, bundle] of Object.entries(WEB_APP)) {
5065
+ try {
5066
+ const path = (0, import_node_path24.join)(dir, bundle);
5067
+ await (0, import_promises20.access)(path);
5068
+ const thumb = await import_electron2.nativeImage.createThumbnailFromPath(path, { width: 64, height: 64 });
5069
+ agentIcons[chat] = thumb.resize({ width: 36, height: 36, quality: "best" }).toDataURL();
5070
+ } catch {
5071
+ }
5072
+ }
4032
5073
  }
5074
+ var WEB_APP = { "claude-web": "Claude.app", chatgpt: "ChatGPT.app" };
4033
5075
  async function refreshSessions() {
4034
5076
  try {
4035
5077
  seedSessions(state, (await Promise.all(readers.map((r) => r.listSessions(PER_AGENT)))).flat());
@@ -4075,7 +5117,7 @@ function createPopover() {
4075
5117
  acceptFirstMouse: true,
4076
5118
  // backgroundThrottling off: the limit panel opens while another app is in front, and a throttled page would leave
4077
5119
  // its fade-in (and so the whole panel) invisible.
4078
- webPreferences: { preload: (0, import_node_path20.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
5120
+ webPreferences: { preload: (0, import_node_path24.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
4079
5121
  });
4080
5122
  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
4081
5123
  win.on("blur", () => {
@@ -4083,22 +5125,23 @@ function createPopover() {
4083
5125
  });
4084
5126
  win.webContents.on("will-navigate", (e) => e.preventDefault());
4085
5127
  win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
4086
- void win.loadFile((0, import_node_path20.join)(__dirname, "..", "renderer", "popover.html"));
5128
+ void win.loadFile((0, import_node_path24.join)(__dirname, "..", "renderer", "popover.html"));
4087
5129
  return win;
4088
5130
  }
4089
5131
  var debug = process.env.HARLAY_DEBUG ? (...a) => console.log("[harlay]", ...a) : () => void 0;
4090
5132
  var alertKey = null;
4091
- function alertPopover(key) {
5133
+ function alertPopover(key, near = null) {
4092
5134
  debug("limit panel for", key, { popover: !!popover, tray: tray?.getBounds(), visible: popover?.isVisible(), focused: popover?.isFocused() });
4093
5135
  if (!popover || !tray) return;
4094
5136
  if (popover.isVisible() && popover.isFocused()) return;
4095
5137
  alertKey = key;
4096
- const show = () => showPopover(key, false, "user");
5138
+ const show = () => showPopover(key, false, near ?? "user");
4097
5139
  if (popover.webContents.isLoading()) popover.webContents.once("did-finish-load", show);
4098
5140
  else show();
4099
5141
  }
4100
5142
  function closeResolvedAlert() {
4101
5143
  if (!alertKey || !popover?.isVisible() || popover.isFocused()) return;
5144
+ if (isWebKey(alertKey)) return;
4102
5145
  if (state.sessions.get(alertKey)?.status === "limit") return;
4103
5146
  alertKey = null;
4104
5147
  hidePopover();
@@ -4107,7 +5150,7 @@ function showPopover(route = null, focus = true, where = "icon") {
4107
5150
  if (!popover || !tray) return;
4108
5151
  const t = tray.getBounds();
4109
5152
  const iconArea = import_electron2.screen.getDisplayNearestPoint({ x: t.x, y: t.y }).workArea;
4110
- const userArea = where === "user" ? import_electron2.screen.getDisplayNearestPoint(import_electron2.screen.getCursorScreenPoint()).workArea : iconArea;
5153
+ const userArea = where === "icon" ? iconArea : where === "user" ? import_electron2.screen.getDisplayNearestPoint(import_electron2.screen.getCursorScreenPoint()).workArea : import_electron2.screen.getDisplayMatching(where).workArea;
4111
5154
  const { x, y } = panelPosition(t, iconArea, userArea, POPOVER_WIDTH);
4112
5155
  popover.setPosition(Math.round(x), Math.round(y));
4113
5156
  refreshUi();
@@ -4180,13 +5223,13 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
4180
5223
  import_electron2.app.on("second-instance", () => revealPopover());
4181
5224
  import_electron2.app.whenReady().then(async () => {
4182
5225
  import_electron2.app.dock?.hide();
4183
- settingsFile = (0, import_node_path20.join)(import_electron2.app.getPath("userData"), "settings.json");
5226
+ settingsFile = (0, import_node_path24.join)(import_electron2.app.getPath("userData"), "settings.json");
4184
5227
  settings = await loadSettings(settingsFile);
4185
5228
  import_electron2.nativeTheme.themeSource = settings.theme;
4186
5229
  if (import_electron2.app.isPackaged) import_electron2.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
4187
5230
  available = await availableTargets(defaultResolveOptions());
4188
5231
  await loadAgentIcons();
4189
- const iconPath = (0, import_node_path20.join)(__dirname, "..", "assets", "trayTemplate.png");
5232
+ const iconPath = (0, import_node_path24.join)(__dirname, "..", "assets", "trayTemplate.png");
4190
5233
  const icon = import_electron2.nativeImage.createFromPath(iconPath);
4191
5234
  icon.setTemplateImage(true);
4192
5235
  tray = new import_electron2.Tray(icon);
@@ -4221,7 +5264,26 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
4221
5264
  claudeBridge = await bridgeState(home);
4222
5265
  if (claudeBridge === "on") await installBridge(home, dataDir()).catch((e) => console.error("[harlay] status line refresh failed", e));
4223
5266
  await usage.start().catch((e) => console.error("[harlay] usage monitor failed to start", e));
4224
- if (!import_electron2.globalShortcut.register(HOTKEY.accelerator, onHotkey)) console.error(`[harlay] could not register ${HOTKEY.accelerator}`);
5267
+ if (!import_electron2.globalShortcut.register(HOTKEY.accelerator, () => void onHotkey())) console.error(`[harlay] could not register ${HOTKEY.accelerator}`);
5268
+ browserAllowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
5269
+ browserWatcher.on("limit", (ev) => {
5270
+ debug("web limit seen", ev.url, ev.limit);
5271
+ void onWebLimit(ev);
5272
+ });
5273
+ browserWatcher.on("status", (s) => debug("browser", s));
5274
+ browserWatcher.on("untrusted", () => {
5275
+ browserAllowed = false;
5276
+ refreshUi();
5277
+ });
5278
+ syncBrowserWatch();
5279
+ setInterval(() => {
5280
+ if (!settings.webChats) return;
5281
+ const allowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
5282
+ if (allowed === browserAllowed && browserWatcher.running === allowed) return;
5283
+ browserAllowed = allowed;
5284
+ syncBrowserWatch();
5285
+ refreshUi();
5286
+ }, 5e3);
4225
5287
  if (!import_electron2.app.getLoginItemSettings().wasOpenedAtLogin) revealPopover();
4226
5288
  }).catch((e) => {
4227
5289
  console.error("[harlay] startup failed", e);
@@ -4232,5 +5294,6 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
4232
5294
  import_electron2.globalShortcut.unregisterAll();
4233
5295
  watcher?.stop();
4234
5296
  usage?.stop();
5297
+ browserWatcher.stop();
4235
5298
  });
4236
5299
  }