harlay 0.3.1 → 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/README.md +2 -2
- package/app/dist/main.cjs +1268 -205
- package/app/renderer/overlay.js +4 -3
- package/app/renderer/popover.css +13 -5
- package/app/renderer/popover.js +101 -37
- package/dist/harlay.mjs +409 -81
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
|
387
|
-
const nl =
|
|
388
|
-
return ["\u2026(earlier lines cut)", ...lines(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
|
|
393
|
-
const nl =
|
|
394
|
-
return [...lines(nl === -1 ?
|
|
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) || !
|
|
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(
|
|
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
|
|
559
|
-
writing.set(dir,
|
|
560
|
-
void
|
|
561
|
-
if (writing.get(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
|
|
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
|
|
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) =>
|
|
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 =
|
|
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) ?
|
|
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
|
|
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)
|
|
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) &&
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
1555
|
-
const nl =
|
|
1556
|
-
return "\u2026(earlier transcript truncated)\n" + (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
|
|
1789
|
-
o.
|
|
1790
|
-
|
|
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 {
|
|
2123
|
-
import {
|
|
2124
|
-
import {
|
|
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 [
|
|
2134
|
-
if (
|
|
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 =
|
|
2140
|
-
await
|
|
2454
|
+
const set = join12(work, "icon.iconset");
|
|
2455
|
+
await mkdir5(set);
|
|
2141
2456
|
for (const size of [16, 32, 128, 256, 512]) {
|
|
2142
|
-
await
|
|
2143
|
-
await
|
|
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 =
|
|
2146
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
2176
|
-
if (!
|
|
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
|
|
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
|
|
2504
|
+
const work = await mkdtemp2(join12(tmpdir3(), "harlay-app-"));
|
|
2190
2505
|
try {
|
|
2191
|
-
const appSrc =
|
|
2192
|
-
for (const f of APP_FILES) await cp(
|
|
2193
|
-
await
|
|
2194
|
-
|
|
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(
|
|
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:
|
|
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 =
|
|
2219
|
-
await
|
|
2220
|
-
|
|
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
|
|
2223
|
-
await
|
|
2224
|
-
await
|
|
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
|
|
2548
|
+
if (opts.open) await run2("open", [dest]);
|
|
2228
2549
|
return dest;
|
|
2229
2550
|
} finally {
|
|
2230
|
-
await
|
|
2551
|
+
await rm4(work, { recursive: true, force: true });
|
|
2231
2552
|
}
|
|
2232
2553
|
}
|
|
2233
2554
|
|
|
2234
2555
|
// src/main.ts
|
|
2235
|
-
var VERSION = "0.
|
|
2556
|
+
var VERSION = "0.4.0";
|
|
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
|
|
2306
|
-
const target =
|
|
2307
|
-
const
|
|
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
|
-
|
|
2331
|
-
` : "
|
|
2332
|
-
|
|
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
|
`);
|