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/README.md +2 -2
- package/app/dist/main.cjs +1321 -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 +3 -2
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
|
|
5
|
+
var import_promises20 = require("node:fs/promises");
|
|
6
6
|
var import_node_os3 = require("node:os");
|
|
7
|
-
var
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
400
|
-
const nl =
|
|
401
|
-
return ["\u2026(earlier lines cut)", ...lines(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
|
|
406
|
-
const nl =
|
|
407
|
-
return [...lines(nl === -1 ?
|
|
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) || !
|
|
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,
|
|
517
|
+
return (0, import_node_path4.join)(home2, "Library", "Application Support", "Harlay", "handoffs");
|
|
490
518
|
}
|
|
491
519
|
function repoFolder(store, root) {
|
|
492
|
-
return (0,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
830
|
-
const indexDir = (0,
|
|
831
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
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,
|
|
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,
|
|
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
|
|
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)
|
|
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,
|
|
1066
|
-
const indexFile = (0,
|
|
1067
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 +
|
|
1234
|
-
const parts = (0,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
1568
|
-
const nl =
|
|
1569
|
-
return "\u2026(earlier transcript truncated)\n" + (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,
|
|
1577
|
-
const r = (0,
|
|
1578
|
-
return r.startsWith("..") || (0,
|
|
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,
|
|
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
|
|
1680
|
+
const pad2 = (n2) => String(n2).padStart(2, "0");
|
|
1621
1681
|
const n = o.now;
|
|
1622
|
-
const baseId = `${
|
|
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
|
|
1716
|
+
var import_promises9 = require("node:fs/promises");
|
|
1657
1717
|
var import_node_os = require("node:os");
|
|
1658
|
-
var
|
|
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,
|
|
1732
|
+
return [(0, import_node_path11.join)(o.home, ".local", "bin", "claude")];
|
|
1673
1733
|
case "codex":
|
|
1674
1734
|
return [
|
|
1675
|
-
(0,
|
|
1676
|
-
(0,
|
|
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,
|
|
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,
|
|
1685
|
-
await (0,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
1987
|
+
var import_promises10 = require("node:fs/promises");
|
|
1762
1988
|
var import_node_os2 = require("node:os");
|
|
1763
|
-
var
|
|
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,
|
|
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,
|
|
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
|
|
1797
|
-
o.
|
|
1798
|
-
|
|
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
|
|
1855
|
-
var
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
2054
|
-
var
|
|
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,
|
|
2079
|
-
const fh = await (0,
|
|
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 -
|
|
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,
|
|
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
|
|
2197
|
-
var
|
|
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,
|
|
2458
|
+
const root = (0, import_node_path16.join)(this.home, ".claude", "projects");
|
|
2226
2459
|
let names;
|
|
2227
2460
|
try {
|
|
2228
|
-
names = await (0,
|
|
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,
|
|
2467
|
+
const path = (0, import_node_path16.join)(root, name);
|
|
2235
2468
|
try {
|
|
2236
|
-
const s = await (0,
|
|
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,
|
|
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
|
|
2456
|
-
var
|
|
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,
|
|
2692
|
+
return (0, import_node_path17.join)(home2, ".claude", "settings.json");
|
|
2460
2693
|
}
|
|
2461
2694
|
function claudeStatusFile(dataDir2) {
|
|
2462
|
-
return (0,
|
|
2695
|
+
return (0, import_node_path17.join)(dataDir2, "claude-status.json");
|
|
2463
2696
|
}
|
|
2464
|
-
var scriptPath = (dataDir2) => (0,
|
|
2465
|
-
var previousJson = (dataDir2) => (0,
|
|
2466
|
-
var previousCommand = (dataDir2) => (0,
|
|
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,
|
|
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,
|
|
2513
|
-
await (0,
|
|
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,
|
|
2526
|
-
await (0,
|
|
2527
|
-
await (0,
|
|
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,
|
|
2536
|
-
await (0,
|
|
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,
|
|
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,
|
|
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,
|
|
2556
|
-
await (0,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,178 @@ 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
|
+
var LIMIT_NOTICES = [
|
|
3199
|
+
// claude.ai
|
|
3200
|
+
"Usage limit reached",
|
|
3201
|
+
"You've reached your session limit",
|
|
3202
|
+
"You've reached your weekly limit",
|
|
3203
|
+
"You've reached your weekly Cowork limit",
|
|
3204
|
+
"You've reached your usage limit",
|
|
3205
|
+
"You've reached your plan's usage limit",
|
|
3206
|
+
"You've reached your monthly spend limit",
|
|
3207
|
+
"You've reached your * limit. Get more usage",
|
|
3208
|
+
"Reached your session limit",
|
|
3209
|
+
"Reached your weekly limit",
|
|
3210
|
+
"Reached your weekly Cowork limit",
|
|
3211
|
+
"Reached your usage limit",
|
|
3212
|
+
"Reached your * limit$",
|
|
3213
|
+
"* limit reached$",
|
|
3214
|
+
"Out of free messages",
|
|
3215
|
+
"You hit your message limit",
|
|
3216
|
+
"You hit your 5-hour message limit",
|
|
3217
|
+
"You hit your weekly message limit",
|
|
3218
|
+
"You hit your weekly * limit",
|
|
3219
|
+
"You hit your usage limit",
|
|
3220
|
+
"You hit the Free plan's",
|
|
3221
|
+
"You just hit your free message limit",
|
|
3222
|
+
"You've hit your limit for Claude messages",
|
|
3223
|
+
"You've hit your session limit",
|
|
3224
|
+
"You've hit your weekly limit",
|
|
3225
|
+
"You've hit your usage limit",
|
|
3226
|
+
"You've hit your monthly spend limit",
|
|
3227
|
+
"Your usage limit is still exceeded",
|
|
3228
|
+
"Paused: Your usage limit",
|
|
3229
|
+
"Limit hit. Your week resets",
|
|
3230
|
+
"Limit hit. You can send new messages",
|
|
3231
|
+
"Try again after your * resets",
|
|
3232
|
+
"Send a message after your * resets",
|
|
3233
|
+
"Wait for your * to reset",
|
|
3234
|
+
"Your * resets * Try again then",
|
|
3235
|
+
"Your * resets * Send a message then",
|
|
3236
|
+
// ChatGPT, signed out
|
|
3237
|
+
"You have reached the anonymous message limit"
|
|
3238
|
+
];
|
|
3239
|
+
function isLimitText(s) {
|
|
3240
|
+
const self = isLimitText;
|
|
3241
|
+
if (!self.patterns) {
|
|
3242
|
+
self.patterns = (self.notices ?? LIMIT_NOTICES).map((n) => {
|
|
3243
|
+
const whole2 = n.endsWith("$");
|
|
3244
|
+
const body = (whole2 ? n.slice(0, -1) : n).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".{1,60}?");
|
|
3245
|
+
return new RegExp(`^${body}${whole2 ? "$" : ""}`, "i");
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
const t = s.replace(/[’‘]/g, "'").replace(/\s+/g, " ").trim();
|
|
3249
|
+
if (/\b(?:upload|image|file|length|storage|project|artifact|translation|read aloud)\b/i.test(t)) return false;
|
|
3250
|
+
return t.length > 0 && t.length <= 300 && self.patterns.some((p) => p.test(t));
|
|
3251
|
+
}
|
|
3252
|
+
var WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
3253
|
+
function limitResetsAt(s, now) {
|
|
3254
|
+
const inHours = /\bin (\d+) hours?\b/i.exec(s);
|
|
3255
|
+
if (inHours) return new Date(now.getTime() + Number(inHours[1]) * 36e5);
|
|
3256
|
+
const inMinutes = /\bin (\d+) min(?:ute)?s?\b/i.exec(s);
|
|
3257
|
+
if (inMinutes) return new Date(now.getTime() + Number(inMinutes[1]) * 6e4);
|
|
3258
|
+
const m = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i.exec(s) ?? /\b(\d{1,2}):(\d{2})\b()/.exec(s);
|
|
3259
|
+
if (!m) return null;
|
|
3260
|
+
let h = Number(m[1]);
|
|
3261
|
+
if (m[3]) h = h % 12 + (m[3].toLowerCase() === "pm" ? 12 : 0);
|
|
3262
|
+
const at = new Date(now);
|
|
3263
|
+
at.setHours(h, m[2] ? Number(m[2]) : 0, 0, 0);
|
|
3264
|
+
const day = /\b(sun|mon|tue|wed|thu|fri|sat)[a-z]*\b/i.exec(s);
|
|
3265
|
+
if (day) {
|
|
3266
|
+
at.setDate(at.getDate() + (WEEKDAYS.indexOf(day[1].toLowerCase()) - at.getDay() + 7) % 7);
|
|
3267
|
+
if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 7);
|
|
3268
|
+
} else if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 1);
|
|
3269
|
+
return at;
|
|
3270
|
+
}
|
|
3271
|
+
function webChatTitle(c) {
|
|
3272
|
+
const t = c.title.replace(/\s*[-|–]\s*(?:Claude|ChatGPT(?: Mobile)?)\s*$/i, "").trim();
|
|
3273
|
+
if (t && !/^(?:Claude|ChatGPT|New chat|New session)$/i.test(t)) return t;
|
|
3274
|
+
const first = c.turns.find((x) => x.who === "me");
|
|
3275
|
+
return first ? oneLine(first.text, 60) : "Web chat";
|
|
3276
|
+
}
|
|
3277
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
3278
|
+
function webChatData(c, o) {
|
|
3279
|
+
const r = new Redactor();
|
|
3280
|
+
const a = (s) => wellFormed(r.apply(stripControl(s))).trim();
|
|
3281
|
+
const turns = c.turns.map((t) => ({ who: t.who, text: a(t.text) })).filter((t) => t.text);
|
|
3282
|
+
const mine = turns.filter((t) => t.who === "me").map((t) => t.text);
|
|
3283
|
+
const picked = pickUserMessages(mine);
|
|
3284
|
+
const lastReply = [...turns].reverse().find((t) => t.who === "agent") ?? null;
|
|
3285
|
+
const title = a(webChatTitle(c));
|
|
3286
|
+
const speaker = speakerLabel(c.chat);
|
|
3287
|
+
const n = o.now;
|
|
3288
|
+
return {
|
|
3289
|
+
baseId: `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(title)}`,
|
|
3290
|
+
title,
|
|
3291
|
+
createdAt: n,
|
|
3292
|
+
from: {
|
|
3293
|
+
tool: c.chat,
|
|
3294
|
+
model: null,
|
|
3295
|
+
session: c.url,
|
|
3296
|
+
reason: c.limit ? "usage_limit" : "manual",
|
|
3297
|
+
resetsAt: c.limit ? limitResetsAt(c.limit, n) : null,
|
|
3298
|
+
message: c.limit
|
|
3299
|
+
},
|
|
3300
|
+
workdir: o.git?.root ?? o.workdir,
|
|
3301
|
+
git: o.git,
|
|
3302
|
+
originalGoal: mine[0] ?? null,
|
|
3303
|
+
userMessages: picked.messages,
|
|
3304
|
+
omittedMessages: picked.omitted,
|
|
3305
|
+
whereItStopped: lastReply && turns.at(-1)?.who === "agent" ? clipWords(lastReply.text, 150) : null,
|
|
3306
|
+
lastRequest: picked.messages.at(-1) ?? null,
|
|
3307
|
+
todos: [],
|
|
3308
|
+
results: [],
|
|
3309
|
+
references: { read: [], attached: [], urls: [c.url] },
|
|
3310
|
+
filesTouched: [],
|
|
3311
|
+
lastFailure: null,
|
|
3312
|
+
transcript: [`# Conversation: ${title}`, "", `Source: ${targetLabel(c.chat)}, ${c.url}`, "", ...turns.map((t) => `**${t.who === "me" ? "User" : speaker}:**
|
|
3313
|
+
${t.text}
|
|
3314
|
+
`)].join("\n"),
|
|
3315
|
+
unknownRecords: 0,
|
|
3316
|
+
redactions: { ...r.counts },
|
|
3317
|
+
carriedFrom: null
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
function webChatTimeline(c) {
|
|
3321
|
+
return c.turns.map((t) => t.who === "me" ? { kind: "user", text: t.text } : { kind: "assistant", text: t.text });
|
|
3322
|
+
}
|
|
3323
|
+
async function runWebChatHandoff(o) {
|
|
3324
|
+
const now = o.now ?? /* @__PURE__ */ new Date();
|
|
3325
|
+
if (isWebChat(o.target)) {
|
|
3326
|
+
if (o.target === o.capture.chat) throw new HarlayError(`This chat is already in ${targetLabel(o.target)}.`);
|
|
3327
|
+
const d2 = webChatData(o.capture, { now, workdir: "", git: null });
|
|
3328
|
+
let written2 = null;
|
|
3329
|
+
let prompt;
|
|
3330
|
+
if (o.attach) {
|
|
3331
|
+
prompt = webAttachPrompt(o.capture.chat, d2.from.reason);
|
|
3332
|
+
written2 = await writeHandoff(d2, { target: o.target, mode: "web", previousId: null, store: o.store, prompt, dir: (0, import_node_path19.join)(o.store, "web") });
|
|
3333
|
+
} else prompt = webMessage(d2, { timeline: webChatTimeline(o.capture) }, "chat").text;
|
|
3334
|
+
if (o.launch === false) return { written: written2, prompt, plan: null, data: d2 };
|
|
3335
|
+
const plan = await launchHandoff({ target: o.target, mode: o.mode, cwd: d2.workdir, id: d2.baseId, prompt, runner: o.runner });
|
|
3336
|
+
return { written: written2, prompt, plan, data: d2 };
|
|
3337
|
+
}
|
|
3338
|
+
if (!o.workdir) throw new HarlayError(`Pick a folder to open ${targetLabel(o.target)} in.`);
|
|
3339
|
+
const git2 = await takeGitSnapshot(o.workdir);
|
|
3340
|
+
const d = webChatData(o.capture, { now, workdir: o.workdir, git: git2 });
|
|
3341
|
+
const written = await writeHandoff(d, { target: o.target, mode: o.mode, previousId: null, store: o.store });
|
|
3342
|
+
if (o.launch === false) return { written, prompt: written.prompt, plan: null, data: d };
|
|
3343
|
+
try {
|
|
3344
|
+
const plan = await launchHandoff({
|
|
3345
|
+
target: o.target,
|
|
3346
|
+
mode: o.mode,
|
|
3347
|
+
cwd: d.workdir,
|
|
3348
|
+
id: written.id,
|
|
3349
|
+
prompt: written.prompt,
|
|
3350
|
+
runner: o.runner,
|
|
3351
|
+
resolve: o.resolve,
|
|
3352
|
+
terminalApp: o.terminalApp,
|
|
3353
|
+
scriptDir: o.scriptDir
|
|
3354
|
+
});
|
|
3355
|
+
return { written, prompt: written.prompt, plan, data: d };
|
|
3356
|
+
} catch (e) {
|
|
3357
|
+
const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
|
|
3358
|
+
throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
|
|
2963
3362
|
// src/menu.ts
|
|
2964
|
-
var
|
|
3363
|
+
var import_node_path21 = require("node:path");
|
|
2965
3364
|
|
|
2966
3365
|
// src/settings.ts
|
|
2967
|
-
var
|
|
2968
|
-
var
|
|
3366
|
+
var import_promises17 = require("node:fs/promises");
|
|
3367
|
+
var import_node_path20 = require("node:path");
|
|
2969
3368
|
var THEMES = ["system", "light", "dark"];
|
|
2970
3369
|
var isTheme = (x) => THEMES.includes(x);
|
|
2971
3370
|
var MAX_PINNED = 12;
|
|
@@ -2983,12 +3382,14 @@ var DEFAULT_SETTINGS = {
|
|
|
2983
3382
|
openAtLogin: true,
|
|
2984
3383
|
theme: "system",
|
|
2985
3384
|
claudeBridgeAsked: false,
|
|
2986
|
-
pinned: []
|
|
3385
|
+
pinned: [],
|
|
3386
|
+
webChats: false,
|
|
3387
|
+
agentFolder: null
|
|
2987
3388
|
};
|
|
2988
3389
|
async function loadSettings(file) {
|
|
2989
3390
|
let raw = {};
|
|
2990
3391
|
try {
|
|
2991
|
-
raw = JSON.parse(await (0,
|
|
3392
|
+
raw = JSON.parse(await (0, import_promises17.readFile)(file, "utf8"));
|
|
2992
3393
|
} catch {
|
|
2993
3394
|
raw = {};
|
|
2994
3395
|
}
|
|
@@ -3000,6 +3401,8 @@ async function loadSettings(file) {
|
|
|
3000
3401
|
if (typeof r.openAtLogin === "boolean") s.openAtLogin = r.openAtLogin;
|
|
3001
3402
|
if (isTheme(r.theme)) s.theme = r.theme;
|
|
3002
3403
|
if (typeof r.claudeBridgeAsked === "boolean") s.claudeBridgeAsked = r.claudeBridgeAsked;
|
|
3404
|
+
if (typeof r.webChats === "boolean") s.webChats = r.webChats;
|
|
3405
|
+
if (typeof r.agentFolder === "string" && r.agentFolder.startsWith("/")) s.agentFolder = r.agentFolder;
|
|
3003
3406
|
if (typeof r.terminalApp === "string" && r.terminalApp.trim()) s.terminalApp = r.terminalApp.trim();
|
|
3004
3407
|
if (typeof r.usageThreshold === "number" && r.usageThreshold > 0 && r.usageThreshold <= 100) s.usageThreshold = r.usageThreshold;
|
|
3005
3408
|
if (typeof r.modes === "object" && r.modes !== null) {
|
|
@@ -3011,8 +3414,8 @@ async function loadSettings(file) {
|
|
|
3011
3414
|
return s;
|
|
3012
3415
|
}
|
|
3013
3416
|
async function saveSettings(file, s) {
|
|
3014
|
-
await (0,
|
|
3015
|
-
await (0,
|
|
3417
|
+
await (0, import_promises17.mkdir)((0, import_node_path20.dirname)(file), { recursive: true });
|
|
3418
|
+
await (0, import_promises17.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
|
|
3016
3419
|
}
|
|
3017
3420
|
|
|
3018
3421
|
// src/state.ts
|
|
@@ -3074,8 +3477,8 @@ function markHandedOff(state2, key, target, now, delivery) {
|
|
|
3074
3477
|
if (!t) return null;
|
|
3075
3478
|
t.status = "handed_off";
|
|
3076
3479
|
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 ${
|
|
3480
|
+
t.delivery = delivery && !isWebChat(target) ? { ...delivery, target, sentAt: now, state: "waiting" } : void 0;
|
|
3481
|
+
t.detail = t.delivery ? deliveryDetail(t.delivery) : `Handed off to ${targetLabel(target)}${isWebChat(target) ? " \xB7 press Send there" : ""}`;
|
|
3079
3482
|
return t;
|
|
3080
3483
|
}
|
|
3081
3484
|
function pendingDeliveries(state2, now) {
|
|
@@ -3113,6 +3516,30 @@ function latestSession(state2) {
|
|
|
3113
3516
|
}
|
|
3114
3517
|
return best;
|
|
3115
3518
|
}
|
|
3519
|
+
var MAX_WEB_CHATS = 4;
|
|
3520
|
+
var webKey = (url) => `web:${url}`;
|
|
3521
|
+
var isWebKey = (key) => key.startsWith("web:");
|
|
3522
|
+
function rememberWebChat(state2, chats, capture, now) {
|
|
3523
|
+
const key = webKey(capture.url);
|
|
3524
|
+
const resets = capture.limit ? limitResetsAt(capture.limit, now) : null;
|
|
3525
|
+
const entry = {
|
|
3526
|
+
key,
|
|
3527
|
+
capture,
|
|
3528
|
+
status: capture.limit ? "limit" : "idle",
|
|
3529
|
+
detail: capture.limit ? `Limit hit${resets ? ` \xB7 resets ${clock(resets)}` : ""}` : "Idle",
|
|
3530
|
+
updatedAt: now
|
|
3531
|
+
};
|
|
3532
|
+
if (capture.limit && capture.chat === "claude-web") state2.limitedUntil["claude-code"] = resets ?? new Date(now.getTime() + DEFAULT_LIMIT_MS);
|
|
3533
|
+
chats.delete(key);
|
|
3534
|
+
chats.set(key, entry);
|
|
3535
|
+
while (chats.size > MAX_WEB_CHATS) chats.delete(chats.keys().next().value);
|
|
3536
|
+
return entry;
|
|
3537
|
+
}
|
|
3538
|
+
function markWebHandedOff(e, target, now) {
|
|
3539
|
+
e.status = "handed_off";
|
|
3540
|
+
e.updatedAt = now;
|
|
3541
|
+
e.detail = `Handed off to ${targetLabel(target)}${isWebChat(target) ? " \xB7 press Send there" : ""}`;
|
|
3542
|
+
}
|
|
3116
3543
|
|
|
3117
3544
|
// src/menu.ts
|
|
3118
3545
|
var HOTKEY = { accelerator: "Alt+H", label: "\u2325H" };
|
|
@@ -3120,7 +3547,7 @@ var DOT = { limit: "\u25CF", warning: "\u25D0", idle: "\u25CB", handed_off: "\u2
|
|
|
3120
3547
|
var MODES = ["app", "terminal"];
|
|
3121
3548
|
var THEME_LABEL = { system: "System", light: "Light", dark: "Dark" };
|
|
3122
3549
|
function sessionTitle(t) {
|
|
3123
|
-
return t.ref.title ?? (t.ref.cwd ? (0,
|
|
3550
|
+
return t.ref.title ?? (t.ref.cwd ? (0, import_node_path21.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
|
|
3124
3551
|
}
|
|
3125
3552
|
function buildMenuModel(sessions, settings2, available2) {
|
|
3126
3553
|
const nodes = [
|
|
@@ -3137,7 +3564,8 @@ function buildMenuModel(sessions, settings2, available2) {
|
|
|
3137
3564
|
{ type: "separator" },
|
|
3138
3565
|
...TOOLS.filter((x) => x !== t.ref.tool).map(
|
|
3139
3566
|
(x) => ({ label: `Hand off to ${TOOL_LABEL[x]}`, enabled: available2[x][settings2.modes[x]], action: { type: "handoff", key, target: x } })
|
|
3140
|
-
)
|
|
3567
|
+
),
|
|
3568
|
+
...WEB_CHATS.map((x) => ({ label: `Continue in ${WEB_LABEL[x]}`, action: { type: "handoff", key, target: x } }))
|
|
3141
3569
|
]
|
|
3142
3570
|
});
|
|
3143
3571
|
}
|
|
@@ -3178,33 +3606,502 @@ function buildMenuModel(sessions, settings2, available2) {
|
|
|
3178
3606
|
}
|
|
3179
3607
|
|
|
3180
3608
|
// src/health.ts
|
|
3181
|
-
var
|
|
3609
|
+
var import_promises18 = require("node:fs/promises");
|
|
3182
3610
|
var HealthMonitor = class {
|
|
3183
3611
|
context = new ContextTracker();
|
|
3184
3612
|
cache = /* @__PURE__ */ new Map();
|
|
3613
|
+
/** Whether each chat folder is in a git repo (see chatKind), looked up once. */
|
|
3614
|
+
repos = /* @__PURE__ */ new Map();
|
|
3185
3615
|
get(key) {
|
|
3186
3616
|
return this.cache.get(key)?.health ?? null;
|
|
3187
3617
|
}
|
|
3188
|
-
|
|
3618
|
+
kind(key) {
|
|
3619
|
+
return this.cache.get(key)?.kind ?? null;
|
|
3620
|
+
}
|
|
3621
|
+
/** Reads the given chats (one at a time, to stay light) and returns those whose state (or kind) changed. */
|
|
3189
3622
|
async update(refs) {
|
|
3190
3623
|
const changes = [];
|
|
3191
3624
|
for (const ref of refs) {
|
|
3192
3625
|
const key = sessionKey(ref);
|
|
3193
3626
|
try {
|
|
3194
|
-
const st = await (0,
|
|
3627
|
+
const st = await (0, import_promises18.stat)(ref.file);
|
|
3195
3628
|
const cached = this.cache.get(key);
|
|
3196
3629
|
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) continue;
|
|
3197
|
-
const
|
|
3198
|
-
|
|
3199
|
-
|
|
3630
|
+
const session = await readSessionTail(ref);
|
|
3631
|
+
const health2 = assessHealth(session, await this.context.read(ref));
|
|
3632
|
+
const kind = chatKind(session, ref.cwd ? await this.inRepo(ref.cwd) : false);
|
|
3633
|
+
this.cache.set(key, { size: st.size, mtimeMs: st.mtimeMs, health: health2, kind });
|
|
3634
|
+
if (cached?.health.state !== health2.state || cached?.kind !== kind) changes.push({ ref, before: cached?.health.state ?? null, after: health2 });
|
|
3200
3635
|
} catch {
|
|
3201
3636
|
this.cache.delete(key);
|
|
3202
3637
|
}
|
|
3203
3638
|
}
|
|
3204
3639
|
return changes;
|
|
3205
3640
|
}
|
|
3641
|
+
inRepo(dir) {
|
|
3642
|
+
let known = this.repos.get(dir);
|
|
3643
|
+
if (!known) {
|
|
3644
|
+
known = inGitRepo(dir);
|
|
3645
|
+
this.repos.set(dir, known);
|
|
3646
|
+
}
|
|
3647
|
+
return known;
|
|
3648
|
+
}
|
|
3206
3649
|
};
|
|
3207
3650
|
|
|
3651
|
+
// src/browser.ts
|
|
3652
|
+
var import_node_child_process4 = require("node:child_process");
|
|
3653
|
+
var import_node_events2 = require("node:events");
|
|
3654
|
+
function scanPage(root, ax, watch3, isLimit) {
|
|
3655
|
+
const SPEAKER = /^(?:You said|Claude responded|ChatGPT said)\b:?/i;
|
|
3656
|
+
const ME = /^You said\b/i;
|
|
3657
|
+
const SKIP = ["AXButton", "AXPopUpButton", "AXMenuButton", "AXCheckBox", "AXRadioButton", "AXToolbar", "AXTextArea", "AXTextField", "AXComboBox", "AXImage", "AXMenu", "AXMenuBar"];
|
|
3658
|
+
const SIDE = ["AXLandmarkNavigation", "AXLandmarkComplementary"];
|
|
3659
|
+
const LIST = /^(?:Chat messages|Conversation)$/;
|
|
3660
|
+
const AGO = /^\d+ (?:second|minute|hour|day|week|month|year)s? ago$/;
|
|
3661
|
+
const turns = [];
|
|
3662
|
+
let limit = null;
|
|
3663
|
+
const headingText = (h) => {
|
|
3664
|
+
const own = ax.text(h);
|
|
3665
|
+
if (own) return own;
|
|
3666
|
+
const first = ax.kids(h).find((k) => ax.role(k) === "AXStaticText");
|
|
3667
|
+
return first && ax.text(first) || "";
|
|
3668
|
+
};
|
|
3669
|
+
const isSpeaker = (n) => ax.role(n) === "AXHeading" && SPEAKER.test(headingText(n).trim());
|
|
3670
|
+
const speakerOf = (n) => {
|
|
3671
|
+
const article = ax.subrole(n) === "AXDocumentArticle";
|
|
3672
|
+
const search = (m, depth) => {
|
|
3673
|
+
for (const k of ax.kids(m)) {
|
|
3674
|
+
if (isSpeaker(k)) return k;
|
|
3675
|
+
if (article && depth < 3 && ax.role(k) === "AXGroup") {
|
|
3676
|
+
const found = search(k, depth + 1);
|
|
3677
|
+
if (found) return found;
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
return null;
|
|
3681
|
+
};
|
|
3682
|
+
return search(n, 0);
|
|
3683
|
+
};
|
|
3684
|
+
const message = (container, heading) => {
|
|
3685
|
+
const who = ME.test(headingText(heading).trim()) ? "me" : "agent";
|
|
3686
|
+
const read = (withHeading) => {
|
|
3687
|
+
let parts = [];
|
|
3688
|
+
const status = /* @__PURE__ */ new Set();
|
|
3689
|
+
const announced = (n) => {
|
|
3690
|
+
const t = ax.role(n) === "AXStaticText" ? ax.text(n)?.trim() : null;
|
|
3691
|
+
if (t) status.add(t);
|
|
3692
|
+
for (const k of ax.kids(n)) announced(k);
|
|
3693
|
+
};
|
|
3694
|
+
let ids = 0;
|
|
3695
|
+
const walk2 = (n, parent) => {
|
|
3696
|
+
const role = ax.role(n);
|
|
3697
|
+
const bubble = who === "me" && (role === "AXPopUpButton" || role === "AXButton");
|
|
3698
|
+
if (role && SKIP.includes(role) && !bubble) return;
|
|
3699
|
+
const sub = ax.subrole(n);
|
|
3700
|
+
if (sub === "AXApplicationGroup" && /actions/i.test(ax.desc(n) ?? "")) return;
|
|
3701
|
+
if (role === "AXGroup") {
|
|
3702
|
+
const isFile = (s) => !!s && /^[^\s/]+\.[A-Za-z0-9]{1,8}$/.test(s.trim());
|
|
3703
|
+
const label = sub === "AXApplicationGroup" ? ax.desc(n) : null;
|
|
3704
|
+
const button = ax.kids(n).find((k) => (ax.role(k) === "AXButton" || ax.role(k) === "AXPopUpButton") && isFile(ax.text(k)));
|
|
3705
|
+
const name = isFile(label) ? label : button ? ax.text(button) : null;
|
|
3706
|
+
if (name) {
|
|
3707
|
+
parts.push({ text: `(attached: ${name.trim()})`, parent: ++ids });
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
if (sub === "AXApplicationStatus") return announced(n);
|
|
3712
|
+
if (n === heading) {
|
|
3713
|
+
if (!withHeading) return;
|
|
3714
|
+
const id2 = ++ids;
|
|
3715
|
+
for (const k of ax.kids(n)) {
|
|
3716
|
+
if (ax.role(k) === "AXStaticText" && SPEAKER.test((ax.text(k) ?? "").trim())) continue;
|
|
3717
|
+
walk2(k, id2);
|
|
3718
|
+
}
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
if (role === "AXStaticText" || role === "AXListMarker") {
|
|
3722
|
+
const t = ax.text(n);
|
|
3723
|
+
if (t && !AGO.test(t.trim())) parts.push({ text: t, parent });
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
const id = ++ids;
|
|
3727
|
+
for (const k of ax.kids(n)) walk2(k, id);
|
|
3728
|
+
};
|
|
3729
|
+
walk2(container, 0);
|
|
3730
|
+
parts = parts.filter((p) => !status.has(p.text.trim()));
|
|
3731
|
+
let out = "";
|
|
3732
|
+
for (let i = 0; i < parts.length; i++) {
|
|
3733
|
+
const p = parts[i];
|
|
3734
|
+
if (i > 0) {
|
|
3735
|
+
const prev = parts[i - 1];
|
|
3736
|
+
const spaced = /\s$/.test(prev.text) || /^[\s.,;:!?)\]]/.test(p.text);
|
|
3737
|
+
if (!spaced) out += prev.parent === p.parent ? " " : "\n";
|
|
3738
|
+
}
|
|
3739
|
+
out += p.text;
|
|
3740
|
+
}
|
|
3741
|
+
return out.replace(//g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3742
|
+
};
|
|
3743
|
+
const text = who === "me" ? read(true) : read(false) || read(true);
|
|
3744
|
+
return { who, text };
|
|
3745
|
+
};
|
|
3746
|
+
let last = null;
|
|
3747
|
+
const lastMessage = (list) => {
|
|
3748
|
+
let cur = list;
|
|
3749
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
3750
|
+
const kids = ax.kids(cur);
|
|
3751
|
+
let next = null;
|
|
3752
|
+
for (let i = kids.length - 1; i >= 0; i--) {
|
|
3753
|
+
const h = speakerOf(kids[i]);
|
|
3754
|
+
if (h) {
|
|
3755
|
+
last = { n: kids[i], h };
|
|
3756
|
+
return;
|
|
3757
|
+
}
|
|
3758
|
+
if (!next && ax.kids(kids[i]).length) next = kids[i];
|
|
3759
|
+
}
|
|
3760
|
+
if (!next) return;
|
|
3761
|
+
cur = next;
|
|
3762
|
+
}
|
|
3763
|
+
};
|
|
3764
|
+
const walk = (n, depth) => {
|
|
3765
|
+
if (depth > 60) return;
|
|
3766
|
+
const role = ax.role(n);
|
|
3767
|
+
if (role && SKIP.includes(role)) return;
|
|
3768
|
+
const sub = ax.subrole(n);
|
|
3769
|
+
if (sub && SIDE.includes(sub)) return;
|
|
3770
|
+
const h = speakerOf(n);
|
|
3771
|
+
if (h) {
|
|
3772
|
+
if (watch3) last = { n, h };
|
|
3773
|
+
else turns.push(message(n, h));
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
if (role === "AXStaticText" || sub === "AXApplicationAlert" || sub === "AXApplicationStatus") {
|
|
3777
|
+
const t = (role === "AXStaticText" ? ax.text(n) : ax.desc(n))?.trim();
|
|
3778
|
+
if (t && !limit && isLimit(t)) limit = t;
|
|
3779
|
+
if (role === "AXStaticText") return;
|
|
3780
|
+
}
|
|
3781
|
+
if (watch3 && LIST.test(ax.desc(n) ?? "")) {
|
|
3782
|
+
lastMessage(n);
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
for (const k of ax.kids(n)) walk(k, depth + 1);
|
|
3786
|
+
};
|
|
3787
|
+
walk(root, 0);
|
|
3788
|
+
const tail2 = watch3 ? last ? message(last.n, last.h) : void 0 : turns.at(-1);
|
|
3789
|
+
if (tail2 && tail2.who === "agent" && isLimit(tail2.text)) {
|
|
3790
|
+
if (!limit) limit = tail2.text;
|
|
3791
|
+
if (!watch3) turns.pop();
|
|
3792
|
+
}
|
|
3793
|
+
return { turns: turns.filter((t) => t.text), limit };
|
|
3794
|
+
}
|
|
3795
|
+
var BROWSERS = {
|
|
3796
|
+
"com.google.Chrome": { name: "Google Chrome", chromium: true },
|
|
3797
|
+
"com.google.Chrome.beta": { name: "Google Chrome Beta", chromium: true },
|
|
3798
|
+
"com.google.Chrome.canary": { name: "Google Chrome Canary", chromium: true },
|
|
3799
|
+
"company.thebrowser.Browser": { name: "Arc", chromium: true },
|
|
3800
|
+
"company.thebrowser.dia": { name: "Dia", chromium: true },
|
|
3801
|
+
"com.brave.Browser": { name: "Brave Browser", chromium: true },
|
|
3802
|
+
"com.microsoft.edgemac": { name: "Microsoft Edge", chromium: true },
|
|
3803
|
+
"com.vivaldi.Vivaldi": { name: "Vivaldi", chromium: true },
|
|
3804
|
+
"com.operasoftware.Opera": { name: "Opera", chromium: true },
|
|
3805
|
+
"ai.perplexity.comet": { name: "Comet", chromium: true },
|
|
3806
|
+
"com.openai.atlas": { name: "ChatGPT Atlas", chromium: true },
|
|
3807
|
+
"com.apple.Safari": { name: "Safari", chromium: false },
|
|
3808
|
+
"com.apple.SafariTechnologyPreview": { name: "Safari Technology Preview", chromium: false },
|
|
3809
|
+
"org.mozilla.firefox": { name: "Firefox", chromium: false }
|
|
3810
|
+
};
|
|
3811
|
+
var CHAT_HOSTS = { "claude.ai": "claude-web", "chatgpt.com": "chatgpt", "chat.openai.com": "chatgpt" };
|
|
3812
|
+
function chatHosts(env = process.env) {
|
|
3813
|
+
const out = { ...CHAT_HOSTS };
|
|
3814
|
+
for (const pair of (env.HARLAY_WEBCHAT_TEST_HOSTS ?? "").split(",").filter(Boolean)) {
|
|
3815
|
+
const i = pair.lastIndexOf("=");
|
|
3816
|
+
const chat = pair.slice(i + 1);
|
|
3817
|
+
if (i > 0 && (chat === "claude-web" || chat === "chatgpt")) out[pair.slice(0, i)] = chat;
|
|
3818
|
+
}
|
|
3819
|
+
return out;
|
|
3820
|
+
}
|
|
3821
|
+
var SCRIPT = `
|
|
3822
|
+
ObjC.import('Cocoa');
|
|
3823
|
+
ObjC.import('CoreGraphics');
|
|
3824
|
+
ObjC.bindFunction('AXIsProcessTrusted', ['bool', []]);
|
|
3825
|
+
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['int']]);
|
|
3826
|
+
ObjC.bindFunction('AXUIElementCopyAttributeValue', ['int', ['id', 'id', 'id*']]);
|
|
3827
|
+
ObjC.bindFunction('AXUIElementSetAttributeValue', ['int', ['id', 'id', 'id']]);
|
|
3828
|
+
ObjC.bindFunction('getppid', ['int', []]);
|
|
3829
|
+
const isLimitText = ${isLimitText.toString()};
|
|
3830
|
+
isLimitText.notices = ${JSON.stringify(LIMIT_NOTICES)};
|
|
3831
|
+
const scanPage = ${scanPage.toString()};
|
|
3832
|
+
function get(el, name) { const r = Ref(); return $.AXUIElementCopyAttributeValue(el, name, r) === 0 ? r[0] : null; }
|
|
3833
|
+
function str(v) { if (!v) return null; try { const s = ObjC.unwrap(v); return typeof s === 'string' ? s : null; } catch (e) { return null; } }
|
|
3834
|
+
function node(el) { return { el: el, k: null, r: undefined, s: undefined, t: undefined, d: undefined }; }
|
|
3835
|
+
const ax = {
|
|
3836
|
+
role: (n) => (n.r === undefined ? (n.r = str(get(n.el, 'AXRole'))) : n.r),
|
|
3837
|
+
subrole: (n) => (n.s === undefined ? (n.s = str(get(n.el, 'AXSubrole'))) : n.s),
|
|
3838
|
+
desc: (n) => (n.d === undefined ? (n.d = str(get(n.el, 'AXDescription'))) : n.d),
|
|
3839
|
+
text: (n) => (n.t === undefined ? (n.t = str(get(n.el, 'AXValue')) || str(get(n.el, 'AXTitle')) || ax.desc(n)) : n.t),
|
|
3840
|
+
kids: (n) => {
|
|
3841
|
+
if (n.k) return n.k;
|
|
3842
|
+
const v = get(n.el, 'AXChildren');
|
|
3843
|
+
const out = [];
|
|
3844
|
+
if (v) for (let i = 0; i < v.count; i++) out.push(node(v.objectAtIndex(i)));
|
|
3845
|
+
return (n.k = out);
|
|
3846
|
+
},
|
|
3847
|
+
};
|
|
3848
|
+
function hostOf(url) { const m = /^https?:\\/\\/([^/?#]+)/i.exec(url || ''); return m ? m[1].toLowerCase() : ''; }
|
|
3849
|
+
function webArea(win, hosts) {
|
|
3850
|
+
let first = null;
|
|
3851
|
+
const find = (n, depth) => {
|
|
3852
|
+
if (depth > 14) return null;
|
|
3853
|
+
if (ax.role(n) === 'AXWebArea') {
|
|
3854
|
+
const u = get(n.el, 'AXURL');
|
|
3855
|
+
const url = u ? ObjC.unwrap(u.absoluteString) : '';
|
|
3856
|
+
if (hosts[hostOf(url)]) return { n: n, url: url };
|
|
3857
|
+
if (!first) first = { n: n, url: url };
|
|
3858
|
+
return null;
|
|
3859
|
+
}
|
|
3860
|
+
for (const k of ax.kids(n)) { const f = find(k, depth + 1); if (f) return f; }
|
|
3861
|
+
return null;
|
|
3862
|
+
};
|
|
3863
|
+
return find(win, 0) || first;
|
|
3864
|
+
}
|
|
3865
|
+
/** The web chat in the front browser window: its page, URL and title; or why there's none. */
|
|
3866
|
+
function front(cfg) {
|
|
3867
|
+
const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
|
|
3868
|
+
const id = app ? ObjC.unwrap(app.bundleIdentifier) : null;
|
|
3869
|
+
const browser = id && cfg.browsers[id];
|
|
3870
|
+
if (!browser) return { ok: false, reason: 'not-browser' };
|
|
3871
|
+
const el = $.AXUIElementCreateApplication(app.processIdentifier);
|
|
3872
|
+
if (browser.chromium) $.AXUIElementSetAttributeValue(el, 'AXManualAccessibility', $.NSNumber.numberWithBool(true));
|
|
3873
|
+
const win = get(el, 'AXFocusedWindow');
|
|
3874
|
+
if (!win) return { ok: false, reason: 'no-window' };
|
|
3875
|
+
const area = webArea(node(win), cfg.hosts);
|
|
3876
|
+
const chat = area && cfg.hosts[hostOf(area.url)];
|
|
3877
|
+
if (!chat) return { ok: false, reason: 'not-chat' };
|
|
3878
|
+
const title = str(get(area.n.el, 'AXTitle')) || ax.desc(area.n) || '';
|
|
3879
|
+
return { ok: true, browser: browser.name, chat: chat, url: area.url, title: title, page: area.n };
|
|
3880
|
+
}
|
|
3881
|
+
function wait(seconds) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(seconds)); }
|
|
3882
|
+
/** The page the browser's focused element is in, and whether that element takes text. */
|
|
3883
|
+
function focusedField(pid) {
|
|
3884
|
+
let n = get($.AXUIElementCreateApplication(pid), 'AXFocusedUIElement');
|
|
3885
|
+
const role = n ? str(get(n, 'AXRole')) : null;
|
|
3886
|
+
for (let i = 0; n && i < 40; i++) {
|
|
3887
|
+
if (str(get(n, 'AXRole')) === 'AXWebArea') {
|
|
3888
|
+
const u = get(n, 'AXURL');
|
|
3889
|
+
return { text: role === 'AXTextArea' || role === 'AXTextField', url: u ? ObjC.unwrap(u.absoluteString) : '' };
|
|
3890
|
+
}
|
|
3891
|
+
n = get(n, 'AXParent');
|
|
3892
|
+
}
|
|
3893
|
+
return null;
|
|
3894
|
+
}
|
|
3895
|
+
/**
|
|
3896
|
+
* Attaches a file to the new chat Harlay just opened: once the browser is in front with the cursor in that chat's
|
|
3897
|
+
* message box (its site, on a new chat's page), it puts the file on the clipboard, presses \u2318V there, and puts the
|
|
3898
|
+
* clipboard back. Otherwise the file is left on the clipboard for the user to paste.
|
|
3899
|
+
*/
|
|
3900
|
+
function paste(cfg) {
|
|
3901
|
+
if (!$.NSFileManager.defaultManager.fileExistsAtPath(cfg.path)) return { ok: false, reason: 'no-file' };
|
|
3902
|
+
const pb = $.NSPasteboard.generalPasteboard;
|
|
3903
|
+
const end = Date.now() + cfg.timeout * 1000;
|
|
3904
|
+
let pid = null;
|
|
3905
|
+
while (Date.now() < end) {
|
|
3906
|
+
const app = $.NSWorkspace.sharedWorkspace.frontmostApplication;
|
|
3907
|
+
const id = app ? ObjC.unwrap(app.bundleIdentifier) : null;
|
|
3908
|
+
if (id && cfg.browsers[id]) {
|
|
3909
|
+
const f = focusedField(app.processIdentifier);
|
|
3910
|
+
const m = f && /^https?:\\/\\/([^/?#]+)(\\/[^?#]*)?/i.exec(f.url);
|
|
3911
|
+
if (f && f.text && m && cfg.hosts[m[1].toLowerCase()] === cfg.chat && cfg.paths.indexOf(m[2] || '/') !== -1) {
|
|
3912
|
+
pid = app.processIdentifier;
|
|
3913
|
+
break;
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
wait(0.25);
|
|
3917
|
+
}
|
|
3918
|
+
const saved = [];
|
|
3919
|
+
if (pid) {
|
|
3920
|
+
const items = pb.pasteboardItems;
|
|
3921
|
+
for (let i = 0; i < items.count; i++) {
|
|
3922
|
+
const item = items.objectAtIndex(i);
|
|
3923
|
+
const kept = [];
|
|
3924
|
+
for (let j = 0; j < item.types.count; j++) {
|
|
3925
|
+
const t = item.types.objectAtIndex(j);
|
|
3926
|
+
const d = item.dataForType(t);
|
|
3927
|
+
if (d) kept.push([t, d]);
|
|
3928
|
+
}
|
|
3929
|
+
saved.push(kept);
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
pb.clearContents;
|
|
3933
|
+
pb.writeObjects($([$.NSURL.fileURLWithPath(cfg.path)]));
|
|
3934
|
+
if (!pid) return { ok: false, reason: 'no-message-box' };
|
|
3935
|
+
// The page fills its message box as it loads: give it a moment.
|
|
3936
|
+
wait(0.6);
|
|
3937
|
+
for (const down of [true, false]) {
|
|
3938
|
+
const e = $.CGEventCreateKeyboardEvent($(), 9, down);
|
|
3939
|
+
$.CGEventSetFlags(e, $.kCGEventFlagMaskCommand);
|
|
3940
|
+
$.CGEventPostToPid(pid, e);
|
|
3941
|
+
}
|
|
3942
|
+
wait(1.5);
|
|
3943
|
+
pb.clearContents;
|
|
3944
|
+
const back = saved.map((kept) => {
|
|
3945
|
+
const item = $.NSPasteboardItem.alloc.init;
|
|
3946
|
+
for (const [t, d] of kept) item.setDataForType(d, t);
|
|
3947
|
+
return item;
|
|
3948
|
+
});
|
|
3949
|
+
if (back.length) pb.writeObjects($(back));
|
|
3950
|
+
return { ok: true };
|
|
3951
|
+
}
|
|
3952
|
+
function write(s) {
|
|
3953
|
+
$.NSFileHandle.fileHandleWithStandardOutput.writeData($(s + '\\n').dataUsingEncoding($.NSUTF8StringEncoding));
|
|
3954
|
+
}
|
|
3955
|
+
function run(argv) {
|
|
3956
|
+
const cfg = JSON.parse(argv[0]);
|
|
3957
|
+
if (!$.AXIsProcessTrusted()) return JSON.stringify({ ok: false, reason: 'untrusted' });
|
|
3958
|
+
if (cfg.mode === 'paste') return JSON.stringify(paste(cfg));
|
|
3959
|
+
if (cfg.mode === 'read') {
|
|
3960
|
+
const f = front(cfg);
|
|
3961
|
+
if (!f.ok) return JSON.stringify(f);
|
|
3962
|
+
const scan = scanPage(f.page, ax, false, isLimitText);
|
|
3963
|
+
return JSON.stringify({ ok: true, browser: f.browser, chat: f.chat, url: f.url, title: f.title, turns: scan.turns, limit: scan.limit });
|
|
3964
|
+
}
|
|
3965
|
+
const parent = $.getppid();
|
|
3966
|
+
let last = '';
|
|
3967
|
+
let said = '';
|
|
3968
|
+
for (;;) {
|
|
3969
|
+
if ($.getppid() !== parent) return '';
|
|
3970
|
+
if (!$.AXIsProcessTrusted()) { write(JSON.stringify({ event: 'untrusted' })); return ''; }
|
|
3971
|
+
let key = '';
|
|
3972
|
+
try {
|
|
3973
|
+
const f = front(cfg);
|
|
3974
|
+
// HARLAY_DEBUG: what it sees, each time that changes.
|
|
3975
|
+
const now = f.ok ? f.url : f.reason;
|
|
3976
|
+
if (cfg.debug && now !== said) write(JSON.stringify({ event: 'status', front: now }));
|
|
3977
|
+
said = now;
|
|
3978
|
+
if (f.ok) {
|
|
3979
|
+
const scan = scanPage(f.page, ax, true, isLimitText);
|
|
3980
|
+
if (scan.limit) {
|
|
3981
|
+
key = f.url + '\\n' + scan.limit;
|
|
3982
|
+
if (key !== last) write(JSON.stringify({ event: 'limit', chat: f.chat, url: f.url, title: f.title, limit: scan.limit }));
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
} catch (e) {
|
|
3986
|
+
// a window that closed mid-read: try again next time
|
|
3987
|
+
if (cfg.debug) write(JSON.stringify({ event: 'status', error: String(e) }));
|
|
3988
|
+
}
|
|
3989
|
+
last = key;
|
|
3990
|
+
// Waits by running the event loop, not delay(): that's how macOS tells this process which app is in front now.
|
|
3991
|
+
$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(cfg.interval));
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
`;
|
|
3995
|
+
function config(mode, hosts, interval = 2.5) {
|
|
3996
|
+
return JSON.stringify({ mode, hosts, browsers: BROWSERS, interval, debug: !!process.env.HARLAY_DEBUG });
|
|
3997
|
+
}
|
|
3998
|
+
function readFrontChat(hosts = chatHosts(), timeoutMs = 8e3) {
|
|
3999
|
+
return new Promise((resolve) => {
|
|
4000
|
+
(0, import_node_child_process4.execFile)("osascript", ["-l", "JavaScript", "-e", SCRIPT, config("read", hosts)], { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }, (err, out) => {
|
|
4001
|
+
if (err) return resolve({ ok: false, reason: "failed" });
|
|
4002
|
+
try {
|
|
4003
|
+
const r = JSON.parse(out);
|
|
4004
|
+
if (r.ok !== true) return resolve({ ok: false, reason: r.reason ?? "failed" });
|
|
4005
|
+
const turns = Array.isArray(r.turns) ? r.turns.filter((t) => (t.who === "me" || t.who === "agent") && typeof t.text === "string") : [];
|
|
4006
|
+
resolve({
|
|
4007
|
+
ok: true,
|
|
4008
|
+
browser: String(r.browser),
|
|
4009
|
+
capture: { chat: r.chat, url: String(r.url), title: String(r.title ?? ""), turns, limit: typeof r.limit === "string" ? r.limit : null }
|
|
4010
|
+
});
|
|
4011
|
+
} catch {
|
|
4012
|
+
resolve({ ok: false, reason: "failed" });
|
|
4013
|
+
}
|
|
4014
|
+
});
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
var NEW_CHAT_PATHS = { "claude-web": ["/new"], chatgpt: ["/"] };
|
|
4018
|
+
function pasteFileInto(chat, file, hosts = chatHosts(), timeoutSec = 15) {
|
|
4019
|
+
const paths = Object.entries(hosts).some(([h, c]) => c === chat && !(h in CHAT_HOSTS)) ? [...NEW_CHAT_PATHS[chat], "/"] : NEW_CHAT_PATHS[chat];
|
|
4020
|
+
const cfg = JSON.stringify({ mode: "paste", chat, path: file, hosts, browsers: BROWSERS, paths, timeout: timeoutSec });
|
|
4021
|
+
return new Promise((resolve) => {
|
|
4022
|
+
(0, import_node_child_process4.execFile)("osascript", ["-l", "JavaScript", "-e", SCRIPT, cfg], { timeout: (timeoutSec + 10) * 1e3 }, (err, out) => {
|
|
4023
|
+
if (err) return resolve({ ok: false, reason: "failed" });
|
|
4024
|
+
try {
|
|
4025
|
+
resolve(JSON.parse(out));
|
|
4026
|
+
} catch {
|
|
4027
|
+
resolve({ ok: false, reason: "failed" });
|
|
4028
|
+
}
|
|
4029
|
+
});
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
var BrowserWatcher = class extends import_node_events2.EventEmitter {
|
|
4033
|
+
constructor(hosts = chatHosts()) {
|
|
4034
|
+
super();
|
|
4035
|
+
this.hosts = hosts;
|
|
4036
|
+
}
|
|
4037
|
+
hosts;
|
|
4038
|
+
child = null;
|
|
4039
|
+
wanted = false;
|
|
4040
|
+
timer = null;
|
|
4041
|
+
start() {
|
|
4042
|
+
this.wanted = true;
|
|
4043
|
+
if (this.child) return;
|
|
4044
|
+
const child = (0, import_node_child_process4.spawn)("osascript", ["-l", "JavaScript", "-e", SCRIPT, config("watch", this.hosts)], { stdio: ["ignore", "pipe", "ignore"] });
|
|
4045
|
+
this.child = child;
|
|
4046
|
+
let buf = "";
|
|
4047
|
+
let untrusted = false;
|
|
4048
|
+
child.stdout?.setEncoding("utf8");
|
|
4049
|
+
child.stdout?.on("data", (chunk) => {
|
|
4050
|
+
buf += chunk;
|
|
4051
|
+
for (let nl = buf.indexOf("\n"); nl !== -1; nl = buf.indexOf("\n")) {
|
|
4052
|
+
const line = buf.slice(0, nl);
|
|
4053
|
+
buf = buf.slice(nl + 1);
|
|
4054
|
+
try {
|
|
4055
|
+
const ev = JSON.parse(line);
|
|
4056
|
+
if (ev.event === "untrusted") untrusted = true;
|
|
4057
|
+
else if (ev.event === "status") this.emit("status", ev);
|
|
4058
|
+
else if (ev.event === "limit") this.emit("limit", { chat: ev.chat, url: ev.url, title: ev.title, limit: ev.limit });
|
|
4059
|
+
} catch {
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
});
|
|
4063
|
+
child.on("exit", () => {
|
|
4064
|
+
this.child = null;
|
|
4065
|
+
if (untrusted) this.emit("untrusted");
|
|
4066
|
+
if (this.wanted && !untrusted) this.timer = setTimeout(() => this.start(), 5e3);
|
|
4067
|
+
else this.wanted = false;
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
get running() {
|
|
4071
|
+
return this.child !== null;
|
|
4072
|
+
}
|
|
4073
|
+
stop() {
|
|
4074
|
+
this.wanted = false;
|
|
4075
|
+
if (this.timer) clearTimeout(this.timer);
|
|
4076
|
+
this.timer = null;
|
|
4077
|
+
this.child?.kill();
|
|
4078
|
+
this.child = null;
|
|
4079
|
+
}
|
|
4080
|
+
};
|
|
4081
|
+
|
|
4082
|
+
// src/targets.ts
|
|
4083
|
+
function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
|
|
4084
|
+
const order = [defaultTarget, ...TOOLS.filter((t) => t !== defaultTarget)];
|
|
4085
|
+
return order.filter((t) => t !== source && available2[t][modes[t]] && !((limitedUntil[t]?.getTime() ?? 0) > now.getTime()));
|
|
4086
|
+
}
|
|
4087
|
+
var SHARES_USAGE = { "claude-web": "claude-code" };
|
|
4088
|
+
function webTargets(source, hitLimit, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
|
|
4089
|
+
const out = [];
|
|
4090
|
+
for (const w of ["claude-web", "chatgpt"]) {
|
|
4091
|
+
if (w === source) continue;
|
|
4092
|
+
const shared = SHARES_USAGE[w];
|
|
4093
|
+
if (shared && (hitLimit && shared === source || (limitedUntil[shared]?.getTime() ?? 0) > now.getTime())) continue;
|
|
4094
|
+
out.push(w);
|
|
4095
|
+
}
|
|
4096
|
+
return out;
|
|
4097
|
+
}
|
|
4098
|
+
function orderTargets(agents, web, kind) {
|
|
4099
|
+
return kind === "chat" ? [...web, ...agents] : [...agents, ...web];
|
|
4100
|
+
}
|
|
4101
|
+
function isPrimary(t, kind) {
|
|
4102
|
+
return isWebChat(t) === (kind === "chat");
|
|
4103
|
+
}
|
|
4104
|
+
|
|
3208
4105
|
// src/popover.ts
|
|
3209
4106
|
var POPOVER_WIDTH = 392;
|
|
3210
4107
|
var PER_AGENT = 3;
|
|
@@ -3263,11 +4160,14 @@ function shownByAgent(sessions, settings2) {
|
|
|
3263
4160
|
}
|
|
3264
4161
|
return out;
|
|
3265
4162
|
}
|
|
3266
|
-
function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false, icons = {}, health2 = () => null) {
|
|
4163
|
+
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
4164
|
const pinned = new Set(settings2.pinned);
|
|
3268
4165
|
const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
|
|
3269
4166
|
const toSession = (t) => {
|
|
3270
4167
|
const key = sessionKey(t.ref);
|
|
4168
|
+
const k = kind(key);
|
|
4169
|
+
const agents = order.filter((x) => x !== t.ref.tool && available2[x][settings2.modes[x]]);
|
|
4170
|
+
const web2 = webTargets(t.ref.tool, t.status === "limit", limitedUntil, now);
|
|
3271
4171
|
return {
|
|
3272
4172
|
key,
|
|
3273
4173
|
title: sessionTitle(t),
|
|
@@ -3277,7 +4177,8 @@ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 =
|
|
|
3277
4177
|
ago: timeAgo(t.updatedAt, now),
|
|
3278
4178
|
busy: busy.has(key),
|
|
3279
4179
|
pinned: pinned.has(key),
|
|
3280
|
-
targets:
|
|
4180
|
+
targets: orderTargets(agents, web2, k).map((x) => ({ tool: x, label: targetLabel(x), mode: isWebChat(x) ? "web" : settings2.modes[x], primary: isPrimary(x, k) })),
|
|
4181
|
+
kind: k,
|
|
3281
4182
|
health: health2(key),
|
|
3282
4183
|
canStartFresh: available2[t.ref.tool][settings2.modes[t.ref.tool]],
|
|
3283
4184
|
delivery: t.status === "handed_off" ? t.delivery?.state ?? null : null
|
|
@@ -3294,20 +4195,53 @@ function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 =
|
|
|
3294
4195
|
usage: usage2,
|
|
3295
4196
|
icons,
|
|
3296
4197
|
askClaudeBridge,
|
|
3297
|
-
settings: {
|
|
4198
|
+
settings: {
|
|
4199
|
+
autoContinue: settings2.autoContinue,
|
|
4200
|
+
openAtLogin: settings2.openAtLogin,
|
|
4201
|
+
theme: settings2.theme,
|
|
4202
|
+
claudeBridge: claudeBridge2,
|
|
4203
|
+
webChats: settings2.webChats,
|
|
4204
|
+
webChatsAllowed: web.allowed
|
|
4205
|
+
},
|
|
4206
|
+
webChats: web.chats,
|
|
4207
|
+
agentFolder: web.folder,
|
|
3298
4208
|
hotkey: HOTKEY.label
|
|
3299
4209
|
};
|
|
3300
4210
|
}
|
|
4211
|
+
function buildWebSessions(entries, settings2, available2, busy, now, limitedUntil = {}) {
|
|
4212
|
+
const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
|
|
4213
|
+
const agents = order.filter((x) => available2[x][settings2.modes[x]] && !((limitedUntil[x]?.getTime() ?? 0) > now.getTime()));
|
|
4214
|
+
return [...entries].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).map((e) => {
|
|
4215
|
+
const web = webTargets(e.capture.chat, e.status === "limit", limitedUntil, now);
|
|
4216
|
+
return {
|
|
4217
|
+
key: e.key,
|
|
4218
|
+
title: webChatTitle(e.capture),
|
|
4219
|
+
status: e.status,
|
|
4220
|
+
detail: e.detail,
|
|
4221
|
+
cwd: null,
|
|
4222
|
+
ago: timeAgo(e.updatedAt, now),
|
|
4223
|
+
busy: busy.has(e.key),
|
|
4224
|
+
pinned: false,
|
|
4225
|
+
targets: orderTargets(agents, web, "chat").map((x) => ({ tool: x, label: targetLabel(x), mode: isWebChat(x) ? "web" : settings2.modes[x], primary: isPrimary(x, "chat") })),
|
|
4226
|
+
kind: "chat",
|
|
4227
|
+
health: null,
|
|
4228
|
+
canStartFresh: false,
|
|
4229
|
+
delivery: null,
|
|
4230
|
+
web: e.capture.chat
|
|
4231
|
+
};
|
|
4232
|
+
});
|
|
4233
|
+
}
|
|
3301
4234
|
function isMenuAction(x) {
|
|
3302
4235
|
if (typeof x !== "object" || x === null) return false;
|
|
3303
4236
|
const a = x;
|
|
3304
4237
|
switch (a.type) {
|
|
3305
4238
|
case "handoff":
|
|
3306
|
-
return typeof a.key === "string" &&
|
|
4239
|
+
return typeof a.key === "string" && isTarget(a.target);
|
|
3307
4240
|
case "unpinChat":
|
|
3308
4241
|
case "freshStart":
|
|
3309
4242
|
case "resendHandoff":
|
|
3310
4243
|
case "copyPrompt":
|
|
4244
|
+
case "pickFolder":
|
|
3311
4245
|
return typeof a.key === "string";
|
|
3312
4246
|
case "setTheme":
|
|
3313
4247
|
return isTheme(a.theme);
|
|
@@ -3320,6 +4254,7 @@ function isMenuAction(x) {
|
|
|
3320
4254
|
case "toggleAuto":
|
|
3321
4255
|
case "toggleLogin":
|
|
3322
4256
|
case "toggleClaudeBridge":
|
|
4257
|
+
case "toggleWebChats":
|
|
3323
4258
|
case "openCursorUsage":
|
|
3324
4259
|
case "quit":
|
|
3325
4260
|
return true;
|
|
@@ -3336,7 +4271,7 @@ function panelPosition(icon, iconArea, userArea, width) {
|
|
|
3336
4271
|
|
|
3337
4272
|
// src/overlay.ts
|
|
3338
4273
|
var import_electron = require("electron");
|
|
3339
|
-
var
|
|
4274
|
+
var import_node_path22 = require("node:path");
|
|
3340
4275
|
var MAX_SHOW_MS = 16e3;
|
|
3341
4276
|
var Overlay = class {
|
|
3342
4277
|
win = null;
|
|
@@ -3392,21 +4327,21 @@ var Overlay = class {
|
|
|
3392
4327
|
type: "panel",
|
|
3393
4328
|
enableLargerThanScreen: true,
|
|
3394
4329
|
alwaysOnTop: true,
|
|
3395
|
-
webPreferences: { preload: (0,
|
|
4330
|
+
webPreferences: { preload: (0, import_node_path22.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
|
|
3396
4331
|
});
|
|
3397
4332
|
win.setAlwaysOnTop(true, "screen-saver");
|
|
3398
4333
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
3399
4334
|
win.setIgnoreMouseEvents(true);
|
|
3400
4335
|
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
3401
4336
|
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
3402
|
-
void win.loadFile((0,
|
|
4337
|
+
void win.loadFile((0, import_node_path22.join)(__dirname, "..", "renderer", "overlay.html"));
|
|
3403
4338
|
this.win = win;
|
|
3404
4339
|
return win;
|
|
3405
4340
|
}
|
|
3406
4341
|
};
|
|
3407
4342
|
|
|
3408
4343
|
// src/show.ts
|
|
3409
|
-
var
|
|
4344
|
+
var import_node_child_process5 = require("node:child_process");
|
|
3410
4345
|
var tokensIn = (chars) => Math.max(1, Math.round(chars / 4));
|
|
3411
4346
|
var plural = (n, one) => `${n.toLocaleString("en-US")} ${one}${n === 1 ? "" : "s"}`;
|
|
3412
4347
|
function startView(o) {
|
|
@@ -3415,14 +4350,14 @@ function startView(o) {
|
|
|
3415
4350
|
const why = stopped || o.status === "warning" ? o.detail.replace(/^Limit hit/, "usage limit").replace(/ · /g, ", ") : fresh ? "new chat, same task" : "moved by you";
|
|
3416
4351
|
return {
|
|
3417
4352
|
key: o.key,
|
|
3418
|
-
from: { tool: o.from, label:
|
|
3419
|
-
to: { tool: o.to, label: fresh ? "New chat" :
|
|
4353
|
+
from: { tool: o.from, label: targetLabel(o.from) },
|
|
4354
|
+
to: { tool: o.to, label: fresh ? "New chat" : targetLabel(o.to), mode: o.mode },
|
|
3420
4355
|
title: o.title,
|
|
3421
4356
|
tag: stopped ? "stopped" : "moving",
|
|
3422
4357
|
steps: [
|
|
3423
4358
|
{ name: "Detect", state: "run", detail: why.charAt(0).toLowerCase() + why.slice(1) },
|
|
3424
4359
|
{ name: "Extract", state: "wait", detail: "reading the chat and git" },
|
|
3425
|
-
{ name: "Distill", state: "wait", detail: "into one .harlay file" },
|
|
4360
|
+
{ name: "Distill", state: "wait", detail: isWebChat(o.to) ? "into one message" : "into one .harlay file" },
|
|
3426
4361
|
{ name: "Rehydrate", state: "wait", detail: rehydrateDetail(o.to, o.mode, fresh, false) }
|
|
3427
4362
|
],
|
|
3428
4363
|
count: null,
|
|
@@ -3431,6 +4366,7 @@ function startView(o) {
|
|
|
3431
4366
|
};
|
|
3432
4367
|
}
|
|
3433
4368
|
function rehydrateDetail(to, mode, fresh, done) {
|
|
4369
|
+
if (isWebChat(to)) return done ? `${targetLabel(to)} \xB7 press \u23CE to send` : `opening ${targetLabel(to)} in your browser`;
|
|
3434
4370
|
const where = `${fresh ? `new ${TOOL_LABEL[to]} chat` : TOOL_LABEL[to]}${mode === "terminal" ? " in Terminal" : ""}`;
|
|
3435
4371
|
if (!done) return `opening ${where}`;
|
|
3436
4372
|
return mode === "app" ? `${where} \xB7 press \u23CE to send` : `${where} \xB7 picking it up`;
|
|
@@ -3475,7 +4411,7 @@ JSON.stringify(l.filter(function (w) { return w.kCGWindowLayer === 0 && w.kCGWin
|
|
|
3475
4411
|
}));`;
|
|
3476
4412
|
function listWindows() {
|
|
3477
4413
|
return new Promise((resolve) => {
|
|
3478
|
-
(0,
|
|
4414
|
+
(0, import_node_child_process5.execFile)("osascript", ["-l", "JavaScript", "-e", LIST_WINDOWS], { timeout: 2e3 }, (err, out) => {
|
|
3479
4415
|
if (err) return resolve([]);
|
|
3480
4416
|
try {
|
|
3481
4417
|
const list = JSON.parse(out);
|
|
@@ -3486,7 +4422,9 @@ function listWindows() {
|
|
|
3486
4422
|
});
|
|
3487
4423
|
});
|
|
3488
4424
|
}
|
|
4425
|
+
var BROWSERS2 = ["Google Chrome", "Safari", "Arc", "Firefox", "Brave Browser", "Microsoft Edge", "Dia", "Comet", "Opera", "Vivaldi", "Orion", "Zen", "ChatGPT Atlas"];
|
|
3489
4426
|
function windowOwners(target, mode, terminalApp) {
|
|
4427
|
+
if (isWebChat(target)) return BROWSERS2;
|
|
3490
4428
|
return mode === "terminal" ? [terminalApp] : APP_BUNDLES[target].map((b) => b.replace(/\.app$/, ""));
|
|
3491
4429
|
}
|
|
3492
4430
|
function ownedBy(app2, owners) {
|
|
@@ -3497,6 +4435,10 @@ function ownedBy(app2, owners) {
|
|
|
3497
4435
|
});
|
|
3498
4436
|
}
|
|
3499
4437
|
var isMain = (w) => w.width >= 320 && w.height >= 240;
|
|
4438
|
+
async function frontWindow() {
|
|
4439
|
+
const w = (await listWindows()).find(isMain);
|
|
4440
|
+
return w ? rect(w) : null;
|
|
4441
|
+
}
|
|
3500
4442
|
async function windowIds() {
|
|
3501
4443
|
return new Set((await listWindows()).map((w) => w.id));
|
|
3502
4444
|
}
|
|
@@ -3507,24 +4449,33 @@ async function findTargetWindow(owners, o) {
|
|
|
3507
4449
|
const all = (await listWindows()).filter(isMain);
|
|
3508
4450
|
const mine = all.filter((w) => ownedBy(w.app, owners));
|
|
3509
4451
|
const found = before ? mine.find((w) => !before.has(w.id)) : all[0] && ownedBy(all[0].app, owners) ? all[0] : void 0;
|
|
3510
|
-
|
|
3511
|
-
if (
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
}
|
|
4452
|
+
const r = found ? await settled(found) : null;
|
|
4453
|
+
if (r) return r;
|
|
4454
|
+
if (Date.now() >= end) return mine[0] ? settled(mine[0]) : null;
|
|
4455
|
+
await sleep(found ? 50 : 250);
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
async function settled(w) {
|
|
4459
|
+
let last = rect(w);
|
|
4460
|
+
const end = Date.now() + SETTLE_MS;
|
|
4461
|
+
while (Date.now() < end) {
|
|
4462
|
+
await sleep(150);
|
|
4463
|
+
const now = (await listWindows()).find((x) => x.id === w.id);
|
|
4464
|
+
if (!now) return null;
|
|
4465
|
+
const r = rect(now);
|
|
4466
|
+
if (r.x === last.x && r.y === last.y && r.width === last.width && r.height === last.height) return r;
|
|
4467
|
+
last = r;
|
|
4468
|
+
}
|
|
4469
|
+
return last;
|
|
4470
|
+
}
|
|
4471
|
+
var SETTLE_MS = 2e3;
|
|
3515
4472
|
var rect = (w) => ({ x: w.x, y: w.y, width: w.width, height: w.height });
|
|
3516
4473
|
|
|
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
4474
|
// src/usage.ts
|
|
3524
|
-
var
|
|
4475
|
+
var import_node_events3 = require("node:events");
|
|
3525
4476
|
var import_node_fs4 = require("node:fs");
|
|
3526
|
-
var
|
|
3527
|
-
var
|
|
4477
|
+
var import_promises19 = require("node:fs/promises");
|
|
4478
|
+
var import_node_path23 = require("node:path");
|
|
3528
4479
|
var CODEX_STALE_MS = 30 * 6e4;
|
|
3529
4480
|
var CODEX_QUERY_GAP_MS = 30 * 6e4;
|
|
3530
4481
|
var DEBOUNCE_MS = 2e3;
|
|
@@ -3535,7 +4486,7 @@ function clock2(d, now) {
|
|
|
3535
4486
|
return d.getTime() - now.getTime() < 20 * 36e5 ? time : `${d.toLocaleDateString([], { weekday: "short" })} ${time}`;
|
|
3536
4487
|
}
|
|
3537
4488
|
var NAME = { "claude-code": "Claude", codex: "Codex", cursor: "Cursor" };
|
|
3538
|
-
var UsageMonitor = class extends
|
|
4489
|
+
var UsageMonitor = class extends import_node_events3.EventEmitter {
|
|
3539
4490
|
constructor(opts) {
|
|
3540
4491
|
super();
|
|
3541
4492
|
this.opts = opts;
|
|
@@ -3555,15 +4506,15 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
3555
4506
|
return claudeStatusFile(this.opts.dataDir);
|
|
3556
4507
|
}
|
|
3557
4508
|
get samplesFile() {
|
|
3558
|
-
return (0,
|
|
4509
|
+
return (0, import_node_path23.join)(this.opts.dataDir, "usage-calibration.json");
|
|
3559
4510
|
}
|
|
3560
4511
|
current() {
|
|
3561
4512
|
return this.snapshots;
|
|
3562
4513
|
}
|
|
3563
4514
|
async start() {
|
|
3564
|
-
await (0,
|
|
4515
|
+
await (0, import_promises19.mkdir)(this.opts.dataDir, { recursive: true });
|
|
3565
4516
|
try {
|
|
3566
|
-
const raw = JSON.parse(await (0,
|
|
4517
|
+
const raw = JSON.parse(await (0, import_promises19.readFile)(this.samplesFile, "utf8"));
|
|
3567
4518
|
if (Array.isArray(raw)) this.samples = raw.filter((s) => typeof s?.capacity === "number" && typeof s?.at === "number" && typeof s?.kind === "string");
|
|
3568
4519
|
} catch {
|
|
3569
4520
|
this.samples = [];
|
|
@@ -3578,8 +4529,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
3578
4529
|
} catch {
|
|
3579
4530
|
}
|
|
3580
4531
|
};
|
|
3581
|
-
watchDir((0,
|
|
3582
|
-
watchDir((0,
|
|
4532
|
+
watchDir((0, import_node_path23.join)(this.opts.home, ".codex", "sessions"), "codex", true);
|
|
4533
|
+
watchDir((0, import_node_path23.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
|
|
3583
4534
|
watchDir(this.opts.dataDir, "claude-code", false);
|
|
3584
4535
|
await Promise.all([this.update("codex"), this.update("claude-code")]);
|
|
3585
4536
|
void this.topUpCodex();
|
|
@@ -3636,8 +4587,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
3636
4587
|
await this.claudeLog.refresh(now);
|
|
3637
4588
|
let exact = null;
|
|
3638
4589
|
try {
|
|
3639
|
-
const s = await (0,
|
|
3640
|
-
exact = parseClaudeStatusline(JSON.parse(await (0,
|
|
4590
|
+
const s = await (0, import_promises19.stat)(this.statusFile);
|
|
4591
|
+
exact = parseClaudeStatusline(JSON.parse(await (0, import_promises19.readFile)(this.statusFile, "utf8")), s.mtimeMs);
|
|
3641
4592
|
} catch {
|
|
3642
4593
|
exact = null;
|
|
3643
4594
|
}
|
|
@@ -3646,7 +4597,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
3646
4597
|
const merged = mergeSamples(this.samples, capacitySamples(entries, hits, exact), now);
|
|
3647
4598
|
if (JSON.stringify(merged) !== JSON.stringify(this.samples)) {
|
|
3648
4599
|
this.samples = merged;
|
|
3649
|
-
await (0,
|
|
4600
|
+
await (0, import_promises19.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
|
|
3650
4601
|
}
|
|
3651
4602
|
return claudeSnapshot({ entries, hits, exact, samples: this.samples, now });
|
|
3652
4603
|
}
|
|
@@ -3681,7 +4632,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
3681
4632
|
var MAX_LIVE_NOTIFICATIONS = 50;
|
|
3682
4633
|
var AUTO_HANDOFF_COOLDOWN_MS = 2 * 6e4;
|
|
3683
4634
|
var home = process.env.HARLAY_HOME ?? (0, import_node_os3.homedir)();
|
|
3684
|
-
if (process.env.HARLAY_HOME) import_electron2.app.setPath("userData", (0,
|
|
4635
|
+
if (process.env.HARLAY_HOME) import_electron2.app.setPath("userData", (0, import_node_path24.join)(home, "Library", "Application Support", "Harlay"));
|
|
3685
4636
|
var readers = createReaders({ home });
|
|
3686
4637
|
var state = createState();
|
|
3687
4638
|
var liveNotifications = /* @__PURE__ */ new Set();
|
|
@@ -3698,6 +4649,9 @@ var claudeBridge = "off";
|
|
|
3698
4649
|
var lastAutoHandoffAt = 0;
|
|
3699
4650
|
var agentIcons = {};
|
|
3700
4651
|
var health = new HealthMonitor();
|
|
4652
|
+
var webChats = /* @__PURE__ */ new Map();
|
|
4653
|
+
var browserWatcher = new BrowserWatcher();
|
|
4654
|
+
var browserAllowed = false;
|
|
3701
4655
|
var overlay = new Overlay();
|
|
3702
4656
|
var showing = null;
|
|
3703
4657
|
var errorMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
@@ -3721,7 +4675,21 @@ function refreshUi() {
|
|
|
3721
4675
|
const rows = buildUsageRows(usage?.current() ?? {}, available, claudeBridge, now);
|
|
3722
4676
|
const claudeInstalled = available["claude-code"].app || available["claude-code"].terminal;
|
|
3723
4677
|
const ask = !settings.claudeBridgeAsked && claudeBridge === "off" && claudeInstalled;
|
|
3724
|
-
const model = buildPopoverModel(
|
|
4678
|
+
const model = buildPopoverModel(
|
|
4679
|
+
[...state.sessions.values()],
|
|
4680
|
+
settings,
|
|
4681
|
+
available,
|
|
4682
|
+
handoffsInFlight,
|
|
4683
|
+
now,
|
|
4684
|
+
rows,
|
|
4685
|
+
claudeBridge,
|
|
4686
|
+
ask,
|
|
4687
|
+
agentIcons,
|
|
4688
|
+
(k) => health.get(k),
|
|
4689
|
+
(k) => health.kind(k),
|
|
4690
|
+
state.limitedUntil,
|
|
4691
|
+
{ chats: buildWebSessions([...webChats.values()], settings, available, handoffsInFlight, now, state.limitedUntil), folder: agentFolder(), allowed: browserAllowed }
|
|
4692
|
+
);
|
|
3725
4693
|
popover.webContents.send("harlay:model", model);
|
|
3726
4694
|
}
|
|
3727
4695
|
function nativeMenu() {
|
|
@@ -3750,13 +4718,16 @@ async function handoff(ref, target) {
|
|
|
3750
4718
|
handoffsInFlight.add(key);
|
|
3751
4719
|
refreshUi();
|
|
3752
4720
|
const since = /* @__PURE__ */ new Date();
|
|
3753
|
-
const mode = settings.modes[target];
|
|
3754
|
-
const
|
|
4721
|
+
const mode = isWebChat(target) ? "web" : settings.modes[target];
|
|
4722
|
+
const t = state.sessions.get(key);
|
|
4723
|
+
const show = presentHandoff({ key, tool: ref.tool, title: t ? sessionTitle(t) : "This chat", status: t?.status ?? "idle", detail: t?.detail ?? "" }, target, mode);
|
|
3755
4724
|
try {
|
|
3756
4725
|
const res = await runHandoff({
|
|
3757
4726
|
session: ref,
|
|
3758
4727
|
target,
|
|
3759
|
-
|
|
4728
|
+
// A web chat opens in the browser whatever this says.
|
|
4729
|
+
mode: mode === "web" ? "app" : mode,
|
|
4730
|
+
attach: canAttach(),
|
|
3760
4731
|
readers,
|
|
3761
4732
|
store: handoffStore(home),
|
|
3762
4733
|
terminalApp: settings.terminalApp,
|
|
@@ -3764,9 +4735,10 @@ async function handoff(ref, target) {
|
|
|
3764
4735
|
onProgress: show.progress
|
|
3765
4736
|
});
|
|
3766
4737
|
const { id, path, prompt } = res.written;
|
|
3767
|
-
markHandedOff(state, key, target, /* @__PURE__ */ new Date(), { id, path, prompt, mode, cwd: res.data.workdir, since });
|
|
4738
|
+
markHandedOff(state, key, target, /* @__PURE__ */ new Date(), mode === "web" ? void 0 : { id, path, prompt, mode, cwd: res.data.workdir, since });
|
|
3768
4739
|
closeResolvedAlert();
|
|
3769
4740
|
void show.launched();
|
|
4741
|
+
if (isWebChat(target) && canAttach()) void attach(target, path);
|
|
3770
4742
|
} catch (e) {
|
|
3771
4743
|
show.failed(errorMessage(e));
|
|
3772
4744
|
notify("Harlay could not hand off", errorMessage(e));
|
|
@@ -3775,10 +4747,9 @@ async function handoff(ref, target) {
|
|
|
3775
4747
|
}
|
|
3776
4748
|
refreshUi();
|
|
3777
4749
|
}
|
|
3778
|
-
function presentHandoff(
|
|
3779
|
-
const key =
|
|
3780
|
-
|
|
3781
|
-
let view = startView({ key, from: ref.tool, to: target, mode, title: t ? sessionTitle(t) : "This chat", status: t?.status ?? "idle", detail: t?.detail ?? "" });
|
|
4750
|
+
function presentHandoff(src, target, mode) {
|
|
4751
|
+
const { key } = src;
|
|
4752
|
+
let view = startView({ key, from: src.tool, to: target, mode, title: src.title, status: src.status, detail: src.detail });
|
|
3782
4753
|
const set = (v) => {
|
|
3783
4754
|
view = v;
|
|
3784
4755
|
if (showing === key && popover && !popover.isDestroyed()) popover.webContents.send("harlay:handoff", v);
|
|
@@ -3802,16 +4773,17 @@ function presentHandoff(ref, target, mode) {
|
|
|
3802
4773
|
const to = tokensIn(p.chars);
|
|
3803
4774
|
const from = fromTokens ?? to;
|
|
3804
4775
|
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`;
|
|
4776
|
+
const size = from > to ? void 0 : `${to.toLocaleString("en-US")} tokens in one ${mode === "web" ? "message" : ".harlay file"}`;
|
|
3806
4777
|
void pace.next(from > to ? 950 : 500, () => set(withStep(withStep(view, "Distill", "done", size), "Rehydrate", "run")));
|
|
3807
4778
|
window = findTargetWindow(windowOwners(target, mode, settings.terminalApp), { newSince: before, timeoutMs: 4e3 });
|
|
3808
4779
|
},
|
|
3809
4780
|
launched: async () => {
|
|
3810
|
-
await pace.next(450, () => set({ ...withStep(view, "Rehydrate", "done", rehydrateDetail(target, mode,
|
|
4781
|
+
await pace.next(450, () => set({ ...withStep(view, "Rehydrate", "done", rehydrateDetail(target, mode, src.tool === target, true)), tag: "done" }));
|
|
3811
4782
|
await pace.next(800, () => set({ ...view, leaving: true }));
|
|
3812
4783
|
await pace.next(200, () => void 0);
|
|
3813
4784
|
if (showing !== key) {
|
|
3814
|
-
|
|
4785
|
+
const typedIn = mode === "app" || mode === "web";
|
|
4786
|
+
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
4787
|
return;
|
|
3816
4788
|
}
|
|
3817
4789
|
showing = null;
|
|
@@ -3823,8 +4795,8 @@ function presentHandoff(ref, target, mode) {
|
|
|
3823
4795
|
key,
|
|
3824
4796
|
from: tb ? { x: tb.x + tb.width / 2, y: tb.y + tb.height / 2 } : { x: 0, y: 0 },
|
|
3825
4797
|
window: found,
|
|
3826
|
-
to: { tool: target, label:
|
|
3827
|
-
fromLabel:
|
|
4798
|
+
to: { tool: target, label: targetLabel(target), icon: agentIcons[target] ?? null, mode },
|
|
4799
|
+
fromLabel: targetLabel(src.tool),
|
|
3828
4800
|
// The agent may have picked it up already (a terminal starts at once).
|
|
3829
4801
|
picked: delivered === "sent" || delivered === "read"
|
|
3830
4802
|
});
|
|
@@ -3883,10 +4855,30 @@ async function onAction(a) {
|
|
|
3883
4855
|
try {
|
|
3884
4856
|
switch (a.type) {
|
|
3885
4857
|
case "handoff": {
|
|
4858
|
+
if (isWebKey(a.key)) {
|
|
4859
|
+
await handoffWeb(a.key, a.target);
|
|
4860
|
+
break;
|
|
4861
|
+
}
|
|
3886
4862
|
const t = state.sessions.get(a.key);
|
|
3887
4863
|
if (t) await handoff(t.ref, a.target);
|
|
3888
4864
|
break;
|
|
3889
4865
|
}
|
|
4866
|
+
case "toggleWebChats":
|
|
4867
|
+
settings.webChats = !settings.webChats;
|
|
4868
|
+
await saveSettings(settingsFile, settings);
|
|
4869
|
+
if (settings.webChats) browserAllowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(true);
|
|
4870
|
+
syncBrowserWatch();
|
|
4871
|
+
refreshUi();
|
|
4872
|
+
break;
|
|
4873
|
+
case "pickFolder": {
|
|
4874
|
+
const r = await import_electron2.dialog.showOpenDialog({ title: "Where agents open web chats", defaultPath: agentFolder() ?? (0, import_node_os3.homedir)(), properties: ["openDirectory", "createDirectory"] });
|
|
4875
|
+
if (!r.canceled && r.filePaths[0]) {
|
|
4876
|
+
settings.agentFolder = r.filePaths[0];
|
|
4877
|
+
await saveSettings(settingsFile, settings);
|
|
4878
|
+
}
|
|
4879
|
+
revealPopover(webChats.has(a.key) ? a.key : null, "user");
|
|
4880
|
+
break;
|
|
4881
|
+
}
|
|
3890
4882
|
case "setTheme":
|
|
3891
4883
|
settings.theme = a.theme;
|
|
3892
4884
|
import_electron2.nativeTheme.themeSource = a.theme;
|
|
@@ -3958,8 +4950,9 @@ function onWatchEvent(ev) {
|
|
|
3958
4950
|
const now = /* @__PURE__ */ new Date();
|
|
3959
4951
|
const tracked = applyWatchEvent(state, ev, now);
|
|
3960
4952
|
refreshUi();
|
|
3961
|
-
const
|
|
3962
|
-
const
|
|
4953
|
+
const agents = pickTargets(ev.ref.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, now);
|
|
4954
|
+
const web = webTargets(ev.ref.tool, ev.event.kind === "halt" && ev.event.halt.reason === "usage_limit", state.limitedUntil, now);
|
|
4955
|
+
const first = orderTargets(agents, web, health.kind(sessionKey(ev.ref)))[0];
|
|
3963
4956
|
if (ev.event.kind === "halt") {
|
|
3964
4957
|
if (settings.autoContinue && first && now.getTime() - lastAutoHandoffAt >= AUTO_HANDOFF_COOLDOWN_MS) {
|
|
3965
4958
|
lastAutoHandoffAt = now.getTime();
|
|
@@ -3983,14 +4976,106 @@ function onUsageAlert(a) {
|
|
|
3983
4976
|
function dataDir() {
|
|
3984
4977
|
return import_electron2.app.getPath("userData");
|
|
3985
4978
|
}
|
|
3986
|
-
function onHotkey() {
|
|
4979
|
+
async function onHotkey() {
|
|
3987
4980
|
if (popover?.isVisible()) {
|
|
3988
4981
|
hidePopover();
|
|
3989
4982
|
return;
|
|
3990
4983
|
}
|
|
4984
|
+
if (settings.webChats && browserAllowed) {
|
|
4985
|
+
const r = await readFrontChat();
|
|
4986
|
+
debug("front chat", r.ok ? { url: r.capture.url, turns: r.capture.turns.length, limit: r.capture.limit } : r.reason);
|
|
4987
|
+
if (r.ok && r.capture.turns.length) {
|
|
4988
|
+
const e = rememberWebChat(state, webChats, r.capture, /* @__PURE__ */ new Date());
|
|
4989
|
+
revealPopover(e.key, "user");
|
|
4990
|
+
return;
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
3991
4993
|
const latest = latestSession(state);
|
|
3992
4994
|
revealPopover(latest ? sessionKey(latest.ref) : null, "user");
|
|
3993
4995
|
}
|
|
4996
|
+
function canAttach() {
|
|
4997
|
+
return import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
|
|
4998
|
+
}
|
|
4999
|
+
async function attach(chat, file) {
|
|
5000
|
+
const dir = (0, import_node_path24.join)((0, import_node_os3.tmpdir)(), "harlay", "attach");
|
|
5001
|
+
await (0, import_promises20.mkdir)(dir, { recursive: true });
|
|
5002
|
+
const named = (0, import_node_path24.join)(dir, (0, import_node_path24.basename)(file).replace(/^\d{4}-\d{4}-/, ""));
|
|
5003
|
+
await (0, import_promises20.copyFile)(file, named);
|
|
5004
|
+
const r = await pasteFileInto(chat, named);
|
|
5005
|
+
debug("attach", chat, file, r);
|
|
5006
|
+
if (!r.ok) notify(`Attach the handoff in ${targetLabel(chat)}`, `It's on your clipboard: press \u2318V in ${targetLabel(chat)}, then send.`);
|
|
5007
|
+
}
|
|
5008
|
+
function agentFolder() {
|
|
5009
|
+
return settings.agentFolder ?? latestSession(state)?.ref.cwd ?? null;
|
|
5010
|
+
}
|
|
5011
|
+
function syncBrowserWatch() {
|
|
5012
|
+
if (settings.webChats && browserAllowed) browserWatcher.start();
|
|
5013
|
+
else browserWatcher.stop();
|
|
5014
|
+
}
|
|
5015
|
+
var webAlerts = /* @__PURE__ */ new Map();
|
|
5016
|
+
var WEB_ALERT_AGAIN_MS = 15 * 6e4;
|
|
5017
|
+
async function onWebLimit(ev) {
|
|
5018
|
+
const seen = `${ev.url}
|
|
5019
|
+
${ev.limit}`;
|
|
5020
|
+
const now = Date.now();
|
|
5021
|
+
if (now - (webAlerts.get(seen) ?? 0) < WEB_ALERT_AGAIN_MS) return;
|
|
5022
|
+
const r = await readFrontChat();
|
|
5023
|
+
if (!r.ok || r.capture.url !== ev.url || !r.capture.turns.length) {
|
|
5024
|
+
debug("web limit not read", r.ok ? { url: r.capture.url, turns: r.capture.turns.length } : r.reason);
|
|
5025
|
+
return;
|
|
5026
|
+
}
|
|
5027
|
+
webAlerts.set(seen, now);
|
|
5028
|
+
const near = await frontWindow();
|
|
5029
|
+
const e = rememberWebChat(state, webChats, { ...r.capture, limit: r.capture.limit ?? ev.limit }, /* @__PURE__ */ new Date());
|
|
5030
|
+
debug("web limit", ev.url, ev.limit);
|
|
5031
|
+
refreshUi();
|
|
5032
|
+
const [first] = buildWebSessions([e], settings, available, handoffsInFlight, /* @__PURE__ */ new Date(), state.limitedUntil)[0].targets;
|
|
5033
|
+
if (settings.autoContinue && first && now - lastAutoHandoffAt >= AUTO_HANDOFF_COOLDOWN_MS) {
|
|
5034
|
+
lastAutoHandoffAt = now;
|
|
5035
|
+
void handoffWeb(e.key, first.tool);
|
|
5036
|
+
return;
|
|
5037
|
+
}
|
|
5038
|
+
alertPopover(e.key, near);
|
|
5039
|
+
}
|
|
5040
|
+
async function handoffWeb(key, target) {
|
|
5041
|
+
const w = webChats.get(key);
|
|
5042
|
+
if (!w || handoffsInFlight.has(key)) return;
|
|
5043
|
+
const folder = isWebChat(target) ? null : agentFolder();
|
|
5044
|
+
if (!isWebChat(target)) {
|
|
5045
|
+
const ok = folder ? await (0, import_promises20.stat)(folder).then((s) => s.isDirectory(), () => false) : false;
|
|
5046
|
+
if (!ok) {
|
|
5047
|
+
notify("Harlay needs a folder", `Pick the folder ${targetLabel(target)} should open this chat in (Change, under the choices).`);
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
handoffsInFlight.add(key);
|
|
5052
|
+
refreshUi();
|
|
5053
|
+
const mode = isWebChat(target) ? "web" : settings.modes[target];
|
|
5054
|
+
const show = presentHandoff({ key, tool: w.capture.chat, title: webChatTitle(w.capture), status: w.status, detail: w.detail }, target, mode);
|
|
5055
|
+
try {
|
|
5056
|
+
show.progress({ stage: "extracted", messages: w.capture.turns.length, filesChanged: 0, chars: w.capture.turns.reduce((n, t) => n + t.text.length, 0) });
|
|
5057
|
+
const res = await runWebChatHandoff({
|
|
5058
|
+
capture: w.capture,
|
|
5059
|
+
target,
|
|
5060
|
+
mode: mode === "web" ? "app" : mode,
|
|
5061
|
+
attach: canAttach(),
|
|
5062
|
+
workdir: folder,
|
|
5063
|
+
store: handoffStore(home),
|
|
5064
|
+
terminalApp: settings.terminalApp,
|
|
5065
|
+
resolve: defaultResolveOptions()
|
|
5066
|
+
});
|
|
5067
|
+
show.progress({ stage: "written", path: res.written?.path ?? "", chars: res.written ? res.written.chars : res.prompt.length });
|
|
5068
|
+
markWebHandedOff(w, target, /* @__PURE__ */ new Date());
|
|
5069
|
+
void show.launched();
|
|
5070
|
+
if (isWebChat(target) && res.written) void attach(target, res.written.path);
|
|
5071
|
+
} catch (e) {
|
|
5072
|
+
show.failed(errorMessage(e));
|
|
5073
|
+
notify("Harlay could not hand off", errorMessage(e));
|
|
5074
|
+
} finally {
|
|
5075
|
+
handoffsInFlight.delete(key);
|
|
5076
|
+
}
|
|
5077
|
+
refreshUi();
|
|
5078
|
+
}
|
|
3994
5079
|
async function trackPinned() {
|
|
3995
5080
|
const refs = await Promise.all(
|
|
3996
5081
|
settings.pinned.filter((k) => !state.sessions.has(k)).map(async (k) => {
|
|
@@ -4001,7 +5086,7 @@ async function trackPinned() {
|
|
|
4001
5086
|
seedSessions(state, refs.filter((r) => r !== null));
|
|
4002
5087
|
}
|
|
4003
5088
|
async function addChat(tool, raw) {
|
|
4004
|
-
const id = (0,
|
|
5089
|
+
const id = (0, import_node_path24.basename)(raw.trim()).replace(/\.jsonl?$/, "");
|
|
4005
5090
|
if (!/^[\w.-]{4,200}$/.test(id)) return { ok: false, error: "That doesn\u2019t look like a chat ID." };
|
|
4006
5091
|
const ref = await findSessionById(readers, tool, id).catch(() => null);
|
|
4007
5092
|
if (!ref) return { ok: false, error: `No ${TOOL_LABEL[tool]} chat with that ID on this Mac.` };
|
|
@@ -4019,9 +5104,9 @@ async function loadAgentIcons() {
|
|
|
4019
5104
|
const dir = defaultResolveOptions().applicationsDir;
|
|
4020
5105
|
for (const tool of TOOLS) {
|
|
4021
5106
|
for (const bundle of APP_BUNDLES[tool]) {
|
|
4022
|
-
const path = (0,
|
|
5107
|
+
const path = (0, import_node_path24.join)(dir, bundle);
|
|
4023
5108
|
try {
|
|
4024
|
-
await (0,
|
|
5109
|
+
await (0, import_promises20.access)(path);
|
|
4025
5110
|
const thumb = await import_electron2.nativeImage.createThumbnailFromPath(path, { width: 64, height: 64 });
|
|
4026
5111
|
agentIcons[tool] = thumb.resize({ width: 36, height: 36, quality: "best" }).toDataURL();
|
|
4027
5112
|
break;
|
|
@@ -4029,7 +5114,17 @@ async function loadAgentIcons() {
|
|
|
4029
5114
|
}
|
|
4030
5115
|
}
|
|
4031
5116
|
}
|
|
5117
|
+
for (const [chat, bundle] of Object.entries(WEB_APP)) {
|
|
5118
|
+
try {
|
|
5119
|
+
const path = (0, import_node_path24.join)(dir, bundle);
|
|
5120
|
+
await (0, import_promises20.access)(path);
|
|
5121
|
+
const thumb = await import_electron2.nativeImage.createThumbnailFromPath(path, { width: 64, height: 64 });
|
|
5122
|
+
agentIcons[chat] = thumb.resize({ width: 36, height: 36, quality: "best" }).toDataURL();
|
|
5123
|
+
} catch {
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
4032
5126
|
}
|
|
5127
|
+
var WEB_APP = { "claude-web": "Claude.app", chatgpt: "ChatGPT.app" };
|
|
4033
5128
|
async function refreshSessions() {
|
|
4034
5129
|
try {
|
|
4035
5130
|
seedSessions(state, (await Promise.all(readers.map((r) => r.listSessions(PER_AGENT)))).flat());
|
|
@@ -4075,7 +5170,7 @@ function createPopover() {
|
|
|
4075
5170
|
acceptFirstMouse: true,
|
|
4076
5171
|
// backgroundThrottling off: the limit panel opens while another app is in front, and a throttled page would leave
|
|
4077
5172
|
// its fade-in (and so the whole panel) invisible.
|
|
4078
|
-
webPreferences: { preload: (0,
|
|
5173
|
+
webPreferences: { preload: (0, import_node_path24.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
|
|
4079
5174
|
});
|
|
4080
5175
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
4081
5176
|
win.on("blur", () => {
|
|
@@ -4083,22 +5178,23 @@ function createPopover() {
|
|
|
4083
5178
|
});
|
|
4084
5179
|
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
4085
5180
|
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
4086
|
-
void win.loadFile((0,
|
|
5181
|
+
void win.loadFile((0, import_node_path24.join)(__dirname, "..", "renderer", "popover.html"));
|
|
4087
5182
|
return win;
|
|
4088
5183
|
}
|
|
4089
5184
|
var debug = process.env.HARLAY_DEBUG ? (...a) => console.log("[harlay]", ...a) : () => void 0;
|
|
4090
5185
|
var alertKey = null;
|
|
4091
|
-
function alertPopover(key) {
|
|
5186
|
+
function alertPopover(key, near = null) {
|
|
4092
5187
|
debug("limit panel for", key, { popover: !!popover, tray: tray?.getBounds(), visible: popover?.isVisible(), focused: popover?.isFocused() });
|
|
4093
5188
|
if (!popover || !tray) return;
|
|
4094
5189
|
if (popover.isVisible() && popover.isFocused()) return;
|
|
4095
5190
|
alertKey = key;
|
|
4096
|
-
const show = () => showPopover(key, false, "user");
|
|
5191
|
+
const show = () => showPopover(key, false, near ?? "user");
|
|
4097
5192
|
if (popover.webContents.isLoading()) popover.webContents.once("did-finish-load", show);
|
|
4098
5193
|
else show();
|
|
4099
5194
|
}
|
|
4100
5195
|
function closeResolvedAlert() {
|
|
4101
5196
|
if (!alertKey || !popover?.isVisible() || popover.isFocused()) return;
|
|
5197
|
+
if (isWebKey(alertKey)) return;
|
|
4102
5198
|
if (state.sessions.get(alertKey)?.status === "limit") return;
|
|
4103
5199
|
alertKey = null;
|
|
4104
5200
|
hidePopover();
|
|
@@ -4107,7 +5203,7 @@ function showPopover(route = null, focus = true, where = "icon") {
|
|
|
4107
5203
|
if (!popover || !tray) return;
|
|
4108
5204
|
const t = tray.getBounds();
|
|
4109
5205
|
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 :
|
|
5206
|
+
const userArea = where === "icon" ? iconArea : where === "user" ? import_electron2.screen.getDisplayNearestPoint(import_electron2.screen.getCursorScreenPoint()).workArea : import_electron2.screen.getDisplayMatching(where).workArea;
|
|
4111
5207
|
const { x, y } = panelPosition(t, iconArea, userArea, POPOVER_WIDTH);
|
|
4112
5208
|
popover.setPosition(Math.round(x), Math.round(y));
|
|
4113
5209
|
refreshUi();
|
|
@@ -4180,13 +5276,13 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
|
|
|
4180
5276
|
import_electron2.app.on("second-instance", () => revealPopover());
|
|
4181
5277
|
import_electron2.app.whenReady().then(async () => {
|
|
4182
5278
|
import_electron2.app.dock?.hide();
|
|
4183
|
-
settingsFile = (0,
|
|
5279
|
+
settingsFile = (0, import_node_path24.join)(import_electron2.app.getPath("userData"), "settings.json");
|
|
4184
5280
|
settings = await loadSettings(settingsFile);
|
|
4185
5281
|
import_electron2.nativeTheme.themeSource = settings.theme;
|
|
4186
5282
|
if (import_electron2.app.isPackaged) import_electron2.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
|
|
4187
5283
|
available = await availableTargets(defaultResolveOptions());
|
|
4188
5284
|
await loadAgentIcons();
|
|
4189
|
-
const iconPath = (0,
|
|
5285
|
+
const iconPath = (0, import_node_path24.join)(__dirname, "..", "assets", "trayTemplate.png");
|
|
4190
5286
|
const icon = import_electron2.nativeImage.createFromPath(iconPath);
|
|
4191
5287
|
icon.setTemplateImage(true);
|
|
4192
5288
|
tray = new import_electron2.Tray(icon);
|
|
@@ -4221,7 +5317,26 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
|
|
|
4221
5317
|
claudeBridge = await bridgeState(home);
|
|
4222
5318
|
if (claudeBridge === "on") await installBridge(home, dataDir()).catch((e) => console.error("[harlay] status line refresh failed", e));
|
|
4223
5319
|
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}`);
|
|
5320
|
+
if (!import_electron2.globalShortcut.register(HOTKEY.accelerator, () => void onHotkey())) console.error(`[harlay] could not register ${HOTKEY.accelerator}`);
|
|
5321
|
+
browserAllowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
|
|
5322
|
+
browserWatcher.on("limit", (ev) => {
|
|
5323
|
+
debug("web limit seen", ev.url, ev.limit);
|
|
5324
|
+
void onWebLimit(ev);
|
|
5325
|
+
});
|
|
5326
|
+
browserWatcher.on("status", (s) => debug("browser", s));
|
|
5327
|
+
browserWatcher.on("untrusted", () => {
|
|
5328
|
+
browserAllowed = false;
|
|
5329
|
+
refreshUi();
|
|
5330
|
+
});
|
|
5331
|
+
syncBrowserWatch();
|
|
5332
|
+
setInterval(() => {
|
|
5333
|
+
if (!settings.webChats) return;
|
|
5334
|
+
const allowed = import_electron2.systemPreferences.isTrustedAccessibilityClient(false);
|
|
5335
|
+
if (allowed === browserAllowed && browserWatcher.running === allowed) return;
|
|
5336
|
+
browserAllowed = allowed;
|
|
5337
|
+
syncBrowserWatch();
|
|
5338
|
+
refreshUi();
|
|
5339
|
+
}, 5e3);
|
|
4225
5340
|
if (!import_electron2.app.getLoginItemSettings().wasOpenedAtLogin) revealPopover();
|
|
4226
5341
|
}).catch((e) => {
|
|
4227
5342
|
console.error("[harlay] startup failed", e);
|
|
@@ -4232,5 +5347,6 @@ if (!import_electron2.app.requestSingleInstanceLock()) {
|
|
|
4232
5347
|
import_electron2.globalShortcut.unregisterAll();
|
|
4233
5348
|
watcher?.stop();
|
|
4234
5349
|
usage?.stop();
|
|
5350
|
+
browserWatcher.stop();
|
|
4235
5351
|
});
|
|
4236
5352
|
}
|