harlay 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/harlay.mjs CHANGED
@@ -10,6 +10,19 @@ var TOOL_LABEL = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor
10
10
  function isTool(v) {
11
11
  return v === "claude-code" || v === "codex" || v === "cursor";
12
12
  }
13
+ var WEB_LABEL = { "claude-web": "claude.ai", chatgpt: "ChatGPT" };
14
+ function isWebChat(v) {
15
+ return v === "claude-web" || v === "chatgpt";
16
+ }
17
+ function isTarget(v) {
18
+ return isTool(v) || isWebChat(v);
19
+ }
20
+ function targetLabel(t) {
21
+ return isWebChat(t) ? WEB_LABEL[t] : TOOL_LABEL[t];
22
+ }
23
+ function speakerLabel(t) {
24
+ return t === "claude-web" ? "Claude" : targetLabel(t);
25
+ }
13
26
 
14
27
  // ../core/src/errors.ts
15
28
  var HarlayError = class extends Error {
@@ -113,7 +126,7 @@ function slugify(s, max = 40) {
113
126
 
114
127
  // ../core/src/sources/claude.ts
115
128
  import { readFile as readFile3, stat as stat4 } from "node:fs/promises";
116
- import { basename as basename3, join as join4, relative, sep } from "node:path";
129
+ import { basename as basename4, join as join4, relative, sep } from "node:path";
117
130
 
118
131
  // ../core/src/util/files.ts
119
132
  import { createReadStream } from "node:fs";
@@ -175,18 +188,18 @@ function blockText(content) {
175
188
  if (typeof content === "string") return content;
176
189
  if (!Array.isArray(content)) return "";
177
190
  const parts = [];
178
- for (const b of content) if (isObj(b) && b.type === "text" && typeof b.text === "string") parts.push(b.text);
191
+ for (const b of content) if (isObj(b) && (b.type === "text" || b.type === "Text") && typeof b.text === "string") parts.push(b.text);
179
192
  return parts.join("\n");
180
193
  }
181
194
 
182
195
  // ../core/src/sources/folder.ts
183
196
  import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
184
- import { basename as basename2, dirname as dirname2 } from "node:path";
197
+ import { basename as basename3, dirname as dirname2 } from "node:path";
185
198
 
186
199
  // ../core/src/brief/write.ts
187
200
  import { createHash, randomUUID } from "node:crypto";
188
201
  import { link, mkdir as mkdir2, readFile, readdir as readdir2, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
189
- import { basename, join as join3 } from "node:path";
202
+ import { basename as basename2, join as join3 } from "node:path";
190
203
 
191
204
  // ../core/src/launch/launchers.ts
192
205
  import { execFile } from "node:child_process";
@@ -338,6 +351,7 @@ var Redactor = class {
338
351
  };
339
352
 
340
353
  // ../core/src/brief/record.ts
354
+ import { basename } from "node:path";
341
355
  var RECORD_SCHEMA = "harlay/handoff@6";
342
356
  var MESSAGE_SCHEMAS = /* @__PURE__ */ new Set([RECORD_SCHEMA, "harlay/handoff@5"]);
343
357
  var OLD_SCHEMAS = /* @__PURE__ */ new Set(["harlay/handoff@4", "harlay/handoff@3", "harlay/handoff@2"]);
@@ -352,7 +366,8 @@ var REASON_TEXT = {
352
366
  manual: "the user moved it here"
353
367
  };
354
368
  function harlayAbout(from, reason, root) {
355
- 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.`;
369
+ const where = isWebChat(from) ? `chatting with ${targetLabel(from)} in their browser (this project is ${root})` : `working with ${targetLabel(from)} in ${root}`;
370
+ 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.`;
356
371
  }
357
372
  function harlayInstructions(root, createdAt, head) {
358
373
  return [
@@ -364,6 +379,18 @@ function harlayInstructions(root, createdAt, head) {
364
379
  "Open what references lists only if you need it."
365
380
  ];
366
381
  }
382
+ function webAbout(from, reason, root) {
383
+ if (isWebChat(from))
384
+ 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).`;
385
+ return `Harlay context: the user was working with ${targetLabel(from)} on a coding task in ${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.`;
386
+ }
387
+ function webInstructions(from) {
388
+ return [
389
+ "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.",
390
+ "What the user asked for and decided is in task.userMessages, in their own words: follow it.",
391
+ ...isWebChat(from) ? [] : ["You can't see the user's files: ask them to paste any you need."]
392
+ ];
393
+ }
367
394
  var LINE_MAX = 500;
368
395
  function lines(s) {
369
396
  const out = [];
@@ -383,27 +410,27 @@ function lines(s) {
383
410
  }
384
411
  function tailLines(s, max) {
385
412
  if (s.length <= max) return lines(s);
386
- const cut = s.slice(s.length - max);
387
- const nl = cut.indexOf("\n");
388
- return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut : cut.slice(nl + 1))];
413
+ const cut2 = s.slice(s.length - max);
414
+ const nl = cut2.indexOf("\n");
415
+ return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut2 : cut2.slice(nl + 1))];
389
416
  }
390
417
  function headLines(s, max, more) {
391
418
  if (s.length <= max) return lines(s.replace(/\n$/, ""));
392
- const cut = s.slice(0, max);
393
- const nl = cut.lastIndexOf("\n");
394
- return [...lines(nl === -1 ? cut : cut.slice(0, nl)), more];
419
+ const cut2 = s.slice(0, max);
420
+ const nl = cut2.lastIndexOf("\n");
421
+ return [...lines(nl === -1 ? cut2 : cut2.slice(0, nl)), more];
395
422
  }
396
423
  function buildRecord(d, id, previousId, delivery, r) {
397
424
  const a = (s) => wellFormed(r.apply(stripControl(s)));
398
425
  const files = d.git?.files ?? [];
399
426
  const failure = d.lastFailure ? a(d.lastFailure.output).split("\n").slice(-FAILURE_OUTPUT_LINES).join("\n") : "";
400
427
  return {
401
- harlay: harlayAbout(d.from.tool, d.from.reason, d.workdir),
428
+ harlay: isWebChat(delivery.target) ? webAbout(d.from.tool, d.from.reason, d.workdir) : harlayAbout(d.from.tool, d.from.reason, d.workdir),
402
429
  // The id stays near the start: an agent may read only the file's first lines, and its copy is matched by the id.
403
430
  schema: RECORD_SCHEMA,
404
431
  id,
405
432
  createdAt: d.createdAt.toISOString(),
406
- instructions: harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
433
+ instructions: isWebChat(delivery.target) ? webInstructions(d.from.tool) : harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
407
434
  task: {
408
435
  title: a(d.title),
409
436
  originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
@@ -458,7 +485,7 @@ function messagesOf(schema, task) {
458
485
  function validateRecord(raw) {
459
486
  if (!isObj2(raw) || !(MESSAGE_SCHEMAS.has(raw.schema) || OLD_SCHEMAS.has(raw.schema)) || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
460
487
  const { task, from } = raw;
461
- if (!isObj2(from) || !isTool(from.tool)) return null;
488
+ if (!isObj2(from) || !isTarget(from.tool)) return null;
462
489
  if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
463
490
  const messages = messagesOf(raw.schema, task);
464
491
  if (!messages) return null;
@@ -476,7 +503,7 @@ function handoffStore(home) {
476
503
  return join3(home, "Library", "Application Support", "Harlay", "handoffs");
477
504
  }
478
505
  function repoFolder(store, root) {
479
- return join3(store, `${slugify(basename(root), 30)}-${createHash("sha256").update(root).digest("hex").slice(0, 8)}`);
506
+ return join3(store, `${slugify(basename2(root), 30)}-${createHash("sha256").update(root).digest("hex").slice(0, 8)}`);
480
507
  }
481
508
  var LEGACY_FILE = "handoff.harlay";
482
509
  async function readHandoffState(workdir) {
@@ -554,13 +581,13 @@ function addCounts(a, b) {
554
581
  }
555
582
  var writing = /* @__PURE__ */ new Map();
556
583
  function writeHandoff(d, o) {
557
- const dir = repoFolder(o.store, d.workdir);
558
- const run2 = (writing.get(dir) ?? Promise.resolve()).catch(() => void 0).then(() => write(d, o, dir));
559
- writing.set(dir, run2);
560
- void run2.finally(() => {
561
- if (writing.get(dir) === run2) writing.delete(dir);
584
+ const dir = o.dir ?? repoFolder(o.store, d.workdir);
585
+ const run3 = (writing.get(dir) ?? Promise.resolve()).catch(() => void 0).then(() => write(d, o, dir));
586
+ writing.set(dir, run3);
587
+ void run3.finally(() => {
588
+ if (writing.get(dir) === run3) writing.delete(dir);
562
589
  }).catch(() => void 0);
563
- return run2;
590
+ return run3;
564
591
  }
565
592
  async function exists(file) {
566
593
  try {
@@ -579,7 +606,7 @@ async function write(d, o, dir) {
579
606
  const path = join3(dir, `${id}.harlay`);
580
607
  if (await exists(path)) continue;
581
608
  const r = new Redactor();
582
- const prompt = launchPrompt(path);
609
+ const prompt = o.prompt ?? launchPrompt(path);
583
610
  const record = buildRecord(d, id, o.previousId, { target: o.target, mode: o.mode, prompt }, r);
584
611
  const text = JSON.stringify(record, null, 2) + "\n";
585
612
  await writeFile2(tmp, text);
@@ -612,7 +639,7 @@ async function handoffRepo(file) {
612
639
  }
613
640
  const root = raw === null ? null : parseRecord(raw)?.root;
614
641
  if (root && await isDir(root)) return root;
615
- return basename2(file) === LEGACY_FILE && await isDir(dirname2(file)) ? dirname2(file) : null;
642
+ return basename3(file) === LEGACY_FILE && await isDir(dirname2(file)) ? dirname2(file) : null;
616
643
  }
617
644
  async function sessionFolder(opened, firstPrompt2) {
618
645
  const file = firstPrompt2 === null ? null : harlayFileIn(firstPrompt2);
@@ -800,7 +827,7 @@ function normalizeClaude(records, ref, invalid = 0) {
800
827
  }
801
828
  async function loadDesktopTitles(dir) {
802
829
  const out = /* @__PURE__ */ new Map();
803
- const files = await listSessionFiles(dir, (f) => basename3(f).startsWith("local_") && f.endsWith(".json"));
830
+ const files = await listSessionFiles(dir, (f) => basename4(f).startsWith("local_") && f.endsWith(".json"));
804
831
  for (const f of files) {
805
832
  try {
806
833
  const d = JSON.parse(await readFile3(f.path, "utf8"));
@@ -871,7 +898,7 @@ function createClaudeReader(o) {
871
898
  entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
872
899
  cache.set(file, entry);
873
900
  }
874
- const id = basename3(file, ".jsonl");
901
+ const id = basename4(file, ".jsonl");
875
902
  const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
876
903
  if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
877
904
  return entry.ref;
@@ -880,7 +907,7 @@ function createClaudeReader(o) {
880
907
  tool: "claude-code",
881
908
  root,
882
909
  isSessionFile,
883
- idFromPath: (file) => isSessionFile(file) ? basename3(file, ".jsonl") : null,
910
+ idFromPath: (file) => isSessionFile(file) ? basename4(file, ".jsonl") : null,
884
911
  refForFile: (file) => refForFile(file),
885
912
  async listSessions(limit = 20) {
886
913
  const files = await listSessionFiles(root, isSessionFile);
@@ -908,7 +935,7 @@ function createClaudeReader(o) {
908
935
 
909
936
  // ../core/src/sources/codex.ts
910
937
  import { readFile as readFile4, stat as stat5 } from "node:fs/promises";
911
- import { basename as basename4, join as join5, sep as sep2 } from "node:path";
938
+ import { basename as basename5, join as join5, sep as sep2 } from "node:path";
912
939
  var OUTPUT_TAIL2 = 2e4;
913
940
  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;
914
941
  var KIND = { add: "create", update: "modify", delete: "delete" };
@@ -976,8 +1003,9 @@ function normalizeCodex(records, ref, invalid = 0) {
976
1003
  sawItemPrompt = true;
977
1004
  }
978
1005
  } else if (it.type === "AgentMessage") {
979
- const text = blockText(it.content).trim();
1006
+ const { text, calls } = externalCalls(blockText(it.content));
980
1007
  if (text) timeline.push({ kind: "assistant", text });
1008
+ timeline.push(...calls);
981
1009
  halt = null;
982
1010
  } else if (it.type === "CommandExecution") {
983
1011
  const cmd = codexCommand(it.command);
@@ -1021,6 +1049,25 @@ function parseSessionMeta(line) {
1021
1049
  const id = str(p.id);
1022
1050
  return id ? { id, cwd: str(p.cwd) } : null;
1023
1051
  }
1052
+ var EXTERNAL_CALL_RE = /\[external_agent_tool_call: ([\w-]+)\]\n?([\s\S]*?)\[\/external_agent_tool_call\]/g;
1053
+ function externalCalls(raw) {
1054
+ const calls = [];
1055
+ const text = raw.replace(EXTERNAL_CALL_RE, (_m, name, body) => {
1056
+ const command = /^command: (.+)$/m.exec(body)?.[1];
1057
+ let input = {};
1058
+ try {
1059
+ const parsed = JSON.parse(/^input: (\{.*\})$/m.exec(body)?.[1] ?? "{}");
1060
+ if (isObj(parsed)) input = parsed;
1061
+ } catch {
1062
+ }
1063
+ const path = str(input.path) ?? str(input.file_path) ?? str(input.target_file);
1064
+ if (name === "Shell" && command) calls.push({ kind: "command", run: { cmd: command, exitCode: null, output: "" } });
1065
+ else if (name === "Read" && path) calls.push({ kind: "read", path });
1066
+ else if (/^(Edit|Write|StrReplace|MultiEdit|Delete)$/.test(name) && path) calls.push({ kind: "edit", edit: { path, kind: name === "Delete" ? "delete" : "modify" } });
1067
+ return "";
1068
+ });
1069
+ return { text: text.replace(/\n{3,}/g, "\n\n").trim(), calls };
1070
+ }
1024
1071
  function typedPrompt(line) {
1025
1072
  if (!line.includes('"user_message"') && !line.includes('"UserMessage"')) return null;
1026
1073
  let r;
@@ -1051,7 +1098,7 @@ async function loadTitles(file) {
1051
1098
  function createCodexReader(o) {
1052
1099
  const root = join5(o.home, ".codex", "sessions");
1053
1100
  const indexFile = join5(o.home, ".codex", "session_index.jsonl");
1054
- const isSessionFile = (file) => file.startsWith(root + sep2) && basename4(file).startsWith("rollout-") && file.endsWith(".jsonl");
1101
+ const isSessionFile = (file) => file.startsWith(root + sep2) && basename5(file).startsWith("rollout-") && file.endsWith(".jsonl");
1055
1102
  const cache = /* @__PURE__ */ new Map();
1056
1103
  async function refForFile(file, titles) {
1057
1104
  let st;
@@ -1087,7 +1134,7 @@ function createCodexReader(o) {
1087
1134
  tool: "codex",
1088
1135
  root,
1089
1136
  isSessionFile,
1090
- idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(basename4(file))?.[1] ?? null : null,
1137
+ idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(basename5(file))?.[1] ?? null : null,
1091
1138
  refForFile: (file) => refForFile(file),
1092
1139
  async listSessions(limit = 20) {
1093
1140
  const files = await listSessionFiles(root, isSessionFile);
@@ -1369,7 +1416,7 @@ async function takeGitSnapshot(cwd) {
1369
1416
  }
1370
1417
 
1371
1418
  // ../core/src/extract/extract.ts
1372
- import { basename as basename5, isAbsolute, relative as relative3 } from "node:path";
1419
+ import { basename as basename6, isAbsolute, relative as relative3 } from "node:path";
1373
1420
 
1374
1421
  // ../core/src/extract/recent.ts
1375
1422
  var USER_MESSAGES = { budgetChars: 16e3, messageChars: 1500 };
@@ -1460,7 +1507,7 @@ function splitShell(cmd, pipes = false) {
1460
1507
  var isCd = (s) => /^cd(\s|$)/.test(s);
1461
1508
  function isProbeStage(stage) {
1462
1509
  const words = stage.trim().split(/\s+/);
1463
- const first = basename5(words[0] ?? "");
1510
+ const first = basename6(words[0] ?? "");
1464
1511
  if (first !== "git") return PROBES.has(first);
1465
1512
  let i = 1;
1466
1513
  while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
@@ -1551,9 +1598,9 @@ function buildTranscript(s, title, rel, redactor) {
1551
1598
  }
1552
1599
  const out = lines2.join("\n");
1553
1600
  if (out.length <= TRANSCRIPT_MAX) return out;
1554
- const cut = out.slice(out.length - TRANSCRIPT_MAX);
1555
- const nl = cut.indexOf("\n");
1556
- return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
1601
+ const cut2 = out.slice(out.length - TRANSCRIPT_MAX);
1602
+ const nl = cut2.indexOf("\n");
1603
+ return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut2 : cut2.slice(nl + 1));
1557
1604
  }
1558
1605
  function extractHandoff(s, git2, o) {
1559
1606
  const cwd = s.ref.cwd;
@@ -1692,6 +1739,172 @@ async function resolveBinaries(o) {
1692
1739
  return out;
1693
1740
  }
1694
1741
 
1742
+ // ../core/src/launch/web.ts
1743
+ import { basename as basename7 } from "node:path";
1744
+ var NEW_CHAT_URL = { "claude-web": "https://claude.ai/new?q=", chatgpt: "https://chatgpt.com/?q=" };
1745
+ var ATTACHED = {
1746
+ usage_limit: (c) => `My chat with ${c} stopped on a usage limit, and the attached file is that chat.`,
1747
+ crash: (c) => `My chat with ${c} stopped with an error, and the attached file is that chat.`,
1748
+ manual: (c) => `I'm moving my chat with ${c} here, and the attached file is that chat.`
1749
+ };
1750
+ function webAttachPrompt(from, reason) {
1751
+ return `This is the context from Harlay. ${ATTACHED[reason](targetLabel(from))} Read it and pick up where it left off.`;
1752
+ }
1753
+ function webChatUrl(chat, text) {
1754
+ return NEW_CHAT_URL[chat] + encodeURIComponent(text);
1755
+ }
1756
+ var WEB_MAX_BYTES = 10500;
1757
+ var bytes = (s) => encodeURIComponent(s).length;
1758
+ function clipBytes(s, max) {
1759
+ if (bytes(s) <= max) return s;
1760
+ let lo = 0;
1761
+ let hi = s.length;
1762
+ while (lo < hi) {
1763
+ const mid = Math.ceil((lo + hi) / 2);
1764
+ if (bytes(cut(s, mid) + " \u2026") <= max) lo = mid;
1765
+ else hi = mid - 1;
1766
+ }
1767
+ const head = cut(s, lo);
1768
+ const space = head.search(/\s\S*$/);
1769
+ return `${(space > head.length * 0.8 ? head.slice(0, space) : head).trimEnd()} \u2026`;
1770
+ }
1771
+ function cut(s, n) {
1772
+ const c = s.charCodeAt(n - 1);
1773
+ return s.slice(0, c >= 55296 && c <= 56319 ? n - 1 : n);
1774
+ }
1775
+ function chatKind(s, inRepo = false) {
1776
+ for (const t of s.timeline) {
1777
+ if (t.kind === "user" && splitHandoffPrompt(t.text).handoff) return "coding";
1778
+ if (t.kind === "edit") return "coding";
1779
+ if (t.kind === "command" && !/\.harlay\b/.test(t.run.cmd)) return "coding";
1780
+ if (t.kind === "read" && t.how !== "fetched" && !t.path.endsWith(".harlay")) return "coding";
1781
+ }
1782
+ return inRepo ? "coding" : "chat";
1783
+ }
1784
+ var WHY = {
1785
+ usage_limit: (a) => `My chat with ${a} stopped on a usage limit.`,
1786
+ crash: (a) => `My chat with ${a} stopped with an error.`,
1787
+ manual: (a) => `I'm moving my chat with ${a} here.`
1788
+ };
1789
+ var USER_MAX = 2e3;
1790
+ var AGENT_MAX = 1600;
1791
+ var LAST_AGENT_MAX = 3200;
1792
+ var SEP = "\n\n";
1793
+ var DIFF_MIN = 2500;
1794
+ function fitList(items, budget, note, sep4 = SEP) {
1795
+ const gap = bytes(sep4);
1796
+ const size = (xs) => xs.reduce((n, x) => n + bytes(x) + gap, 0);
1797
+ if (size(items) <= budget) return items;
1798
+ const first = items[0];
1799
+ const noteSize = bytes(note(items.length)) + gap;
1800
+ const keepFirst = items.length > 1 && bytes(first) + gap + noteSize <= budget / 3;
1801
+ let room = budget - noteSize - (keepFirst ? bytes(first) + gap : 0);
1802
+ const latest = [];
1803
+ for (let i = items.length - 1; i >= (keepFirst ? 1 : 0); i--) {
1804
+ const c = bytes(items[i]) + gap;
1805
+ if (c > room) {
1806
+ if (!latest.length) latest.unshift(clipBytes(items[i], Math.max(0, room - gap)));
1807
+ break;
1808
+ }
1809
+ room -= c;
1810
+ latest.unshift(items[i]);
1811
+ }
1812
+ const left = items.length - latest.length - (keepFirst ? 1 : 0);
1813
+ return [...keepFirst ? [first] : [], ...left ? [note(left)] : [], ...latest];
1814
+ }
1815
+ function webMessage(d, s, kind, max = WEB_MAX_BYTES) {
1816
+ const r = new Redactor();
1817
+ const a = (x) => wellFormed(r.apply(stripControl(x))).trim();
1818
+ const agent = speakerLabel(d.from.tool);
1819
+ const turns = conversation(s, a);
1820
+ const unanswered = turns.at(-1)?.who === "me" ? `(The chat stopped here, before ${agent} answered.)` : null;
1821
+ 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.`];
1822
+ const room = (rest) => max - bytes([...parts, ...rest].join(SEP)) - 2 * bytes(SEP);
1823
+ const done = () => ({ text: clipBytes(parts.join(SEP), max), redactions: { ...r.counts } });
1824
+ const left = (n) => `(${n} earlier message${n === 1 ? "" : "s"} left out)`;
1825
+ if (kind === "chat") {
1826
+ const items = turns.map(
1827
+ (t, i) => t.who === "me" ? `Me: ${clipBytes(t.text, USER_MAX)}` : `${agent}: ${clipBytes(t.text, i === turns.length - 1 ? LAST_AGENT_MAX : AGENT_MAX)}`
1828
+ );
1829
+ const tail2 = unanswered ? [unanswered] : [];
1830
+ if (items.length) parts.push("Conversation so far:");
1831
+ parts.push(...fitList(items, room(tail2), left), ...tail2);
1832
+ return done();
1833
+ }
1834
+ const branch = d.git?.branch ? ` on branch ${d.git.branch}` : "";
1835
+ parts.push(`It's a coding task in my project ${basename7(d.workdir)}${branch}. You can't see my files, so ask me to paste any you need.`);
1836
+ parts.push(`Task: ${a(d.title)}`);
1837
+ const facts = [];
1838
+ if (d.whereItStopped && !unanswered) facts.push(`${agent}'s last message:
1839
+ ${clipBytes(a(d.whereItStopped), LAST_AGENT_MAX)}`);
1840
+ if (unanswered) facts.push(workedAfterLastMessage(s) ? `(It stopped while working on my last message, before ${agent} replied.)` : unanswered);
1841
+ const changed = [.../* @__PURE__ */ new Set([...d.git?.files.map((f) => f.path) ?? [], ...d.filesTouched])];
1842
+ if (changed.length) facts.push(`Files changed: ${changed.slice(0, 15).map(a).join(", ")}${changed.length > 15 ? ` and ${changed.length - 15} more` : ""}`);
1843
+ if (d.lastFailure) {
1844
+ const out = d.lastFailure.output.trim().split("\n").slice(-25).join("\n");
1845
+ facts.push(`Still failing: \`${a(d.lastFailure.cmd)}\` (exit ${d.lastFailure.exitCode})${out ? `
1846
+ ${clipBytes(a(out), 1500)}` : ""}`);
1847
+ }
1848
+ if (d.todos.length) facts.push(`Still to do:
1849
+ ${d.todos.map((t) => `- ${a(t.text)}${t.status === "in_progress" ? " (in progress)" : ""}`).join("\n")}`);
1850
+ const diff = d.git?.diff ? a(d.git.diff) : "";
1851
+ const heading = "What I asked, oldest first:";
1852
+ if (d.userMessages.length) {
1853
+ const asked = d.userMessages.map((m) => `- ${clipBytes(a(m), USER_MAX)}`);
1854
+ const free = room([heading, ...facts]);
1855
+ const budget = diff ? Math.max(free - DIFF_MIN, free * 0.6) : free;
1856
+ const list = fitList(asked, budget, (n) => `- ${left(n + d.omittedMessages)}`, "\n");
1857
+ if (d.omittedMessages && list.length === asked.length) list.splice(1, 0, `- ${left(d.omittedMessages)}`);
1858
+ parts.push(`${heading}
1859
+ ${list.join("\n")}`);
1860
+ }
1861
+ parts.push(...facts);
1862
+ const diffHead = "Uncommitted changes (git diff):";
1863
+ const diffRoom = room([diffHead]) - bytes("\n");
1864
+ if (diff && diffRoom >= 600) parts.push(`${diffHead}
1865
+ ${bytes(diff) <= diffRoom ? diff : clipLines(diff, diffRoom)}`);
1866
+ return done();
1867
+ }
1868
+ function clipLines(s, max) {
1869
+ const out = [];
1870
+ let n = bytes("\n\u2026(cut)");
1871
+ for (const line of s.split("\n")) {
1872
+ const c = bytes(`${line}
1873
+ `);
1874
+ if (n + c > max) break;
1875
+ n += c;
1876
+ out.push(line);
1877
+ }
1878
+ return [...out, "\u2026(cut)"].join("\n");
1879
+ }
1880
+ function workedAfterLastMessage(s) {
1881
+ for (let i = s.timeline.length - 1; i >= 0; i--) {
1882
+ const t = s.timeline[i];
1883
+ if (t.kind === "user") return false;
1884
+ if (t.kind === "command" || t.kind === "edit") return true;
1885
+ }
1886
+ return false;
1887
+ }
1888
+ function conversation(s, a) {
1889
+ const turns = [];
1890
+ for (const t of s.timeline) {
1891
+ if (t.kind === "user") {
1892
+ const text = a(splitHandoffPrompt(t.text).rest);
1893
+ if (!text || /^\/[\w:-]+$/.test(text.split(/\s/)[0]) && text.length < 200) continue;
1894
+ turns.push({ who: "me", text });
1895
+ } else if (t.kind === "assistant") {
1896
+ const text = a(t.text);
1897
+ if (!text) continue;
1898
+ const last = turns.at(-1);
1899
+ if (last?.who === "agent") last.text += `
1900
+
1901
+ ${text}`;
1902
+ else turns.push({ who: "agent", text });
1903
+ }
1904
+ }
1905
+ return turns;
1906
+ }
1907
+
1695
1908
  // ../core/src/sources/index.ts
1696
1909
  import { realpath } from "node:fs/promises";
1697
1910
  import { homedir as homedir2 } from "node:os";
@@ -1743,6 +1956,16 @@ function parseTool(name) {
1743
1956
  if (n === "cursor") return "cursor";
1744
1957
  throw new HarlayError(`Unknown tool "${name}". Use claude, codex or cursor.`);
1745
1958
  }
1959
+ function parseTarget(name) {
1960
+ const n = name.trim().toLowerCase();
1961
+ if (n === "claude.ai" || n === "claude-web") return "claude-web";
1962
+ if (n === "chatgpt" || n === "chatgpt.com") return "chatgpt";
1963
+ try {
1964
+ return parseTool(name);
1965
+ } catch {
1966
+ throw new HarlayError(`Unknown target "${name}". Use claude, codex, cursor, claude.ai or chatgpt.`);
1967
+ }
1968
+ }
1746
1969
  function parseSessionSpec(spec) {
1747
1970
  const i = spec.indexOf(":");
1748
1971
  if (i <= 0 || i === spec.length - 1) throw new HarlayError(`Invalid --session "${spec}". Use <tool>:<id>, for example codex:019a\u2026`);
@@ -1754,6 +1977,11 @@ import { stat as stat8 } from "node:fs/promises";
1754
1977
  import { tmpdir } from "node:os";
1755
1978
  import { join as join8 } from "node:path";
1756
1979
  async function launchHandoff(o) {
1980
+ if (isWebChat(o.target)) {
1981
+ const plan2 = { target: o.target, mode: "web", steps: [{ kind: "open", args: [webChatUrl(o.target, o.prompt)] }] };
1982
+ await executeLaunch(plan2, o.runner);
1983
+ return plan2;
1984
+ }
1757
1985
  const binaries = await resolveBinaries(o.resolve ?? defaultResolveOptions());
1758
1986
  const plan = planLaunch({
1759
1987
  target: o.target,
@@ -1785,9 +2013,11 @@ async function runHandoff(o) {
1785
2013
  filesChanged: git2 ? git2.files.length : data.filesTouched.length,
1786
2014
  chars: sessionChars(session)
1787
2015
  });
1788
- const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
1789
- o.onProgress?.({ stage: "written", path: written.path, chars: written.chars });
1790
- if (o.launch === false) return { written, plan: null, data };
2016
+ const kind = chatKind(session, git2 !== null);
2017
+ const web = isWebChat(o.target) ? o.attach ? webAttachPrompt(data.from.tool, data.from.reason) : webMessage(data, session, kind).text : null;
2018
+ const written = await writeHandoff(data, { target: o.target, mode: web === null ? o.mode : "web", previousId: data.carriedFrom, store: o.store, prompt: web ?? void 0 });
2019
+ o.onProgress?.({ stage: "written", path: written.path, chars: web !== null && !o.attach ? web.length : written.chars });
2020
+ if (o.launch === false) return { written, plan: null, data, kind };
1791
2021
  let plan;
1792
2022
  try {
1793
2023
  plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
@@ -1795,7 +2025,7 @@ async function runHandoff(o) {
1795
2025
  const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
1796
2026
  throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
1797
2027
  }
1798
- return { written, plan, data };
2028
+ return { written, plan, data, kind };
1799
2029
  }
1800
2030
  function sessionChars(s) {
1801
2031
  let n = 0;
@@ -2117,43 +2347,128 @@ var HEALTH = {
2117
2347
  };
2118
2348
 
2119
2349
  // src/app.ts
2350
+ import { execFile as execFile4 } from "node:child_process";
2351
+ import { existsSync as existsSync2 } from "node:fs";
2352
+ import { cp, mkdir as mkdir5, mkdtemp as mkdtemp2, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
2353
+ import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
2354
+ import { dirname as dirname3, join as join12, resolve } from "node:path";
2355
+ import { fileURLToPath } from "node:url";
2356
+ import { promisify as promisify4 } from "node:util";
2357
+
2358
+ // src/signing.ts
2120
2359
  import { execFile as execFile3 } from "node:child_process";
2360
+ import { randomBytes } from "node:crypto";
2121
2361
  import { existsSync } from "node:fs";
2122
- import { cp, mkdir as mkdir4, mkdtemp, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
2123
- import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
2124
- import { dirname as dirname3, join as join11, resolve } from "node:path";
2125
- import { fileURLToPath } from "node:url";
2362
+ import { chmod as chmod3, mkdir as mkdir4, mkdtemp, readFile as readFile7, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
2363
+ import { tmpdir as tmpdir2 } from "node:os";
2364
+ import { join as join11 } from "node:path";
2126
2365
  import { promisify as promisify3 } from "node:util";
2127
2366
  var run = promisify3(execFile3);
2367
+ var SIGNING_NAME = "Harlay Local Signing";
2368
+ var CERT_CONFIG = `[req]
2369
+ distinguished_name = dn
2370
+ x509_extensions = ext
2371
+ prompt = no
2372
+ [dn]
2373
+ CN = ${SIGNING_NAME}
2374
+ [ext]
2375
+ basicConstraints = critical, CA:false
2376
+ keyUsage = critical, digitalSignature
2377
+ extendedKeyUsage = critical, codeSigning
2378
+ `;
2379
+ function identityHash(out) {
2380
+ const line = out.split("\n").find((l) => l.includes(`"${SIGNING_NAME}"`));
2381
+ return /\b([0-9A-F]{40})\b/.exec(line ?? "")?.[1] ?? null;
2382
+ }
2383
+ async function listedKeychains() {
2384
+ return [...(await run("security", ["list-keychains", "-d", "user"])).stdout.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
2385
+ }
2386
+ async function withKeychainListed(keychain, fn) {
2387
+ const listed = await listedKeychains();
2388
+ await run("security", ["list-keychains", "-d", "user", "-s", ...listed.filter((k) => k !== keychain), keychain]);
2389
+ try {
2390
+ return await fn();
2391
+ } finally {
2392
+ await run("security", ["list-keychains", "-d", "user", "-s", ...listed]);
2393
+ }
2394
+ }
2395
+ async function create(dir, keychain, passFile) {
2396
+ await mkdir4(dir, { recursive: true, mode: 448 });
2397
+ const password = randomBytes(24).toString("hex");
2398
+ await writeFile4(passFile, password, { mode: 384 });
2399
+ const listed = await listedKeychains();
2400
+ await run("security", ["create-keychain", "-p", password, keychain]);
2401
+ await run("security", ["list-keychains", "-d", "user", "-s", ...listed]);
2402
+ await run("security", ["set-keychain-settings", keychain]);
2403
+ const work = await mkdtemp(join11(tmpdir2(), "harlay-cert-"));
2404
+ try {
2405
+ const p12Pass = randomBytes(12).toString("hex");
2406
+ await writeFile4(join11(work, "cert.cnf"), CERT_CONFIG);
2407
+ await run("/usr/bin/openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650", "-config", join11(work, "cert.cnf"), "-keyout", join11(work, "key.pem"), "-out", join11(dir, "cert.pem")]);
2408
+ await run("/usr/bin/openssl", ["pkcs12", "-export", "-inkey", join11(work, "key.pem"), "-in", join11(dir, "cert.pem"), "-name", SIGNING_NAME, "-out", join11(work, "id.p12"), "-passout", `pass:${p12Pass}`]);
2409
+ await run("security", ["unlock-keychain", "-p", password, keychain]);
2410
+ await run("security", ["import", join11(work, "id.p12"), "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign"]);
2411
+ await run("security", ["set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain]);
2412
+ } finally {
2413
+ await rm3(work, { recursive: true, force: true });
2414
+ }
2415
+ }
2416
+ async function localSigning(dataDir, log, trust = true) {
2417
+ const dir = join11(dataDir, "signing");
2418
+ const keychain = join11(dir, "harlay.keychain-db");
2419
+ const passFile = join11(dir, "keychain-password");
2420
+ if (!existsSync(keychain) || !existsSync(passFile)) {
2421
+ await rm3(dir, { recursive: true, force: true });
2422
+ await create(dir, keychain, passFile);
2423
+ }
2424
+ await chmod3(passFile, 384);
2425
+ await run("security", ["unlock-keychain", "-p", (await readFile7(passFile, "utf8")).trim(), keychain]);
2426
+ const hash = identityHash((await run("security", ["find-identity", "-p", "codesigning", keychain])).stdout);
2427
+ if (!hash) return null;
2428
+ const valid = async () => identityHash((await run("security", ["find-identity", "-v", "-p", "codesigning", keychain])).stdout) === hash;
2429
+ if (await valid()) return { keychain, hash };
2430
+ if (!trust) return null;
2431
+ log("macOS will ask for your password to trust Harlay\u2019s signing certificate (once), so permissions you give Harlay last across updates.\n");
2432
+ try {
2433
+ await run("security", ["add-trusted-cert", "-p", "codeSign", "-k", keychain, join11(dir, "cert.pem")]);
2434
+ } catch {
2435
+ log("Not trusted: Harlay is signed ad hoc, so macOS asks for its permissions again after each update.\n");
2436
+ return null;
2437
+ }
2438
+ return await valid() ? { keychain, hash } : null;
2439
+ }
2440
+
2441
+ // src/app.ts
2442
+ var run2 = promisify4(execFile4);
2128
2443
  var ELECTRON_VERSION = "44.5.1";
2129
2444
  var APP_BUNDLE_ID = "com.corelabs.harlay";
2130
2445
  var APP_FILES = ["dist", "renderer", "assets"];
2131
2446
  function findAppSource(cliFile = fileURLToPath(import.meta.url)) {
2132
2447
  const pkg = resolve(dirname3(cliFile), "..");
2133
- for (const dir of [join11(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
2134
- if (existsSync(join11(dir, "dist", "main.cjs")) && existsSync(join11(dir, "renderer", "popover.html")) && existsSync(join11(dir, "assets", "icon.png"))) return dir;
2448
+ for (const dir of [join12(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
2449
+ if (existsSync2(join12(dir, "dist", "main.cjs")) && existsSync2(join12(dir, "renderer", "popover.html")) && existsSync2(join12(dir, "assets", "icon.png"))) return dir;
2135
2450
  }
2136
2451
  return null;
2137
2452
  }
2138
2453
  async function makeIcns(png, work) {
2139
- const set = join11(work, "icon.iconset");
2140
- await mkdir4(set);
2454
+ const set = join12(work, "icon.iconset");
2455
+ await mkdir5(set);
2141
2456
  for (const size of [16, 32, 128, 256, 512]) {
2142
- await run("sips", ["-z", String(size), String(size), png, "--out", join11(set, `icon_${size}x${size}.png`)]);
2143
- await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out", join11(set, `icon_${size}x${size}@2x.png`)]);
2457
+ await run2("sips", ["-z", String(size), String(size), png, "--out", join12(set, `icon_${size}x${size}.png`)]);
2458
+ await run2("sips", ["-z", String(size * 2), String(size * 2), png, "--out", join12(set, `icon_${size}x${size}@2x.png`)]);
2144
2459
  }
2145
- const icns = join11(work, "icon.icns");
2146
- await run("iconutil", ["-c", "icns", set, "-o", icns]);
2460
+ const icns = join12(work, "icon.icns");
2461
+ await run2("iconutil", ["-c", "icns", set, "-o", icns]);
2147
2462
  return icns;
2148
2463
  }
2149
2464
  async function quitRunningApp() {
2150
2465
  try {
2151
- await run("osascript", ["-e", `if application id "${APP_BUNDLE_ID}" is running then tell application id "${APP_BUNDLE_ID}" to quit`]);
2466
+ await run2("osascript", ["-e", `if application id "${APP_BUNDLE_ID}" is running then tell application id "${APP_BUNDLE_ID}" to quit`]);
2152
2467
  } catch {
2153
2468
  }
2154
2469
  for (let i = 0; i < 20; i++) {
2155
2470
  try {
2156
- await run("pgrep", ["-f", "Harlay.app/Contents/MacOS/Harlay$"]);
2471
+ await run2("pgrep", ["-f", "Harlay.app/Contents/MacOS/Harlay$"]);
2157
2472
  } catch {
2158
2473
  return;
2159
2474
  }
@@ -2161,7 +2476,7 @@ async function quitRunningApp() {
2161
2476
  }
2162
2477
  }
2163
2478
  function harlayDataDir(home = homedir3()) {
2164
- return join11(home, "Library", "Application Support", "Harlay");
2479
+ return join12(home, "Library", "Application Support", "Harlay");
2165
2480
  }
2166
2481
  async function uninstallApp(opts) {
2167
2482
  await quitRunningApp();
@@ -2172,13 +2487,13 @@ async function uninstallApp(opts) {
2172
2487
  opts.log(`Left ~/.claude/settings.json unchanged: ${e instanceof Error ? e.message : String(e)}
2173
2488
  `);
2174
2489
  }
2175
- const app = join11(opts.dir, "Harlay.app");
2176
- if (!existsSync(app)) {
2490
+ const app = join12(opts.dir, "Harlay.app");
2491
+ if (!existsSync2(app)) {
2177
2492
  opts.log(`No Harlay.app in ${opts.dir}.
2178
2493
  `);
2179
2494
  return;
2180
2495
  }
2181
- await run("osascript", ["-e", "on run argv", "-e", 'tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)', "-e", "end run", app]);
2496
+ await run2("osascript", ["-e", "on run argv", "-e", 'tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)', "-e", "end run", app]);
2182
2497
  opts.log(`Moved ${app} to the Trash.
2183
2498
  `);
2184
2499
  }
@@ -2186,21 +2501,21 @@ async function installApp(opts) {
2186
2501
  if (process.platform !== "darwin") throw new Error("The Harlay app is macOS only; the CLI works without it.");
2187
2502
  const source = findAppSource();
2188
2503
  if (!source) throw new Error("App files are missing from this install. Reinstall with `npm install -g harlay`.");
2189
- const work = await mkdtemp(join11(tmpdir2(), "harlay-app-"));
2504
+ const work = await mkdtemp2(join12(tmpdir3(), "harlay-app-"));
2190
2505
  try {
2191
- const appSrc = join11(work, "src");
2192
- for (const f of APP_FILES) await cp(join11(source, f), join11(appSrc, f), { recursive: true });
2193
- await writeFile4(
2194
- join11(appSrc, "package.json"),
2506
+ const appSrc = join12(work, "src");
2507
+ for (const f of APP_FILES) await cp(join12(source, f), join12(appSrc, f), { recursive: true });
2508
+ await writeFile5(
2509
+ join12(appSrc, "package.json"),
2195
2510
  JSON.stringify({ name: "harlay-desktop", productName: "Harlay", version: opts.version, main: "dist/main.cjs" }, null, 2)
2196
2511
  );
2197
- const icon = await makeIcns(join11(source, "assets", "icon.png"), work);
2512
+ const icon = await makeIcns(join12(source, "assets", "icon.png"), work);
2198
2513
  opts.log(`Building Harlay.app (Electron ${ELECTRON_VERSION}; the first install downloads it, about 100 MB)\u2026
2199
2514
  `);
2200
2515
  const { packager } = await import("@electron/packager");
2201
2516
  const [outDir] = await packager({
2202
2517
  dir: appSrc,
2203
- out: join11(work, "out"),
2518
+ out: join12(work, "out"),
2204
2519
  name: "Harlay",
2205
2520
  platform: "darwin",
2206
2521
  arch: process.arch === "arm64" ? "arm64" : "x64",
@@ -2215,29 +2530,35 @@ async function installApp(opts) {
2215
2530
  quiet: true,
2216
2531
  extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: "alert" }
2217
2532
  });
2218
- const built = join11(outDir, "Harlay.app");
2219
- await run("codesign", ["--force", "--deep", "--sign", "-", built]);
2220
- const dest = join11(opts.dir, "Harlay.app");
2533
+ const built = join12(outDir, "Harlay.app");
2534
+ const signing = await localSigning(harlayDataDir(), opts.log).catch((e) => {
2535
+ opts.log(`Signing ad hoc (${e instanceof Error ? e.message.split("\n")[0] : String(e)}).
2536
+ `);
2537
+ return null;
2538
+ });
2539
+ if (signing) await withKeychainListed(signing.keychain, () => run2("codesign", ["--force", "--deep", "--keychain", signing.keychain, "--sign", signing.hash, built]));
2540
+ else await run2("codesign", ["--force", "--deep", "--sign", "-", built]);
2541
+ const dest = join12(opts.dir, "Harlay.app");
2221
2542
  await quitRunningApp();
2222
- await mkdir4(opts.dir, { recursive: true });
2223
- await rm3(dest, { recursive: true, force: true });
2224
- await run("ditto", [built, dest]);
2543
+ await mkdir5(opts.dir, { recursive: true });
2544
+ await rm4(dest, { recursive: true, force: true });
2545
+ await run2("ditto", [built, dest]);
2225
2546
  opts.log(`Installed ${dest}
2226
2547
  `);
2227
- if (opts.open) await run("open", [dest]);
2548
+ if (opts.open) await run2("open", [dest]);
2228
2549
  return dest;
2229
2550
  } finally {
2230
- await rm3(work, { recursive: true, force: true });
2551
+ await rm4(work, { recursive: true, force: true });
2231
2552
  }
2232
2553
  }
2233
2554
 
2234
2555
  // src/main.ts
2235
- var VERSION = "0.3.1";
2556
+ var VERSION = "0.4.1";
2236
2557
  var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
2237
2558
 
2238
2559
  Usage:
2239
2560
  harlay sessions [--limit N] [--json]
2240
- harlay handoff --to <claude|codex|cursor> [--session <tool>:<id>] [--mode app|terminal] [--no-launch] [--terminal <App>] [--wait]
2561
+ harlay handoff --to <claude|codex|cursor|claude.ai|chatgpt> [--session <tool>:<id>] [--mode app|terminal] [--no-launch] [--terminal <App>] [--wait]
2241
2562
  harlay watch [--auto <claude|codex|cursor>] [--mode app|terminal] [--threshold N]
2242
2563
  harlay app install [--dir <folder>] [--no-open] install or update the macOS menu bar app (default folder: /Applications)
2243
2564
  harlay app uninstall [--dir <folder>] quit it, restore Claude Code's status line, move it to the Trash
@@ -2302,9 +2623,12 @@ async function cmdHandoff(args, readers, ctx) {
2302
2623
  },
2303
2624
  strict: true
2304
2625
  });
2305
- if (!values.to) throw new UsageError("--to is required (claude, codex or cursor)");
2306
- const target = parseTool(values.to);
2307
- const mode = parseMode(values.mode, target);
2626
+ if (!values.to) throw new UsageError("--to is required (claude, codex, cursor, claude.ai or chatgpt)");
2627
+ const target = parseTarget(values.to);
2628
+ const web = isWebChat(target);
2629
+ if (web && values.mode !== void 0) throw new UsageError("--mode is for agents; a web chat opens in your browser");
2630
+ if (web && values.wait) throw new UsageError("--wait is for agents; Harlay can't see a web chat");
2631
+ const mode = web ? "app" : parseMode(values.mode, target);
2308
2632
  const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir4());
2309
2633
  const since = /* @__PURE__ */ new Date();
2310
2634
  const res = await runHandoff({
@@ -2318,7 +2642,8 @@ async function cmdHandoff(args, readers, ctx) {
2318
2642
  runner: ctx.runner,
2319
2643
  resolve: defaultResolveOptions(ctx.env)
2320
2644
  });
2321
- ctx.out(`Handoff: ${res.written.path}
2645
+ ctx.out(web ? `Handoff: ${res.written.path}
2646
+ ` : `Handoff: ${res.written.path}
2322
2647
  Prompt: ${res.written.prompt}
2323
2648
  `);
2324
2649
  const kinds = Object.keys(res.written.redactions);
@@ -2327,9 +2652,12 @@ Prompt: ${res.written.prompt}
2327
2652
  ctx.out(`Redacted ${total} secret${total === 1 ? "" : "s"} (${kinds.join(", ")}).
2328
2653
  `);
2329
2654
  }
2330
- ctx.out(res.plan ? `Continuing in ${TOOL_LABEL[target]} (${mode}).
2331
- ` : "Not launched (--no-launch).\n");
2332
- if (!values.wait) return 0;
2655
+ if (!res.plan) ctx.out("Not launched (--no-launch).\n");
2656
+ else if (isWebChat(target)) ctx.out(`Continuing in ${targetLabel(target)} in your browser: press Send there (${res.kind === "coding" ? "a coding task" : "a chat"}, ${res.written.prompt.length.toLocaleString("en-US")} characters).
2657
+ `);
2658
+ else ctx.out(`Continuing in ${TOOL_LABEL[target]} (${mode}).
2659
+ `);
2660
+ if (!values.wait || isWebChat(target)) return 0;
2333
2661
  const seconds = Number(ctx.env.HARLAY_WAIT_SECONDS ?? WAIT_SECONDS);
2334
2662
  ctx.out(`Waiting for ${TOOL_LABEL[target]} to read it (up to ${seconds} s)\u2026
2335
2663
  `);