harlay 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/dist/main.cjs +1750 -770
- package/app/dist/preload.cjs +2 -2
- package/app/renderer/popover.css +222 -153
- package/app/renderer/popover.html +10 -8
- package/app/renderer/popover.js +368 -307
- package/app/renderer/vendor/motion-LICENSE.md +21 -0
- package/app/renderer/vendor/motion.js +1 -0
- package/dist/harlay.mjs +1009 -573
- package/package.json +1 -1
package/app/dist/main.cjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/main.ts
|
|
4
4
|
var import_electron = require("electron");
|
|
5
|
-
var
|
|
6
|
-
var
|
|
7
|
-
var
|
|
5
|
+
var import_promises19 = require("node:fs/promises");
|
|
6
|
+
var import_node_os3 = require("node:os");
|
|
7
|
+
var import_node_path19 = require("node:path");
|
|
8
8
|
|
|
9
9
|
// ../../packages/core/src/types.ts
|
|
10
10
|
var TOOLS = ["claude-code", "codex", "cursor"];
|
|
@@ -31,30 +31,93 @@ var LaunchError = class extends HarlayError {
|
|
|
31
31
|
|
|
32
32
|
// ../../packages/core/src/util/text.ts
|
|
33
33
|
var CHARS_PER_TOKEN = 4;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (s.length <= max) return s;
|
|
37
|
-
const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
|
|
38
|
-
return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
|
|
39
|
-
}
|
|
34
|
+
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
35
|
+
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
40
36
|
function tail(s, maxChars) {
|
|
41
|
-
|
|
37
|
+
if (s.length <= maxChars) return s;
|
|
38
|
+
let start = s.length - maxChars + 1;
|
|
39
|
+
if (start < s.length && isLow(s.charCodeAt(start))) start++;
|
|
40
|
+
return "\u2026" + s.slice(start);
|
|
41
|
+
}
|
|
42
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
43
|
+
function wellFormed(s) {
|
|
44
|
+
return s.replace(LONE_SURROGATE, "\uFFFD");
|
|
42
45
|
}
|
|
43
46
|
function oneLine(s, max = 120) {
|
|
44
47
|
const t = s.replace(/\s+/g, " ").trim();
|
|
45
|
-
|
|
48
|
+
if (t.length <= max) return t;
|
|
49
|
+
let end = max - 1;
|
|
50
|
+
if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
|
|
51
|
+
return t.slice(0, end) + "\u2026";
|
|
52
|
+
}
|
|
53
|
+
function wordCount(s) {
|
|
54
|
+
return s.split(/\s+/).filter(Boolean).length;
|
|
55
|
+
}
|
|
56
|
+
function clipWords(s, max) {
|
|
57
|
+
const w = s.split(/\s+/).filter(Boolean);
|
|
58
|
+
return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
|
|
59
|
+
}
|
|
60
|
+
function splitSentences(s) {
|
|
61
|
+
return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
|
|
46
62
|
}
|
|
47
63
|
var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
|
|
48
64
|
var MACHINE_TAG_RE = /^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|bash-input|bash-stdout|bash-stderr|environment_context|user_instructions)\b/;
|
|
65
|
+
var CODEX_REQUEST_RE = /^## My request:[ \t]*\n/m;
|
|
49
66
|
function cleanPrompt(raw) {
|
|
50
67
|
let s = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(CONTEXT_BLOCK_RE, "").trim();
|
|
51
68
|
const query = /<user_query>([\s\S]*?)<\/user_query>/.exec(s);
|
|
52
69
|
if (query) s = query[1].trim();
|
|
70
|
+
const request = CODEX_REQUEST_RE.exec(s);
|
|
71
|
+
if (request) s = s.slice(request.index + request[0].length).trim();
|
|
53
72
|
if (MACHINE_TAG_RE.test(s)) return null;
|
|
54
73
|
return s ? s : null;
|
|
55
74
|
}
|
|
75
|
+
var LEGACY_HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
76
|
+
var HANDOFF_TAG = '<handoff from="';
|
|
77
|
+
var HANDOFF_END = "</handoff>";
|
|
78
|
+
var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
|
|
79
|
+
var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
|
|
80
|
+
var HARLAY_FILE_ID_RE = /"schema":\s*"harlay\/handoff@\d+",\s*(?:\d+\s+)?"id":\s*"([^"\\]+)"/;
|
|
81
|
+
var HARLAY_FILE_END_RE = /"endOfHandoff":\s*"([^"\\]+)"\s*(?:\d+\s+)?\}\s*$/;
|
|
82
|
+
function handoffIdIn(text) {
|
|
83
|
+
return HARLAY_FILE_ID_RE.exec(text)?.[1] ?? HARLAY_FILE_END_RE.exec(text)?.[1] ?? [...text.matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
84
|
+
}
|
|
85
|
+
var HARLAY_PROMPT_RE = /^\s*(?:this is the context from harlay:?|continue in harlay)(?:[ \t]+(?:[@@]"?(?:[^\n"]*\/)?(?:\.harlay\/HANDOFF\.md|[^\s/"]*\.harlay)"?|\[[@@]?[^\]\n]*\.harlay\]\([^)\n]*\))(?=[\s,;:!?)]|\.(?!\w)|$)|[.!]?[ \t]*(?=\r?\n|$)|[.,;:!](?=\s))/i;
|
|
86
|
+
var HARLAY_FILE_RE = /^\s*(?:this is the context from harlay:?|continue in harlay)[ \t]+(?:[@@]"(\/[^\n"]*\.harlay)"|[@@](\/[^\n"]*?\.harlay)(?=[\s,;:!?)]|\.(?!\w)|$)|\[[@@]?[^\]\n]*\.harlay\]\((?:file:\/\/)?(\/[^)\n]*\.harlay)\))/i;
|
|
87
|
+
function harlayFileIn(p) {
|
|
88
|
+
const m = HARLAY_FILE_RE.exec(p);
|
|
89
|
+
if (!m) return null;
|
|
90
|
+
const quotedOrBare = m[1] ?? m[2];
|
|
91
|
+
if (quotedOrBare) return quotedOrBare;
|
|
92
|
+
try {
|
|
93
|
+
return decodeURIComponent(m[3]);
|
|
94
|
+
} catch {
|
|
95
|
+
return m[3];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function splitHandoffPrompt(p) {
|
|
99
|
+
const start = p.indexOf(HANDOFF_TAG);
|
|
100
|
+
const end = p.lastIndexOf(HANDOFF_END);
|
|
101
|
+
if (start !== -1 && end > start) {
|
|
102
|
+
const before = p.slice(0, start).trim();
|
|
103
|
+
const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
|
|
104
|
+
const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
105
|
+
return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
|
|
106
|
+
}
|
|
107
|
+
if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === LEGACY_HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
|
|
108
|
+
const m = HARLAY_PROMPT_RE.exec(p);
|
|
109
|
+
if (m) return { handoff: true, rest: p.slice(m[0].length).replace(/^[.,;:!?)]+/, "").trim(), id: null };
|
|
110
|
+
return { handoff: false, rest: p, id: null };
|
|
111
|
+
}
|
|
56
112
|
function isHandoffPrompt(p) {
|
|
57
|
-
|
|
113
|
+
const { handoff: handoff2, rest } = splitHandoffPrompt(p);
|
|
114
|
+
return handoff2 && rest === "";
|
|
115
|
+
}
|
|
116
|
+
var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
117
|
+
var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
118
|
+
var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
119
|
+
function stripControl(s) {
|
|
120
|
+
return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
|
|
58
121
|
}
|
|
59
122
|
function slugify(s, max = 40) {
|
|
60
123
|
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
|
|
@@ -62,12 +125,14 @@ function slugify(s, max = 40) {
|
|
|
62
125
|
}
|
|
63
126
|
|
|
64
127
|
// ../../packages/core/src/sources/claude.ts
|
|
65
|
-
var
|
|
66
|
-
var
|
|
128
|
+
var import_promises5 = require("node:fs/promises");
|
|
129
|
+
var import_node_path5 = require("node:path");
|
|
67
130
|
|
|
68
131
|
// ../../packages/core/src/util/files.ts
|
|
132
|
+
var import_node_fs = require("node:fs");
|
|
69
133
|
var import_promises = require("node:fs/promises");
|
|
70
134
|
var import_node_path = require("node:path");
|
|
135
|
+
var import_node_readline = require("node:readline");
|
|
71
136
|
async function listSessionFiles(root, accept) {
|
|
72
137
|
let names;
|
|
73
138
|
try {
|
|
@@ -87,27 +152,14 @@ async function listSessionFiles(root, accept) {
|
|
|
87
152
|
}
|
|
88
153
|
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
89
154
|
}
|
|
90
|
-
async function
|
|
91
|
-
const
|
|
155
|
+
async function eachLine(file, each, maxBytes = 8 * 1024 * 1024) {
|
|
156
|
+
const input = (0, import_node_fs.createReadStream)(file, { encoding: "utf8", end: maxBytes - 1 });
|
|
157
|
+
const lines2 = (0, import_node_readline.createInterface)({ input, crlfDelay: Infinity });
|
|
92
158
|
try {
|
|
93
|
-
const
|
|
94
|
-
const buf = Buffer.alloc(65536);
|
|
95
|
-
let total = 0;
|
|
96
|
-
while (total < maxBytes) {
|
|
97
|
-
const { bytesRead } = await fh.read(buf, 0, buf.length, total);
|
|
98
|
-
if (bytesRead === 0) break;
|
|
99
|
-
const slice = buf.subarray(0, bytesRead);
|
|
100
|
-
const nl = slice.indexOf(10);
|
|
101
|
-
if (nl >= 0) {
|
|
102
|
-
chunks.push(Buffer.from(slice.subarray(0, nl)));
|
|
103
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
104
|
-
}
|
|
105
|
-
chunks.push(Buffer.from(slice));
|
|
106
|
-
total += bytesRead;
|
|
107
|
-
}
|
|
108
|
-
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
159
|
+
for await (const line of lines2) if (each(line)) break;
|
|
109
160
|
} finally {
|
|
110
|
-
|
|
161
|
+
lines2.close();
|
|
162
|
+
input.destroy();
|
|
111
163
|
}
|
|
112
164
|
}
|
|
113
165
|
|
|
@@ -140,6 +192,445 @@ function blockText(content) {
|
|
|
140
192
|
return parts.join("\n");
|
|
141
193
|
}
|
|
142
194
|
|
|
195
|
+
// ../../packages/core/src/sources/folder.ts
|
|
196
|
+
var import_promises4 = require("node:fs/promises");
|
|
197
|
+
var import_node_path4 = require("node:path");
|
|
198
|
+
|
|
199
|
+
// ../../packages/core/src/brief/write.ts
|
|
200
|
+
var import_node_crypto = require("node:crypto");
|
|
201
|
+
var import_promises3 = require("node:fs/promises");
|
|
202
|
+
var import_node_path3 = require("node:path");
|
|
203
|
+
|
|
204
|
+
// ../../packages/core/src/launch/launchers.ts
|
|
205
|
+
var import_node_child_process = require("node:child_process");
|
|
206
|
+
var import_promises2 = require("node:fs/promises");
|
|
207
|
+
var import_node_path2 = require("node:path");
|
|
208
|
+
var import_node_util = require("node:util");
|
|
209
|
+
var pexec = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
210
|
+
var HARLAY_PROMPT = "This is the context from Harlay";
|
|
211
|
+
function launchPrompt(file) {
|
|
212
|
+
return `${HARLAY_PROMPT} @${/\s/.test(file) ? `"${file}"` : file}`;
|
|
213
|
+
}
|
|
214
|
+
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
215
|
+
var defaultRunner = {
|
|
216
|
+
async open(args) {
|
|
217
|
+
await pexec("/usr/bin/open", args);
|
|
218
|
+
},
|
|
219
|
+
async writeExecutable(path, content) {
|
|
220
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(path), { recursive: true });
|
|
221
|
+
await (0, import_promises2.writeFile)(path, content, { mode: 493 });
|
|
222
|
+
await (0, import_promises2.chmod)(path, 493);
|
|
223
|
+
},
|
|
224
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
225
|
+
};
|
|
226
|
+
function shq(s) {
|
|
227
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
228
|
+
}
|
|
229
|
+
var enc = encodeURIComponent;
|
|
230
|
+
function planLaunch(o) {
|
|
231
|
+
if (o.mode === "app") {
|
|
232
|
+
switch (o.target) {
|
|
233
|
+
case "claude-code":
|
|
234
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
235
|
+
case "codex":
|
|
236
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
237
|
+
case "cursor":
|
|
238
|
+
return {
|
|
239
|
+
target: o.target,
|
|
240
|
+
mode: o.mode,
|
|
241
|
+
steps: [
|
|
242
|
+
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
243
|
+
{ kind: "wait", ms: 1500 },
|
|
244
|
+
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
245
|
+
]
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const bin = o.binaries[o.target];
|
|
250
|
+
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
251
|
+
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
252
|
+
const path = (0, import_node_path2.join)(o.scriptDir, `continue-${o.target}.command`);
|
|
253
|
+
const content = `#!/bin/zsh -l
|
|
254
|
+
cd -- ${shq(o.cwd)} || exit 1
|
|
255
|
+
exec ${argv.map(shq).join(" ")}
|
|
256
|
+
`;
|
|
257
|
+
return {
|
|
258
|
+
target: o.target,
|
|
259
|
+
mode: o.mode,
|
|
260
|
+
steps: [
|
|
261
|
+
{ kind: "script", path, content },
|
|
262
|
+
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
263
|
+
]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async function executeLaunch(plan, runner = defaultRunner) {
|
|
267
|
+
for (const step of plan.steps) {
|
|
268
|
+
if (step.kind === "open") await runner.open(step.args);
|
|
269
|
+
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
270
|
+
else await runner.sleep(step.ms);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ../../packages/core/src/redact/redact.ts
|
|
275
|
+
var mark = (kind) => `[REDACTED:${kind}]`;
|
|
276
|
+
var whole = (kind) => () => mark(kind);
|
|
277
|
+
var ENV_KEY = String.raw`(?<![A-Za-z0-9_])((?:export\s+)?(?=[A-Z][A-Z0-9_]*\s*[=:])(?:[A-Z][A-Z0-9_]*)?(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*)`;
|
|
278
|
+
var CONFIG_KEY = String.raw`(?<![A-Za-z0-9_.-])((["']?)[A-Za-z0-9_.-]*?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\2[ \t]*[=:][ \t]*)`;
|
|
279
|
+
var NOT_A_VALUE = String.raw`(?:true|false|null|none|undefined|await|new|this|self|string|number|boolean|object|unknown|bool)\b`;
|
|
280
|
+
var RULES = [
|
|
281
|
+
{ kind: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replace: whole("private_key") },
|
|
282
|
+
{ kind: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, replace: whole("anthropic_key") },
|
|
283
|
+
{ kind: "openai_key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, replace: whole("openai_key") },
|
|
284
|
+
{ kind: "github_token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, replace: whole("github_token") },
|
|
285
|
+
{ kind: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: whole("aws_access_key") },
|
|
286
|
+
{ kind: "stripe_key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, replace: whole("stripe_key") },
|
|
287
|
+
{ kind: "slack_token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: whole("slack_token") },
|
|
288
|
+
{ kind: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: whole("google_api_key") },
|
|
289
|
+
{ kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: whole("jwt") },
|
|
290
|
+
{ kind: "bearer", re: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, replace: (_m, g) => `${g[0]}${mark("bearer")}` },
|
|
291
|
+
// Env rules run before config rules so an uppercase env line is counted once, as env_secret.
|
|
292
|
+
{
|
|
293
|
+
kind: "env_secret",
|
|
294
|
+
re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
|
|
295
|
+
replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
kind: "env_secret",
|
|
299
|
+
re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
|
|
300
|
+
replace: (_m, g) => `${g[0]}${mark("env_secret")}`
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
kind: "config_secret",
|
|
304
|
+
re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
|
|
305
|
+
replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
// The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
|
|
309
|
+
// backtrack into a shorter match.
|
|
310
|
+
kind: "config_secret",
|
|
311
|
+
re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
|
|
312
|
+
replace: (_m, g) => `${g[0]}${mark("config_secret")}`
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
kind: "url_credentials",
|
|
316
|
+
re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
|
|
317
|
+
replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
|
|
318
|
+
}
|
|
319
|
+
];
|
|
320
|
+
function shannon(s) {
|
|
321
|
+
const freq = /* @__PURE__ */ new Map();
|
|
322
|
+
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
323
|
+
let h = 0;
|
|
324
|
+
for (const n of freq.values()) {
|
|
325
|
+
const p = n / s.length;
|
|
326
|
+
h -= p * Math.log2(p);
|
|
327
|
+
}
|
|
328
|
+
return h;
|
|
329
|
+
}
|
|
330
|
+
var Redactor = class {
|
|
331
|
+
counts = {};
|
|
332
|
+
bump(kind) {
|
|
333
|
+
this.counts[kind] = (this.counts[kind] ?? 0) + 1;
|
|
334
|
+
}
|
|
335
|
+
apply(text) {
|
|
336
|
+
let out = text;
|
|
337
|
+
for (const rule of RULES) {
|
|
338
|
+
out = out.replace(rule.re, (match, ...rest) => {
|
|
339
|
+
this.bump(rule.kind);
|
|
340
|
+
const groups = rest.filter((x) => typeof x === "string");
|
|
341
|
+
return rule.replace(match, groups);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
|
|
345
|
+
if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
|
|
346
|
+
this.bump("high_entropy");
|
|
347
|
+
return mark("high_entropy");
|
|
348
|
+
});
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ../../packages/core/src/brief/record.ts
|
|
354
|
+
var RECORD_SCHEMA = "harlay/handoff@6";
|
|
355
|
+
var MESSAGE_SCHEMAS = /* @__PURE__ */ new Set([RECORD_SCHEMA, "harlay/handoff@5"]);
|
|
356
|
+
var OLD_SCHEMAS = /* @__PURE__ */ new Set(["harlay/handoff@4", "harlay/handoff@3", "harlay/handoff@2"]);
|
|
357
|
+
var ACTIVITY_MAX = 6e3;
|
|
358
|
+
var DIFF_MAX = 6e3;
|
|
359
|
+
var FAILURE_OUTPUT_MAX = 3e3;
|
|
360
|
+
var FAILURE_OUTPUT_LINES = 40;
|
|
361
|
+
var MAX_UNCOMMITTED = 15;
|
|
362
|
+
var REASON_TEXT = {
|
|
363
|
+
usage_limit: "it hit a usage limit",
|
|
364
|
+
crash: "it stopped with an error",
|
|
365
|
+
manual: "the user moved it here"
|
|
366
|
+
};
|
|
367
|
+
function harlayAbout(from, reason, root) {
|
|
368
|
+
return `Harlay context: the user was working with ${TOOL_LABEL[from]} in ${root}, and that chat stopped (${REASON_TEXT[reason]}). This file is what happened there: everything the user typed, word for word and oldest first (task.userMessages), where it stopped and their latest request (state), a command that still fails (lastFailure), how each command went (results), what the previous agent read, was given and looked up (references), and the recent activity and diff.`;
|
|
369
|
+
}
|
|
370
|
+
function harlayInstructions(root, createdAt, head) {
|
|
371
|
+
return [
|
|
372
|
+
"Reply with a short summary (the task and where it stands, two or three lines), then ask the user what they want to do next.",
|
|
373
|
+
"Don't run commands or change files until they answer.",
|
|
374
|
+
"The rules the user set and what they decided are in task.userMessages, in their own words: follow them.",
|
|
375
|
+
`Work in ${root}, even if this chat was opened somewhere else.`,
|
|
376
|
+
head ? `This file is a snapshot from ${createdAt}: before changing anything, check the repo still matches it (git status against files.uncommitted, git log -1 against repo.head ${head}).` : `This file is a snapshot from ${createdAt}: before changing anything, check the files still match it.`,
|
|
377
|
+
"Open what references lists only if you need it."
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
var LINE_MAX = 500;
|
|
381
|
+
function lines(s) {
|
|
382
|
+
const out = [];
|
|
383
|
+
for (const line of wellFormed(s).split("\n")) {
|
|
384
|
+
if (line.length <= LINE_MAX) {
|
|
385
|
+
out.push(line);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
for (let i = 0; i < line.length; ) {
|
|
389
|
+
let end = Math.min(i + LINE_MAX, line.length);
|
|
390
|
+
if (end < line.length && /[\ud800-\udbff]/.test(line[end - 1])) end--;
|
|
391
|
+
out.push(line.slice(i, end));
|
|
392
|
+
i = end;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
function tailLines(s, max) {
|
|
398
|
+
if (s.length <= max) return lines(s);
|
|
399
|
+
const cut = s.slice(s.length - max);
|
|
400
|
+
const nl = cut.indexOf("\n");
|
|
401
|
+
return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut : cut.slice(nl + 1))];
|
|
402
|
+
}
|
|
403
|
+
function headLines(s, max, more) {
|
|
404
|
+
if (s.length <= max) return lines(s.replace(/\n$/, ""));
|
|
405
|
+
const cut = s.slice(0, max);
|
|
406
|
+
const nl = cut.lastIndexOf("\n");
|
|
407
|
+
return [...lines(nl === -1 ? cut : cut.slice(0, nl)), more];
|
|
408
|
+
}
|
|
409
|
+
function buildRecord(d, id, previousId, delivery, r) {
|
|
410
|
+
const a = (s) => wellFormed(r.apply(stripControl(s)));
|
|
411
|
+
const files = d.git?.files ?? [];
|
|
412
|
+
const failure = d.lastFailure ? a(d.lastFailure.output).split("\n").slice(-FAILURE_OUTPUT_LINES).join("\n") : "";
|
|
413
|
+
return {
|
|
414
|
+
harlay: harlayAbout(d.from.tool, d.from.reason, d.workdir),
|
|
415
|
+
// 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
|
+
schema: RECORD_SCHEMA,
|
|
417
|
+
id,
|
|
418
|
+
createdAt: d.createdAt.toISOString(),
|
|
419
|
+
instructions: harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
|
|
420
|
+
task: {
|
|
421
|
+
title: a(d.title),
|
|
422
|
+
originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
|
|
423
|
+
userMessages: d.userMessages.map(a),
|
|
424
|
+
omittedMessages: d.omittedMessages
|
|
425
|
+
},
|
|
426
|
+
state: {
|
|
427
|
+
whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
|
|
428
|
+
lastRequest: d.lastRequest === null ? null : a(d.lastRequest),
|
|
429
|
+
todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
|
|
430
|
+
},
|
|
431
|
+
lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, output: tailLines(failure, FAILURE_OUTPUT_MAX) } : null,
|
|
432
|
+
results: d.results.map((x) => ({ ...x, cmd: clipWords(a(x.cmd), 30) })),
|
|
433
|
+
references: { read: d.references.read.map(a), attached: d.references.attached.map(a), urls: d.references.urls.map(a) },
|
|
434
|
+
files: {
|
|
435
|
+
touched: d.filesTouched.map(a),
|
|
436
|
+
uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
|
|
437
|
+
moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
|
|
438
|
+
},
|
|
439
|
+
recentActivity: tailLines(a(d.transcript), ACTIVITY_MAX),
|
|
440
|
+
diff: d.git && d.git.diff ? headLines(a(d.git.diff), DIFF_MAX, "\u2026(diff cut; run git diff for the rest)") : null,
|
|
441
|
+
from: {
|
|
442
|
+
tool: d.from.tool,
|
|
443
|
+
model: d.from.model,
|
|
444
|
+
session: d.from.session,
|
|
445
|
+
reason: d.from.reason,
|
|
446
|
+
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
|
|
447
|
+
message: d.from.message === null ? null : a(d.from.message)
|
|
448
|
+
},
|
|
449
|
+
repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
|
|
450
|
+
previous: previousId ? { id: previousId } : null,
|
|
451
|
+
delivery,
|
|
452
|
+
unknownRecords: d.unknownRecords,
|
|
453
|
+
endOfHandoff: id
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
var isStr = (v) => typeof v === "string";
|
|
457
|
+
var isObj2 = (v) => typeof v === "object" && v !== null;
|
|
458
|
+
var isStrs = (v) => Array.isArray(v) && v.every(isStr);
|
|
459
|
+
function messagesOf(schema, task) {
|
|
460
|
+
if (MESSAGE_SCHEMAS.has(schema)) {
|
|
461
|
+
if (!isStrs(task.userMessages) || typeof task.omittedMessages !== "number") return null;
|
|
462
|
+
return { userMessages: task.userMessages, omittedMessages: task.omittedMessages };
|
|
463
|
+
}
|
|
464
|
+
const recent = task.recentUserMessages;
|
|
465
|
+
const rules = task.pinnedRules;
|
|
466
|
+
if (!isStrs(recent)) return null;
|
|
467
|
+
if (!Array.isArray(rules) || !rules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
|
|
468
|
+
const ruleTexts = rules.map((r) => r.text).filter((t) => !recent.some((m) => m.includes(t)));
|
|
469
|
+
return { userMessages: [...ruleTexts, ...recent], omittedMessages: 0 };
|
|
470
|
+
}
|
|
471
|
+
function validateRecord(raw) {
|
|
472
|
+
if (!isObj2(raw) || !(MESSAGE_SCHEMAS.has(raw.schema) || OLD_SCHEMAS.has(raw.schema)) || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
|
|
473
|
+
const { task, from } = raw;
|
|
474
|
+
if (!isObj2(from) || !isTool(from.tool)) return null;
|
|
475
|
+
if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
|
|
476
|
+
const messages = messagesOf(raw.schema, task);
|
|
477
|
+
if (!messages) return null;
|
|
478
|
+
const sent = isObj2(raw.delivery) ? raw.delivery : isObj2(raw.prompt) ? raw.prompt : null;
|
|
479
|
+
return {
|
|
480
|
+
id: raw.id,
|
|
481
|
+
task: { title: task.title, originalGoal: task.originalGoal, ...messages },
|
|
482
|
+
target: sent && isTool(sent.target) ? sent.target : null,
|
|
483
|
+
root: isObj2(raw.repo) && isStr(raw.repo.root) ? raw.repo.root : null
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ../../packages/core/src/brief/write.ts
|
|
488
|
+
function handoffStore(home2) {
|
|
489
|
+
return (0, import_node_path3.join)(home2, "Library", "Application Support", "Harlay", "handoffs");
|
|
490
|
+
}
|
|
491
|
+
function repoFolder(store, root) {
|
|
492
|
+
return (0, import_node_path3.join)(store, `${slugify((0, import_node_path3.basename)(root), 30)}-${(0, import_node_crypto.createHash)("sha256").update(root).digest("hex").slice(0, 8)}`);
|
|
493
|
+
}
|
|
494
|
+
var LEGACY_FILE = "handoff.harlay";
|
|
495
|
+
async function readHandoffState(workdir) {
|
|
496
|
+
let raw;
|
|
497
|
+
try {
|
|
498
|
+
raw = JSON.parse(await (0, import_promises3.readFile)((0, import_node_path3.join)(workdir, ".harlay", "state.json"), "utf8"));
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
503
|
+
const s = raw;
|
|
504
|
+
if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
|
|
505
|
+
if (s.goal !== null && typeof s.goal !== "string") return null;
|
|
506
|
+
return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
|
|
507
|
+
}
|
|
508
|
+
async function readIfExists(file) {
|
|
509
|
+
try {
|
|
510
|
+
return await (0, import_promises3.readFile)(file, "utf8");
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function parseRecord(raw) {
|
|
516
|
+
try {
|
|
517
|
+
return validateRecord(JSON.parse(raw));
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function toPrevious(rec) {
|
|
523
|
+
const { task } = rec;
|
|
524
|
+
return {
|
|
525
|
+
id: rec.id,
|
|
526
|
+
goal: task.originalGoal,
|
|
527
|
+
title: task.title,
|
|
528
|
+
userMessages: task.userMessages,
|
|
529
|
+
omittedMessages: task.omittedMessages,
|
|
530
|
+
target: rec.target
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
async function newestIn(dir) {
|
|
534
|
+
let names;
|
|
535
|
+
try {
|
|
536
|
+
names = await (0, import_promises3.readdir)(dir);
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
let best = null;
|
|
541
|
+
for (const name of names) {
|
|
542
|
+
if (!name.endsWith(".harlay") || name.startsWith(".")) continue;
|
|
543
|
+
const file = (0, import_node_path3.join)(dir, name);
|
|
544
|
+
try {
|
|
545
|
+
const at = (await (0, import_promises3.stat)(file)).mtimeMs;
|
|
546
|
+
if (!best || at > best.at) best = { file, at };
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return best?.file ?? null;
|
|
551
|
+
}
|
|
552
|
+
async function readPreviousHandoff(store, workdir) {
|
|
553
|
+
const newest = await newestIn(repoFolder(store, workdir));
|
|
554
|
+
for (const file of newest ? [newest] : [(0, import_node_path3.join)(workdir, LEGACY_FILE), (0, import_node_path3.join)(workdir, ".harlay", "handoff.json")]) {
|
|
555
|
+
const raw = await readIfExists(file);
|
|
556
|
+
if (raw === null) continue;
|
|
557
|
+
const rec = parseRecord(raw);
|
|
558
|
+
return rec ? toPrevious(rec) : null;
|
|
559
|
+
}
|
|
560
|
+
const state2 = await readHandoffState(workdir);
|
|
561
|
+
return state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title, userMessages: [], omittedMessages: 0, target: null } : null;
|
|
562
|
+
}
|
|
563
|
+
function addCounts(a, b) {
|
|
564
|
+
const out = { ...a };
|
|
565
|
+
for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
var writing = /* @__PURE__ */ new Map();
|
|
569
|
+
function writeHandoff(d, o) {
|
|
570
|
+
const dir = repoFolder(o.store, d.workdir);
|
|
571
|
+
const run = (writing.get(dir) ?? Promise.resolve()).catch(() => void 0).then(() => write(d, o, dir));
|
|
572
|
+
writing.set(dir, run);
|
|
573
|
+
void run.finally(() => {
|
|
574
|
+
if (writing.get(dir) === run) writing.delete(dir);
|
|
575
|
+
}).catch(() => void 0);
|
|
576
|
+
return run;
|
|
577
|
+
}
|
|
578
|
+
async function exists(file) {
|
|
579
|
+
try {
|
|
580
|
+
await (0, import_promises3.stat)(file);
|
|
581
|
+
return true;
|
|
582
|
+
} catch {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async function write(d, o, dir) {
|
|
587
|
+
await (0, import_promises3.mkdir)(dir, { recursive: true });
|
|
588
|
+
const tmp = (0, import_node_path3.join)(dir, `.tmp-${process.pid}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}.harlay`);
|
|
589
|
+
try {
|
|
590
|
+
for (let n = 1; ; n++) {
|
|
591
|
+
const id = n === 1 ? d.baseId : `${d.baseId}-${n}`;
|
|
592
|
+
const path = (0, import_node_path3.join)(dir, `${id}.harlay`);
|
|
593
|
+
if (await exists(path)) continue;
|
|
594
|
+
const r = new Redactor();
|
|
595
|
+
const prompt = launchPrompt(path);
|
|
596
|
+
const record = buildRecord(d, id, o.previousId, { target: o.target, mode: o.mode, prompt }, r);
|
|
597
|
+
await (0, import_promises3.writeFile)(tmp, JSON.stringify(record, null, 2) + "\n");
|
|
598
|
+
try {
|
|
599
|
+
await (0, import_promises3.link)(tmp, path);
|
|
600
|
+
} catch (e) {
|
|
601
|
+
if (e.code === "EEXIST") continue;
|
|
602
|
+
throw e;
|
|
603
|
+
}
|
|
604
|
+
return { id, path, prompt, redactions: addCounts(d.redactions ?? {}, r.counts) };
|
|
605
|
+
}
|
|
606
|
+
} finally {
|
|
607
|
+
await (0, import_promises3.rm)(tmp, { force: true });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ../../packages/core/src/sources/folder.ts
|
|
612
|
+
async function isDir(p) {
|
|
613
|
+
try {
|
|
614
|
+
return (await (0, import_promises4.stat)(p)).isDirectory();
|
|
615
|
+
} catch {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function handoffRepo(file) {
|
|
620
|
+
let raw = null;
|
|
621
|
+
try {
|
|
622
|
+
raw = await (0, import_promises4.readFile)(file, "utf8");
|
|
623
|
+
} catch {
|
|
624
|
+
}
|
|
625
|
+
const root = raw === null ? null : parseRecord(raw)?.root;
|
|
626
|
+
if (root && await isDir(root)) return root;
|
|
627
|
+
return (0, import_node_path4.basename)(file) === LEGACY_FILE && await isDir((0, import_node_path4.dirname)(file)) ? (0, import_node_path4.dirname)(file) : null;
|
|
628
|
+
}
|
|
629
|
+
async function sessionFolder(opened, firstPrompt2) {
|
|
630
|
+
const file = firstPrompt2 === null ? null : harlayFileIn(firstPrompt2);
|
|
631
|
+
return (file && await handoffRepo(file)) ?? opened;
|
|
632
|
+
}
|
|
633
|
+
|
|
143
634
|
// ../../packages/core/src/sources/todos.ts
|
|
144
635
|
var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
|
|
145
636
|
function applyTodoWrite(current2, input) {
|
|
@@ -160,6 +651,14 @@ function applyTodoWrite(current2, input) {
|
|
|
160
651
|
|
|
161
652
|
// ../../packages/core/src/sources/claude.ts
|
|
162
653
|
var OUTPUT_TAIL = 2e4;
|
|
654
|
+
var NOT_RUN_RE = /^(?:Error: )?(?:This command requires approval|The user doesn't want to (?:proceed with this tool use|take this action)|Permission for this action was denied|Permission to use \S+ .*has been denied)/;
|
|
655
|
+
var HANDOFF_FILE_RE = /\.harlay$|\.harlay\/(?:handoffs\/[^/]+\/)?HANDOFF\.md$/;
|
|
656
|
+
function claudeBriefId(a) {
|
|
657
|
+
if (!isObj(a) || a.type !== "file" || typeof a.filename !== "string" || !HANDOFF_FILE_RE.test(a.filename)) return null;
|
|
658
|
+
const c = a.content;
|
|
659
|
+
const text = isObj(c) && isObj(c.file) && typeof c.file.content === "string" ? c.file.content : typeof c === "string" ? c : "";
|
|
660
|
+
return handoffIdIn(text);
|
|
661
|
+
}
|
|
163
662
|
function claudeHalt(record) {
|
|
164
663
|
if (!isObj(record) || record.type !== "assistant" || record.isApiErrorMessage !== true) return null;
|
|
165
664
|
if (record.error !== "rate_limit" && record.apiErrorStatus !== 429) return null;
|
|
@@ -172,16 +671,32 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
172
671
|
const timeline = [];
|
|
173
672
|
const pendingBash = /* @__PURE__ */ new Map();
|
|
174
673
|
const pendingWrite = /* @__PURE__ */ new Map();
|
|
674
|
+
const pendingEdit = /* @__PURE__ */ new Map();
|
|
675
|
+
const pushEdit = (id, edit) => {
|
|
676
|
+
const item = { kind: "edit", edit };
|
|
677
|
+
timeline.push(item);
|
|
678
|
+
if (id) pendingEdit.set(id, item);
|
|
679
|
+
};
|
|
175
680
|
let todos = null;
|
|
176
681
|
let halt = null;
|
|
177
682
|
let model = null;
|
|
178
683
|
let unknown = invalid;
|
|
684
|
+
const handoffIds = [];
|
|
179
685
|
for (const rec of records) {
|
|
180
686
|
if (!isObj(rec) || typeof rec.type !== "string") {
|
|
181
687
|
unknown++;
|
|
182
688
|
continue;
|
|
183
689
|
}
|
|
184
690
|
if (rec.isSidechain === true) continue;
|
|
691
|
+
if (rec.type === "attachment") {
|
|
692
|
+
const id = claudeBriefId(rec.attachment);
|
|
693
|
+
if (id && !handoffIds.includes(id)) handoffIds.push(id);
|
|
694
|
+
const a = rec.attachment;
|
|
695
|
+
if (!id && isObj(a) && a.type === "file" && typeof a.filename === "string" && !HANDOFF_FILE_RE.test(a.filename)) {
|
|
696
|
+
timeline.push({ kind: "read", path: a.filename, how: "attached" });
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
185
700
|
if (rec.type === "assistant") {
|
|
186
701
|
const h = claudeHalt(rec);
|
|
187
702
|
if (h) {
|
|
@@ -205,15 +720,31 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
205
720
|
if (c) pendingBash.set(id, c);
|
|
206
721
|
break;
|
|
207
722
|
}
|
|
208
|
-
case "Edit":
|
|
723
|
+
case "Edit": {
|
|
724
|
+
const p = str(input.file_path);
|
|
725
|
+
const before = str(input.old_string);
|
|
726
|
+
const after = str(input.new_string);
|
|
727
|
+
if (p) pushEdit(id, before !== null && after !== null ? { path: p, kind: "modify", before, after } : { path: p, kind: "modify" });
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
209
730
|
case "MultiEdit": {
|
|
210
731
|
const p = str(input.file_path);
|
|
211
|
-
if (p)
|
|
732
|
+
if (p) pushEdit(id, { path: p, kind: "modify" });
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case "Read": {
|
|
736
|
+
const p = str(input.file_path);
|
|
737
|
+
if (p) timeline.push({ kind: "read", path: p });
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
case "WebFetch": {
|
|
741
|
+
const u = str(input.url);
|
|
742
|
+
if (u) timeline.push({ kind: "read", path: u, how: "fetched" });
|
|
212
743
|
break;
|
|
213
744
|
}
|
|
214
745
|
case "NotebookEdit": {
|
|
215
746
|
const p = str(input.notebook_path);
|
|
216
|
-
if (p)
|
|
747
|
+
if (p) pushEdit(id, { path: p, kind: "modify" });
|
|
217
748
|
break;
|
|
218
749
|
}
|
|
219
750
|
case "Write": {
|
|
@@ -221,7 +752,7 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
221
752
|
if (p) {
|
|
222
753
|
const edit = { path: p, kind: "modify" };
|
|
223
754
|
pendingWrite.set(id, edit);
|
|
224
|
-
|
|
755
|
+
pushEdit(id, edit);
|
|
225
756
|
}
|
|
226
757
|
break;
|
|
227
758
|
}
|
|
@@ -247,6 +778,11 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
247
778
|
if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
248
779
|
if (b.type !== "tool_result") continue;
|
|
249
780
|
const id = str(b.tool_use_id) ?? "";
|
|
781
|
+
const e = pendingEdit.get(id);
|
|
782
|
+
if (e) {
|
|
783
|
+
pendingEdit.delete(id);
|
|
784
|
+
if (b.is_error === true) timeline.splice(timeline.lastIndexOf(e), 1);
|
|
785
|
+
}
|
|
250
786
|
const w = pendingWrite.get(id);
|
|
251
787
|
if (w) {
|
|
252
788
|
pendingWrite.delete(id);
|
|
@@ -256,6 +792,10 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
256
792
|
if (cmd === void 0) continue;
|
|
257
793
|
pendingBash.delete(id);
|
|
258
794
|
const content = blockText(b.content);
|
|
795
|
+
if (b.is_error === true && NOT_RUN_RE.test(content)) {
|
|
796
|
+
timeline.push({ kind: "command", run: { cmd, exitCode: null, output: tail(content, OUTPUT_TAIL) } });
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
259
799
|
const code = /^Exit code (\d+)/.exec(content);
|
|
260
800
|
const exitCode = code ? Number(code[1]) : b.is_error === true ? 1 : 0;
|
|
261
801
|
const r = rec.toolUseResult;
|
|
@@ -268,14 +808,14 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
268
808
|
}
|
|
269
809
|
}
|
|
270
810
|
}
|
|
271
|
-
return { ref, model, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
811
|
+
return { ref, model, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown, handoffIds };
|
|
272
812
|
}
|
|
273
813
|
async function loadDesktopTitles(dir) {
|
|
274
814
|
const out = /* @__PURE__ */ new Map();
|
|
275
|
-
const files = await listSessionFiles(dir, (f) => (0,
|
|
815
|
+
const files = await listSessionFiles(dir, (f) => (0, import_node_path5.basename)(f).startsWith("local_") && f.endsWith(".json"));
|
|
276
816
|
for (const f of files) {
|
|
277
817
|
try {
|
|
278
|
-
const d = JSON.parse(await (0,
|
|
818
|
+
const d = JSON.parse(await (0, import_promises5.readFile)(f.path, "utf8"));
|
|
279
819
|
if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
|
|
280
820
|
out.set(d.cliSessionId, d.title);
|
|
281
821
|
}
|
|
@@ -285,14 +825,14 @@ async function loadDesktopTitles(dir) {
|
|
|
285
825
|
return out;
|
|
286
826
|
}
|
|
287
827
|
function createClaudeReader(o) {
|
|
288
|
-
const root = (0,
|
|
289
|
-
const indexDir = (0,
|
|
290
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
828
|
+
const root = (0, import_node_path5.join)(o.home, ".claude", "projects");
|
|
829
|
+
const indexDir = (0, import_node_path5.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
830
|
+
const isSessionFile = (file) => file.startsWith(root + import_node_path5.sep) && file.endsWith(".jsonl") && (0, import_node_path5.relative)(root, file).split(import_node_path5.sep).length === 2;
|
|
291
831
|
const cache = /* @__PURE__ */ new Map();
|
|
292
832
|
async function refForFile(file, titles) {
|
|
293
833
|
let st;
|
|
294
834
|
try {
|
|
295
|
-
st = await (0,
|
|
835
|
+
st = await (0, import_promises5.stat)(file);
|
|
296
836
|
} catch {
|
|
297
837
|
return null;
|
|
298
838
|
}
|
|
@@ -300,17 +840,20 @@ function createClaudeReader(o) {
|
|
|
300
840
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
301
841
|
let text;
|
|
302
842
|
try {
|
|
303
|
-
text = await (0,
|
|
843
|
+
text = await (0, import_promises5.readFile)(file, "utf8");
|
|
304
844
|
} catch {
|
|
305
845
|
return null;
|
|
306
846
|
}
|
|
307
847
|
let cwd = null;
|
|
308
848
|
let custom = null;
|
|
309
849
|
let ai = null;
|
|
310
|
-
|
|
850
|
+
let first;
|
|
851
|
+
const lines2 = text.split("\n");
|
|
852
|
+
for (const line of lines2) {
|
|
311
853
|
const wantCwd = cwd === null && line.includes('"cwd"');
|
|
312
854
|
const wantTitle = line.includes('-title"');
|
|
313
|
-
|
|
855
|
+
const wantPrompt = first === void 0 && line.includes('"user"');
|
|
856
|
+
if (!wantCwd && !wantTitle && !wantPrompt) continue;
|
|
314
857
|
let r;
|
|
315
858
|
try {
|
|
316
859
|
r = JSON.parse(line);
|
|
@@ -321,11 +864,26 @@ function createClaudeReader(o) {
|
|
|
321
864
|
if (wantCwd && typeof r.cwd === "string") cwd = r.cwd;
|
|
322
865
|
if (r.type === "custom-title" && typeof r.customTitle === "string") custom = r.customTitle;
|
|
323
866
|
if (r.type === "ai-title" && typeof r.aiTitle === "string") ai = r.aiTitle;
|
|
867
|
+
if (wantPrompt && r.type === "user" && r.isMeta !== true && r.isSidechain !== true && r.isCompactSummary !== true && isObj(r.message)) {
|
|
868
|
+
first = cleanPrompt(blockText(r.message.content)) ?? void 0;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
for (let i = lines2.length - 1; i >= 0; i--) {
|
|
872
|
+
if (!lines2[i].includes('"cwd"')) continue;
|
|
873
|
+
try {
|
|
874
|
+
const r = JSON.parse(lines2[i]);
|
|
875
|
+
if (isObj(r) && typeof r.cwd === "string") {
|
|
876
|
+
cwd = r.cwd;
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
} catch {
|
|
880
|
+
}
|
|
324
881
|
}
|
|
882
|
+
cwd = await sessionFolder(cwd, first ?? null);
|
|
325
883
|
entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
|
|
326
884
|
cache.set(file, entry);
|
|
327
885
|
}
|
|
328
|
-
const id = (0,
|
|
886
|
+
const id = (0, import_node_path5.basename)(file, ".jsonl");
|
|
329
887
|
const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
|
|
330
888
|
if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
|
|
331
889
|
return entry.ref;
|
|
@@ -334,7 +892,7 @@ function createClaudeReader(o) {
|
|
|
334
892
|
tool: "claude-code",
|
|
335
893
|
root,
|
|
336
894
|
isSessionFile,
|
|
337
|
-
idFromPath: (file) => isSessionFile(file) ? (0,
|
|
895
|
+
idFromPath: (file) => isSessionFile(file) ? (0, import_node_path5.basename)(file, ".jsonl") : null,
|
|
338
896
|
refForFile: (file) => refForFile(file),
|
|
339
897
|
async listSessions(limit = 20) {
|
|
340
898
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -348,7 +906,7 @@ function createClaudeReader(o) {
|
|
|
348
906
|
return refs;
|
|
349
907
|
},
|
|
350
908
|
async readSession(ref) {
|
|
351
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
909
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises5.readFile)(ref.file, "utf8"));
|
|
352
910
|
return normalizeClaude(records, ref, invalid);
|
|
353
911
|
},
|
|
354
912
|
detect(record) {
|
|
@@ -361,11 +919,21 @@ function createClaudeReader(o) {
|
|
|
361
919
|
}
|
|
362
920
|
|
|
363
921
|
// ../../packages/core/src/sources/codex.ts
|
|
364
|
-
var
|
|
365
|
-
var
|
|
922
|
+
var import_promises6 = require("node:fs/promises");
|
|
923
|
+
var import_node_path6 = require("node:path");
|
|
366
924
|
var OUTPUT_TAIL2 = 2e4;
|
|
367
925
|
var ROLLOUT_ID_RE = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
368
926
|
var KIND = { add: "create", update: "modify", delete: "delete" };
|
|
927
|
+
function diffSides(diff) {
|
|
928
|
+
const before = [];
|
|
929
|
+
const after = [];
|
|
930
|
+
for (const line of diff.split("\n")) {
|
|
931
|
+
if (line.startsWith("---") || line.startsWith("+++")) continue;
|
|
932
|
+
if (line.startsWith("-")) before.push(line.slice(1));
|
|
933
|
+
else if (line.startsWith("+")) after.push(line.slice(1));
|
|
934
|
+
}
|
|
935
|
+
return { before: before.join("\n"), after: after.join("\n") };
|
|
936
|
+
}
|
|
369
937
|
function codexCommand(command) {
|
|
370
938
|
if (typeof command === "string") return command;
|
|
371
939
|
if (!Array.isArray(command)) return "";
|
|
@@ -433,7 +1001,8 @@ function normalizeCodex(records, ref, invalid = 0) {
|
|
|
433
1001
|
} else if (it.type === "FileChange" && isObj(it.changes)) {
|
|
434
1002
|
for (const [path, change] of Object.entries(it.changes)) {
|
|
435
1003
|
const t = isObj(change) ? str(change.type) : null;
|
|
436
|
-
|
|
1004
|
+
const diff = isObj(change) ? str(change.unified_diff) : null;
|
|
1005
|
+
timeline.push({ kind: "edit", edit: { path, kind: t && KIND[t] || "modify", ...diff ? diffSides(diff) : {} } });
|
|
437
1006
|
}
|
|
438
1007
|
}
|
|
439
1008
|
} else if (p.type === "user_message") {
|
|
@@ -464,11 +1033,25 @@ function parseSessionMeta(line) {
|
|
|
464
1033
|
const id = str(p.id);
|
|
465
1034
|
return id ? { id, cwd: str(p.cwd) } : null;
|
|
466
1035
|
}
|
|
1036
|
+
function typedPrompt(line) {
|
|
1037
|
+
if (!line.includes('"user_message"') && !line.includes('"UserMessage"')) return null;
|
|
1038
|
+
let r;
|
|
1039
|
+
try {
|
|
1040
|
+
r = JSON.parse(line);
|
|
1041
|
+
} catch {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
if (!isObj(r) || r.type !== "event_msg" || !isObj(r.payload)) return null;
|
|
1045
|
+
const p = r.payload;
|
|
1046
|
+
if (p.type === "user_message") return typeof p.message === "string" ? cleanPrompt(p.message) : null;
|
|
1047
|
+
if (p.type === "item_completed" && isObj(p.item) && p.item.type === "UserMessage") return cleanPrompt(blockText(p.item.content));
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
467
1050
|
async function loadTitles(file) {
|
|
468
1051
|
const out = /* @__PURE__ */ new Map();
|
|
469
1052
|
let text;
|
|
470
1053
|
try {
|
|
471
|
-
text = await (0,
|
|
1054
|
+
text = await (0, import_promises6.readFile)(file, "utf8");
|
|
472
1055
|
} catch {
|
|
473
1056
|
return out;
|
|
474
1057
|
}
|
|
@@ -478,28 +1061,34 @@ async function loadTitles(file) {
|
|
|
478
1061
|
return out;
|
|
479
1062
|
}
|
|
480
1063
|
function createCodexReader(o) {
|
|
481
|
-
const root = (0,
|
|
482
|
-
const indexFile = (0,
|
|
483
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
1064
|
+
const root = (0, import_node_path6.join)(o.home, ".codex", "sessions");
|
|
1065
|
+
const indexFile = (0, import_node_path6.join)(o.home, ".codex", "session_index.jsonl");
|
|
1066
|
+
const isSessionFile = (file) => file.startsWith(root + import_node_path6.sep) && (0, import_node_path6.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
|
|
484
1067
|
const cache = /* @__PURE__ */ new Map();
|
|
485
1068
|
async function refForFile(file, titles) {
|
|
486
1069
|
let st;
|
|
487
1070
|
try {
|
|
488
|
-
st = await (0,
|
|
1071
|
+
st = await (0, import_promises6.stat)(file);
|
|
489
1072
|
} catch {
|
|
490
1073
|
return null;
|
|
491
1074
|
}
|
|
492
1075
|
let entry = cache.get(file);
|
|
493
1076
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
494
|
-
let
|
|
1077
|
+
let meta = null;
|
|
1078
|
+
let first = null;
|
|
1079
|
+
let lines2 = 0;
|
|
495
1080
|
try {
|
|
496
|
-
|
|
1081
|
+
await eachLine(file, (line) => {
|
|
1082
|
+
if (lines2++ === 0) return (meta = parseSessionMeta(line)) === null;
|
|
1083
|
+
first = typedPrompt(line);
|
|
1084
|
+
return first !== null;
|
|
1085
|
+
});
|
|
497
1086
|
} catch {
|
|
498
1087
|
return null;
|
|
499
1088
|
}
|
|
500
|
-
const meta = first ? parseSessionMeta(first) : null;
|
|
501
1089
|
if (!meta) return null;
|
|
502
|
-
|
|
1090
|
+
const { id, cwd } = meta;
|
|
1091
|
+
entry = { size: st.size, mtimeMs: st.mtimeMs, id, cwd: await sessionFolder(cwd, first), ref: null };
|
|
503
1092
|
cache.set(file, entry);
|
|
504
1093
|
}
|
|
505
1094
|
const title = (titles ?? await loadTitles(indexFile)).get(entry.id) ?? null;
|
|
@@ -510,7 +1099,7 @@ function createCodexReader(o) {
|
|
|
510
1099
|
tool: "codex",
|
|
511
1100
|
root,
|
|
512
1101
|
isSessionFile,
|
|
513
|
-
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0,
|
|
1102
|
+
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path6.basename)(file))?.[1] ?? null : null,
|
|
514
1103
|
refForFile: (file) => refForFile(file),
|
|
515
1104
|
async listSessions(limit = 20) {
|
|
516
1105
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -524,7 +1113,7 @@ function createCodexReader(o) {
|
|
|
524
1113
|
return refs;
|
|
525
1114
|
},
|
|
526
1115
|
async readSession(ref) {
|
|
527
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
1116
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises6.readFile)(ref.file, "utf8"));
|
|
528
1117
|
return normalizeCodex(records, ref, invalid);
|
|
529
1118
|
},
|
|
530
1119
|
detect(record) {
|
|
@@ -549,21 +1138,21 @@ function createCodexReader(o) {
|
|
|
549
1138
|
}
|
|
550
1139
|
|
|
551
1140
|
// ../../packages/core/src/sources/cursor.ts
|
|
552
|
-
var
|
|
553
|
-
var
|
|
1141
|
+
var import_promises7 = require("node:fs/promises");
|
|
1142
|
+
var import_node_path7 = require("node:path");
|
|
554
1143
|
var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
|
|
555
1144
|
async function resolveCursorSlug(slug, rootDir = "/") {
|
|
556
1145
|
if (!slug || /^\d+$/.test(slug)) return null;
|
|
557
1146
|
async function walk(dir, rest, depth) {
|
|
558
1147
|
if (depth > 16) return null;
|
|
559
|
-
const entries = await (0,
|
|
1148
|
+
const entries = await (0, import_promises7.readdir)(dir, { withFileTypes: true }).catch(() => null);
|
|
560
1149
|
if (!entries) return null;
|
|
561
1150
|
for (const e of entries) {
|
|
562
1151
|
if (!e.isDirectory()) continue;
|
|
563
1152
|
const n = norm(e.name);
|
|
564
|
-
if (rest === n) return (0,
|
|
1153
|
+
if (rest === n) return (0, import_node_path7.join)(dir, e.name);
|
|
565
1154
|
if (rest.startsWith(n + "-")) {
|
|
566
|
-
const found = await walk((0,
|
|
1155
|
+
const found = await walk((0, import_node_path7.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
567
1156
|
if (found) return found;
|
|
568
1157
|
}
|
|
569
1158
|
}
|
|
@@ -611,7 +1200,13 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
611
1200
|
const input = isObj(b.input) ? b.input : {};
|
|
612
1201
|
if (b.name === "StrReplace" || b.name === "Write") {
|
|
613
1202
|
const paths = [str(input.path), ...Array.isArray(input.paths) ? input.paths.map(str) : []].filter((x) => !!x);
|
|
614
|
-
|
|
1203
|
+
const before = b.name === "StrReplace" ? str(input.old_string) : null;
|
|
1204
|
+
const after = b.name === "StrReplace" ? str(input.new_string) : null;
|
|
1205
|
+
const sides = before !== null && after !== null && paths.length === 1 ? { before, after } : {};
|
|
1206
|
+
for (const path of paths) timeline.push({ kind: "edit", edit: { path, kind: "modify", ...sides } });
|
|
1207
|
+
} else if (b.name === "Read") {
|
|
1208
|
+
const path = str(input.path) ?? str(input.file_path);
|
|
1209
|
+
if (path) timeline.push({ kind: "read", path });
|
|
615
1210
|
} else if (b.name === "Shell") {
|
|
616
1211
|
const cmd = str(input.command);
|
|
617
1212
|
if (cmd) timeline.push({ kind: "command", run: { cmd, exitCode: null, output: "" } });
|
|
@@ -623,7 +1218,7 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
623
1218
|
return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
624
1219
|
}
|
|
625
1220
|
function createCursorReader(o) {
|
|
626
|
-
const root = (0,
|
|
1221
|
+
const root = (0, import_node_path7.join)(o.home, ".cursor", "projects");
|
|
627
1222
|
const slugCache = /* @__PURE__ */ new Map();
|
|
628
1223
|
const cwdFor = (slug) => {
|
|
629
1224
|
let p = slugCache.get(slug);
|
|
@@ -634,17 +1229,17 @@ function createCursorReader(o) {
|
|
|
634
1229
|
return p;
|
|
635
1230
|
};
|
|
636
1231
|
const isSessionFile = (file) => {
|
|
637
|
-
if (!file.startsWith(root +
|
|
638
|
-
const parts = (0,
|
|
1232
|
+
if (!file.startsWith(root + import_node_path7.sep) || !file.endsWith(".jsonl")) return false;
|
|
1233
|
+
const parts = (0, import_node_path7.relative)(root, file).split(import_node_path7.sep);
|
|
639
1234
|
return parts.length === 4 && parts[1] === "agent-transcripts" && parts[3] === `${parts[2]}.jsonl`;
|
|
640
1235
|
};
|
|
641
1236
|
const cache = /* @__PURE__ */ new Map();
|
|
642
1237
|
async function refForFile(file) {
|
|
643
1238
|
if (!isSessionFile(file)) return null;
|
|
644
|
-
const parts = (0,
|
|
1239
|
+
const parts = (0, import_node_path7.relative)(root, file).split(import_node_path7.sep);
|
|
645
1240
|
let st;
|
|
646
1241
|
try {
|
|
647
|
-
st = await (0,
|
|
1242
|
+
st = await (0, import_promises7.stat)(file);
|
|
648
1243
|
} catch {
|
|
649
1244
|
return null;
|
|
650
1245
|
}
|
|
@@ -652,21 +1247,25 @@ function createCursorReader(o) {
|
|
|
652
1247
|
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
|
|
653
1248
|
let text;
|
|
654
1249
|
try {
|
|
655
|
-
text = await (0,
|
|
1250
|
+
text = await (0, import_promises7.readFile)(file, "utf8");
|
|
656
1251
|
} catch {
|
|
657
1252
|
return null;
|
|
658
1253
|
}
|
|
659
1254
|
let title = null;
|
|
1255
|
+
let first = null;
|
|
660
1256
|
for (const r of parseJsonLines(text).records) {
|
|
661
1257
|
if (isObj(r) && r.role === "user" && isObj(r.message)) {
|
|
662
1258
|
const p = cleanPrompt(blockText(r.message.content));
|
|
663
|
-
|
|
664
|
-
|
|
1259
|
+
first ??= p;
|
|
1260
|
+
const rest = p ? splitHandoffPrompt(p).rest : "";
|
|
1261
|
+
if (rest) {
|
|
1262
|
+
title = oneLine(rest, 60);
|
|
665
1263
|
break;
|
|
666
1264
|
}
|
|
667
1265
|
}
|
|
668
1266
|
}
|
|
669
|
-
const
|
|
1267
|
+
const cwd = await sessionFolder(await cwdFor(parts[0]), first);
|
|
1268
|
+
const ref = { tool: "cursor", id: parts[2], file, cwd, title, lastActivityAt: st.mtime };
|
|
670
1269
|
cache.set(file, { size: st.size, mtimeMs: st.mtimeMs, ref });
|
|
671
1270
|
return ref;
|
|
672
1271
|
}
|
|
@@ -674,7 +1273,7 @@ function createCursorReader(o) {
|
|
|
674
1273
|
tool: "cursor",
|
|
675
1274
|
root,
|
|
676
1275
|
isSessionFile,
|
|
677
|
-
idFromPath: (file) => isSessionFile(file) ? (0,
|
|
1276
|
+
idFromPath: (file) => isSessionFile(file) ? (0, import_node_path7.relative)(root, file).split(import_node_path7.sep)[2] : null,
|
|
678
1277
|
refForFile,
|
|
679
1278
|
async listSessions(limit = 20) {
|
|
680
1279
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -687,7 +1286,7 @@ function createCursorReader(o) {
|
|
|
687
1286
|
return refs;
|
|
688
1287
|
},
|
|
689
1288
|
async readSession(ref) {
|
|
690
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
1289
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises7.readFile)(ref.file, "utf8"));
|
|
691
1290
|
return normalizeCursor(records, ref, invalid);
|
|
692
1291
|
},
|
|
693
1292
|
detect(record) {
|
|
@@ -700,14 +1299,14 @@ function createCursorReader(o) {
|
|
|
700
1299
|
}
|
|
701
1300
|
|
|
702
1301
|
// ../../packages/core/src/git/snapshot.ts
|
|
703
|
-
var
|
|
704
|
-
var
|
|
705
|
-
var
|
|
706
|
-
var
|
|
1302
|
+
var import_node_child_process2 = require("node:child_process");
|
|
1303
|
+
var import_node_crypto2 = require("node:crypto");
|
|
1304
|
+
var import_node_util2 = require("node:util");
|
|
1305
|
+
var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
707
1306
|
var MAX_DIFF_CHARS = 2e6;
|
|
708
1307
|
var MAX_FILES = 500;
|
|
709
1308
|
async function git(cwd, args) {
|
|
710
|
-
const { stdout } = await
|
|
1309
|
+
const { stdout } = await pexec2("git", args, {
|
|
711
1310
|
cwd,
|
|
712
1311
|
maxBuffer: 64 * 1024 * 1024,
|
|
713
1312
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
|
|
@@ -776,97 +1375,42 @@ async function takeGitSnapshot(cwd) {
|
|
|
776
1375
|
}
|
|
777
1376
|
}
|
|
778
1377
|
let diff = await git(root, ["diff", base, "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/"]);
|
|
779
|
-
const diffSha = (0,
|
|
1378
|
+
const diffSha = (0, import_node_crypto2.createHash)("sha256").update(diff).digest("hex");
|
|
780
1379
|
if (diff.length > MAX_DIFF_CHARS) diff = diff.slice(0, MAX_DIFF_CHARS) + "\n[diff truncated by Harlay]\n";
|
|
781
1380
|
return { root, branch, head, files, diff, diffSha };
|
|
782
1381
|
}
|
|
783
1382
|
|
|
784
|
-
// ../../packages/core/src/
|
|
785
|
-
var
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
var
|
|
789
|
-
var
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
|
|
805
|
-
replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
|
|
806
|
-
},
|
|
807
|
-
{
|
|
808
|
-
kind: "env_secret",
|
|
809
|
-
re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
|
|
810
|
-
replace: (_m, g) => `${g[0]}${mark("env_secret")}`
|
|
811
|
-
},
|
|
812
|
-
{
|
|
813
|
-
kind: "config_secret",
|
|
814
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
|
|
815
|
-
replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
|
|
816
|
-
},
|
|
817
|
-
{
|
|
818
|
-
// The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
|
|
819
|
-
// backtrack into a shorter match.
|
|
820
|
-
kind: "config_secret",
|
|
821
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
|
|
822
|
-
replace: (_m, g) => `${g[0]}${mark("config_secret")}`
|
|
823
|
-
},
|
|
824
|
-
{
|
|
825
|
-
kind: "url_credentials",
|
|
826
|
-
re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
|
|
827
|
-
replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
|
|
828
|
-
}
|
|
829
|
-
];
|
|
830
|
-
function shannon(s) {
|
|
831
|
-
const freq = /* @__PURE__ */ new Map();
|
|
832
|
-
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
833
|
-
let h = 0;
|
|
834
|
-
for (const n of freq.values()) {
|
|
835
|
-
const p = n / s.length;
|
|
836
|
-
h -= p * Math.log2(p);
|
|
837
|
-
}
|
|
838
|
-
return h;
|
|
1383
|
+
// ../../packages/core/src/extract/extract.ts
|
|
1384
|
+
var import_node_path8 = require("node:path");
|
|
1385
|
+
|
|
1386
|
+
// ../../packages/core/src/extract/recent.ts
|
|
1387
|
+
var USER_MESSAGES = { budgetChars: 16e3, messageChars: 1500 };
|
|
1388
|
+
var CLIPPED = " \u2026(clipped)";
|
|
1389
|
+
function clipMessage(m) {
|
|
1390
|
+
const t = m.trim();
|
|
1391
|
+
return t.length <= USER_MESSAGES.messageChars ? t : t.slice(0, USER_MESSAGES.messageChars - CLIPPED.length).trimEnd() + CLIPPED;
|
|
1392
|
+
}
|
|
1393
|
+
function pickUserMessages(prompts) {
|
|
1394
|
+
const all = prompts.map(clipMessage).filter((m) => m.length > 0);
|
|
1395
|
+
if (all.reduce((n, m) => n + m.length, 0) <= USER_MESSAGES.budgetChars) return { messages: all, omitted: 0 };
|
|
1396
|
+
const newest = [];
|
|
1397
|
+
let used = all[0].length;
|
|
1398
|
+
for (let i = all.length - 1; i >= 1 && used + all[i].length <= USER_MESSAGES.budgetChars; i--) {
|
|
1399
|
+
newest.unshift(all[i]);
|
|
1400
|
+
used += all[i].length;
|
|
1401
|
+
}
|
|
1402
|
+
return { messages: [all[0], ...newest], omitted: all.length - 1 - newest.length };
|
|
839
1403
|
}
|
|
840
|
-
var Redactor = class {
|
|
841
|
-
counts = {};
|
|
842
|
-
bump(kind) {
|
|
843
|
-
this.counts[kind] = (this.counts[kind] ?? 0) + 1;
|
|
844
|
-
}
|
|
845
|
-
apply(text) {
|
|
846
|
-
let out = text;
|
|
847
|
-
for (const rule of RULES) {
|
|
848
|
-
out = out.replace(rule.re, (match, ...rest) => {
|
|
849
|
-
this.bump(rule.kind);
|
|
850
|
-
const groups = rest.filter((x) => typeof x === "string");
|
|
851
|
-
return rule.replace(match, groups);
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
|
|
855
|
-
if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
|
|
856
|
-
this.bump("high_entropy");
|
|
857
|
-
return mark("high_entropy");
|
|
858
|
-
});
|
|
859
|
-
return out;
|
|
860
|
-
}
|
|
861
|
-
};
|
|
862
1404
|
|
|
863
1405
|
// ../../packages/core/src/extract/extract.ts
|
|
864
|
-
var import_node_path5 = require("node:path");
|
|
865
|
-
var DECISION_RE = /\b(don['’]t|do not|never|must|always|keep|avoid|make sure|without|instead of)\b/i;
|
|
866
1406
|
var TRANSCRIPT_MAX = 2e5;
|
|
1407
|
+
var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
|
|
1408
|
+
function isSlashCommand(p) {
|
|
1409
|
+
return SLASH_COMMAND_RE.test(p.trim());
|
|
1410
|
+
}
|
|
867
1411
|
function isSubstantive(p) {
|
|
868
1412
|
const t = p.trim();
|
|
869
|
-
return !t
|
|
1413
|
+
return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
|
|
870
1414
|
}
|
|
871
1415
|
var PROBES = /* @__PURE__ */ new Set([
|
|
872
1416
|
"rg",
|
|
@@ -896,401 +1440,203 @@ var PROBES = /* @__PURE__ */ new Set([
|
|
|
896
1440
|
"pwd"
|
|
897
1441
|
]);
|
|
898
1442
|
var GIT_PROBES = /* @__PURE__ */ new Set(["diff", "grep", "show", "log", "status", "rev-parse", "ls-files", "branch"]);
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
let
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1443
|
+
function splitShell(cmd, pipes = false) {
|
|
1444
|
+
const out = [];
|
|
1445
|
+
let cur = "";
|
|
1446
|
+
let quote = null;
|
|
1447
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
1448
|
+
const c = cmd[i];
|
|
1449
|
+
if (quote) {
|
|
1450
|
+
cur += c;
|
|
1451
|
+
if (c === "\\" && quote === '"' && i + 1 < cmd.length) cur += cmd[++i];
|
|
1452
|
+
else if (c === quote) quote = null;
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
if (c === '"' || c === "'") {
|
|
1456
|
+
quote = c;
|
|
1457
|
+
cur += c;
|
|
1458
|
+
} else if (c === ";" || c === "&" && cmd[i + 1] === "&" || c === "|" && cmd[i + 1] === "|") {
|
|
1459
|
+
out.push(cur);
|
|
1460
|
+
cur = "";
|
|
1461
|
+
if (c !== ";") i++;
|
|
1462
|
+
} else if (c === "|" && pipes) {
|
|
1463
|
+
out.push(cur);
|
|
1464
|
+
cur = "";
|
|
1465
|
+
} else cur += c;
|
|
1466
|
+
}
|
|
1467
|
+
out.push(cur);
|
|
1468
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
1469
|
+
}
|
|
1470
|
+
var isCd = (s) => /^cd(\s|$)/.test(s);
|
|
1471
|
+
function isProbeStage(stage) {
|
|
1472
|
+
const words = stage.trim().split(/\s+/);
|
|
1473
|
+
const first = (0, import_node_path8.basename)(words[0] ?? "");
|
|
905
1474
|
if (first !== "git") return PROBES.has(first);
|
|
906
1475
|
let i = 1;
|
|
907
1476
|
while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
|
|
908
1477
|
return GIT_PROBES.has(words[i] ?? "");
|
|
909
1478
|
}
|
|
910
|
-
function
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1479
|
+
function isProbeCommand(cmd) {
|
|
1480
|
+
const stages = splitShell(cmd, true).filter((s) => !isCd(s));
|
|
1481
|
+
return stages.length > 0 && stages.every(isProbeStage);
|
|
1482
|
+
}
|
|
1483
|
+
var FAILURE_WINDOW = 5;
|
|
1484
|
+
function findLastFailure(cmds) {
|
|
1485
|
+
const real = cmds.filter((c) => !isProbeCommand(c.cmd));
|
|
1486
|
+
for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
|
|
1487
|
+
const c = real[i];
|
|
1488
|
+
if (c.exitCode === null || c.exitCode === 0) continue;
|
|
1489
|
+
const check = checkOf(c.cmd);
|
|
1490
|
+
if (check?.piped) continue;
|
|
1491
|
+
const later = (d) => {
|
|
1492
|
+
const k = checkOf(d.cmd);
|
|
1493
|
+
return d.cmd === c.cmd || !!check && !!k && k.key === check.key;
|
|
1494
|
+
};
|
|
1495
|
+
if (real.slice(i + 1).some((d) => later(d) && (d.exitCode === 0 || d.exitCode === null || checkOf(d.cmd)?.piped))) continue;
|
|
1496
|
+
return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
|
|
915
1497
|
}
|
|
916
1498
|
return null;
|
|
917
1499
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1500
|
+
var RESULTS_MAX = 12;
|
|
1501
|
+
var REFERENCES_MAX = 15;
|
|
1502
|
+
var REDIRECT_RE = /\s+(?:\d?>>?|&>)\s*(?:&\d|\/dev\/null)/g;
|
|
1503
|
+
function checkOf(cmd) {
|
|
1504
|
+
let piped = false;
|
|
1505
|
+
const parts = splitShell(cmd).filter((s) => !isCd(s) && !isProbeCommand(s)).map((s) => {
|
|
1506
|
+
const stages = splitShell(s, true);
|
|
1507
|
+
if (stages.length > 1) piped = true;
|
|
1508
|
+
return stages[0].replace(REDIRECT_RE, "").trim();
|
|
927
1509
|
});
|
|
928
|
-
return
|
|
929
|
-
}
|
|
930
|
-
function
|
|
931
|
-
const
|
|
932
|
-
for (const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1510
|
+
return parts.length ? { key: parts.join(" && "), piped } : null;
|
|
1511
|
+
}
|
|
1512
|
+
function commandResults(cmds) {
|
|
1513
|
+
const by = /* @__PURE__ */ new Map();
|
|
1514
|
+
for (const c of cmds) {
|
|
1515
|
+
const check = checkOf(c.cmd);
|
|
1516
|
+
if (!check) continue;
|
|
1517
|
+
const r = by.get(check.key) ?? { cmd: check.key, runs: 0, failed: 0, last: "unknown" };
|
|
1518
|
+
by.delete(check.key);
|
|
1519
|
+
r.runs++;
|
|
1520
|
+
const known = c.exitCode !== null && !check.piped;
|
|
1521
|
+
if (known && c.exitCode !== 0) r.failed++;
|
|
1522
|
+
r.last = !known ? "unknown" : c.exitCode === 0 ? "passed" : "failed";
|
|
1523
|
+
by.set(check.key, r);
|
|
1524
|
+
}
|
|
1525
|
+
return [...by.values()].slice(-RESULTS_MAX);
|
|
1526
|
+
}
|
|
1527
|
+
function dropPartialLine(output) {
|
|
1528
|
+
if (!output.startsWith("\u2026")) return output;
|
|
1529
|
+
const nl = output.indexOf("\n");
|
|
1530
|
+
return nl === -1 ? "" : output.slice(nl + 1);
|
|
1531
|
+
}
|
|
1532
|
+
function buildTranscript(s, title, rel, redactor) {
|
|
1533
|
+
const r = (x) => redactor.apply(x);
|
|
1534
|
+
const lines2 = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
|
|
942
1535
|
for (const t of s.timeline) {
|
|
943
|
-
if (t.kind === "user")
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1536
|
+
if (t.kind === "user") {
|
|
1537
|
+
const { handoff: handoff2, rest } = splitHandoffPrompt(t.text);
|
|
1538
|
+
if (handoff2) lines2.push("**Harlay handoff prompt** (the task came from the previous agent in a .harlay file)", "");
|
|
1539
|
+
if (rest) lines2.push("**User:**", r(rest), "");
|
|
1540
|
+
} else if (t.kind === "assistant") {
|
|
1541
|
+
const text = r(t.text);
|
|
1542
|
+
lines2.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
|
|
1543
|
+
} else if (t.kind === "command") lines2.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
|
|
1544
|
+
else if (t.kind === "edit") lines2.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
|
|
1545
|
+
}
|
|
1546
|
+
const out = lines2.join("\n");
|
|
1547
|
+
if (out.length <= TRANSCRIPT_MAX) return out;
|
|
1548
|
+
const cut = out.slice(out.length - TRANSCRIPT_MAX);
|
|
1549
|
+
const nl = cut.indexOf("\n");
|
|
1550
|
+
return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
|
|
950
1551
|
}
|
|
951
1552
|
function extractHandoff(s, git2, o) {
|
|
952
1553
|
const cwd = s.ref.cwd;
|
|
953
1554
|
if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
|
|
954
1555
|
const workdir = git2?.root ?? cwd;
|
|
955
1556
|
const rel = (p) => {
|
|
956
|
-
if (!(0,
|
|
957
|
-
const r = (0,
|
|
958
|
-
return r.startsWith("..") || (0,
|
|
1557
|
+
if (!(0, import_node_path8.isAbsolute)(p)) return p;
|
|
1558
|
+
const r = (0, import_node_path8.relative)(workdir, p);
|
|
1559
|
+
return r.startsWith("..") || (0, import_node_path8.isAbsolute)(r) ? p : r;
|
|
959
1560
|
};
|
|
960
|
-
const
|
|
961
|
-
const prompts =
|
|
1561
|
+
const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
|
|
1562
|
+
const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
|
|
962
1563
|
const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
|
|
963
1564
|
const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
|
|
964
1565
|
const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
const
|
|
968
|
-
const
|
|
1566
|
+
const redactor = new Redactor();
|
|
1567
|
+
const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
|
|
1568
|
+
const prev = o.previous;
|
|
1569
|
+
const started = split.some((p) => p.handoff);
|
|
1570
|
+
const startedFrom = [...s.handoffIds ?? [], ...split.flatMap((p) => p.id ? [p.id] : [])][0] ?? (started ? cmds.map((c) => /\.harlay\b/.test(c.cmd) ? handoffIdIn(c.output) : null).find((id) => id !== null) ?? null : null);
|
|
1571
|
+
const sameTarget = !prev?.target || prev.target === s.ref.tool;
|
|
1572
|
+
const fromPrev = prev ? started && (startedFrom === null ? sameTarget : startedFrom === prev.id) : false;
|
|
1573
|
+
const carried = prev && prev.goal !== null && fromPrev ? prev : null;
|
|
1574
|
+
const carriedMessages = carried ? carried.userMessages.map((m) => redactor.apply(m)) : [];
|
|
1575
|
+
const picked = pickUserMessages([...carriedMessages, ...realPrompts]);
|
|
1576
|
+
const userMessages2 = picked.messages;
|
|
1577
|
+
const omittedMessages = picked.omitted + (carried?.omittedMessages ?? 0);
|
|
1578
|
+
const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
|
|
969
1579
|
const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
|
|
970
|
-
const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
|
|
971
1580
|
const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
|
|
972
|
-
const
|
|
973
|
-
const
|
|
974
|
-
const
|
|
975
|
-
const
|
|
976
|
-
|
|
1581
|
+
const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
|
|
1582
|
+
const openTodos = todos.flatMap((t) => t.status === "in_progress" || t.status === "pending" ? [{ text: redactor.apply(t.text).replace(/\s+/g, " ").trim(), status: t.status }] : []).slice(0, 8);
|
|
1583
|
+
const failure = findLastFailure(cmds);
|
|
1584
|
+
const results = commandResults(cmds).map((r) => ({ ...r, cmd: redactor.apply(r.cmd) }));
|
|
1585
|
+
const references = { read: [], attached: [], urls: [] };
|
|
1586
|
+
for (const t of s.timeline) {
|
|
1587
|
+
if (t.kind !== "read") continue;
|
|
1588
|
+
const list = t.how === "fetched" ? references.urls : t.how === "attached" ? references.attached : references.read;
|
|
1589
|
+
const p = redactor.apply(t.how === "fetched" ? t.path : rel(t.path));
|
|
1590
|
+
if (!list.includes(p)) list.push(p);
|
|
1591
|
+
}
|
|
1592
|
+
for (const k of ["read", "attached", "urls"]) references[k] = references[k].slice(-REFERENCES_MAX);
|
|
1593
|
+
const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
|
|
1594
|
+
const filesTouched = [];
|
|
1595
|
+
for (const e of [...edits].reverse()) {
|
|
977
1596
|
const r = rel(e.path);
|
|
978
|
-
if (!
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
const readFirst = [...editedFiles].sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0)).slice(0, 3);
|
|
982
|
-
const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
|
|
983
|
-
const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
|
|
984
|
-
const redactor = new Redactor();
|
|
985
|
-
const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
|
|
986
|
-
const pad = (n2) => String(n2).padStart(2, "0");
|
|
987
|
-
const n = o.now;
|
|
988
|
-
const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
|
|
989
|
-
return {
|
|
990
|
-
baseId,
|
|
991
|
-
title,
|
|
992
|
-
status: "in_progress",
|
|
993
|
-
createdAt: o.now,
|
|
994
|
-
from: {
|
|
995
|
-
tool: s.ref.tool,
|
|
996
|
-
model: s.model,
|
|
997
|
-
session: s.ref.id,
|
|
998
|
-
reason: o.reason ?? s.halt?.reason ?? "manual",
|
|
999
|
-
resetsAt: s.halt?.resetsAt ?? null,
|
|
1000
|
-
message: s.halt?.message ?? null
|
|
1001
|
-
},
|
|
1002
|
-
workdir,
|
|
1003
|
-
git: git2,
|
|
1004
|
-
goal,
|
|
1005
|
-
latestInstruction,
|
|
1006
|
-
nextStep,
|
|
1007
|
-
blocker: blocker && blocker.exitCode !== null ? { cmd: blocker.cmd, exitCode: blocker.exitCode, output: blocker.output } : null,
|
|
1008
|
-
progress,
|
|
1009
|
-
progressSource,
|
|
1010
|
-
editedFiles,
|
|
1011
|
-
decisions: findDecisions(prompts),
|
|
1012
|
-
deadEnds: findDeadEnds(cmds, blocker),
|
|
1013
|
-
readFirst,
|
|
1014
|
-
transcript: buildTranscript(s, title, rel),
|
|
1015
|
-
unknownRecords: s.unknownRecords
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// ../../packages/core/src/brief/render.ts
|
|
1020
|
-
var BUDGET = { goal: 150, latest: 100, next: 200, blocker: 250, progress: 300, files: 300, decisions: 250, deadEnds: 200 };
|
|
1021
|
-
var REASON_TEXT = {
|
|
1022
|
-
usage_limit: "it hit a usage limit",
|
|
1023
|
-
crash: "it stopped with an error",
|
|
1024
|
-
manual: "the user handed it off"
|
|
1025
|
-
};
|
|
1026
|
-
var j = (v) => JSON.stringify(v ?? null);
|
|
1027
|
-
function quote(s) {
|
|
1028
|
-
return s.split("\n").map((l) => l.trim() ? `> ${l}` : ">").join("\n");
|
|
1029
|
-
}
|
|
1030
|
-
function listWithin(lines, maxTokens, pointer) {
|
|
1031
|
-
const max = maxTokens * CHARS_PER_TOKEN;
|
|
1032
|
-
const out = [];
|
|
1033
|
-
let used = 0;
|
|
1034
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1035
|
-
const line = lines[i];
|
|
1036
|
-
if (out.length > 0 && used + line.length + 1 > max) {
|
|
1037
|
-
out.push(`- \u2026 ${lines.length - i} more (see \`${pointer}\`)`);
|
|
1038
|
-
break;
|
|
1039
|
-
}
|
|
1040
|
-
out.push(line);
|
|
1041
|
-
used += line.length + 1;
|
|
1042
|
-
}
|
|
1043
|
-
return out;
|
|
1044
|
-
}
|
|
1045
|
-
function tailWithin(s, maxTokens) {
|
|
1046
|
-
const max = maxTokens * CHARS_PER_TOKEN;
|
|
1047
|
-
const t = s.trimEnd();
|
|
1048
|
-
if (t.length <= max) return t;
|
|
1049
|
-
const cut = t.slice(t.length - max);
|
|
1050
|
-
const nl = cut.indexOf("\n");
|
|
1051
|
-
return "\u2026\n" + (nl >= 0 ? cut.slice(nl + 1) : cut);
|
|
1052
|
-
}
|
|
1053
|
-
function fenceFor(body) {
|
|
1054
|
-
const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((r) => r.length));
|
|
1055
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
1056
|
-
}
|
|
1057
|
-
function fileLine(f) {
|
|
1058
|
-
const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
|
|
1059
|
-
return `- \`${f.path}\` (${f.status}${counts})`;
|
|
1060
|
-
}
|
|
1061
|
-
function renderBrief(d, id, opts = {}) {
|
|
1062
|
-
const dir = `.harlay/handoffs/${id}`;
|
|
1063
|
-
const transcript = `${dir}/transcript.md`;
|
|
1064
|
-
const front = [
|
|
1065
|
-
"---",
|
|
1066
|
-
"harlay: 1",
|
|
1067
|
-
`id: ${j(id)}`,
|
|
1068
|
-
`status: ${j(d.status)}`,
|
|
1069
|
-
`created: ${j(d.createdAt.toISOString())}`,
|
|
1070
|
-
"from:",
|
|
1071
|
-
` tool: ${j(d.from.tool)}`,
|
|
1072
|
-
` model: ${j(d.from.model)}`,
|
|
1073
|
-
` session: ${j(d.from.session)}`,
|
|
1074
|
-
` reason: ${j(d.from.reason)}`,
|
|
1075
|
-
` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
|
|
1076
|
-
"repo:",
|
|
1077
|
-
` path: ${j(d.workdir)}`,
|
|
1078
|
-
...d.git ? [` branch: ${j(d.git.branch)}`, ` base_commit: ${j(d.git.head)}`, ` dirty_files: ${d.git.files.length}`, ` diff_sha: ${j(d.git.diffSha.slice(0, 12))}`] : [" git: false"],
|
|
1079
|
-
"---"
|
|
1080
|
-
];
|
|
1081
|
-
const header = [
|
|
1082
|
-
`# Continue: ${d.title}`,
|
|
1083
|
-
"",
|
|
1084
|
-
`> You are continuing a coding task that another AI agent (${TOOL_LABEL[d.from.tool]}) started but could not finish (${REASON_TEXT[d.from.reason]}).`,
|
|
1085
|
-
"> This brief was extracted from its session log and the repo.",
|
|
1086
|
-
d.git ? '> First run `git status` and check it matches "Changed files". Do not redo finished work.' : "> Check the files listed below before editing. Do not redo finished work."
|
|
1087
|
-
];
|
|
1088
|
-
const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
|
|
1089
|
-
if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
|
|
1090
|
-
const next = ["## 2. Next step"];
|
|
1091
|
-
if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
|
|
1092
|
-
else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
|
|
1093
|
-
else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
|
|
1094
|
-
const blocker = ["## 3. Blocker"];
|
|
1095
|
-
if (d.blocker) {
|
|
1096
|
-
const body = tailWithin(d.blocker.output, BUDGET.blocker);
|
|
1097
|
-
const fence = fenceFor(body);
|
|
1098
|
-
blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
|
|
1099
|
-
} else {
|
|
1100
|
-
blocker.push("No failing command recorded.");
|
|
1101
|
-
}
|
|
1102
|
-
const progress = ["## 4. Progress"];
|
|
1103
|
-
if (d.progressSource === "none") progress.push("Nothing recorded yet.");
|
|
1104
|
-
else {
|
|
1105
|
-
if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
|
|
1106
|
-
progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
|
|
1107
|
-
}
|
|
1108
|
-
const files = [];
|
|
1109
|
-
if (d.git) {
|
|
1110
|
-
files.push("## 5. Changed files (uncommitted)");
|
|
1111
|
-
if (d.git.files.length === 0) files.push("Working tree is clean.");
|
|
1112
|
-
else files.push(...listWithin(d.git.files.map(fileLine), BUDGET.files, `${dir}/diff.patch`), `Full diff: \`${dir}/diff.patch\``);
|
|
1113
|
-
} else {
|
|
1114
|
-
files.push("## 5. Changed files", "Not a git repository." + (d.editedFiles.length ? " Files the agent edited:" : ""));
|
|
1115
|
-
files.push(...listWithin(d.editedFiles.map((f) => `- \`${f}\``), BUDGET.files, transcript));
|
|
1116
|
-
}
|
|
1117
|
-
const decisions = [
|
|
1118
|
-
"## 6. Decisions and constraints",
|
|
1119
|
-
...d.decisions.length ? listWithin(d.decisions.map((s) => `- "${oneLine(s, 300)}"`), BUDGET.decisions, transcript) : ["None recorded."]
|
|
1120
|
-
];
|
|
1121
|
-
const deadEnds = [
|
|
1122
|
-
"## 7. Dead ends (don't retry)",
|
|
1123
|
-
...d.deadEnds.length ? listWithin(d.deadEnds.map((e) => `- \`${oneLine(e.cmd, 160)}\` failed (exit ${e.exitCode})`), BUDGET.deadEnds, transcript) : ["None recorded."]
|
|
1124
|
-
];
|
|
1125
|
-
const more = ["## 8. Read more", `- Condensed transcript: \`${transcript}\``];
|
|
1126
|
-
if (opts.previousBrief) more.push(`- Previous brief (as last updated by the previous agent): \`${dir}/previous-HANDOFF.md\``);
|
|
1127
|
-
if (d.readFirst.length) more.push(`- Read these first: ${d.readFirst.map((f) => `\`${f}\``).join(", ")}`);
|
|
1128
|
-
const stop = ["## 9. When you stop", "Before ending your turn, update sections 2\u20134 and `status:` in this file so the next agent can continue."];
|
|
1129
|
-
return [front, header, goal, next, blocker, progress, files, decisions, deadEnds, more, stop].map((s) => s.join("\n")).join("\n\n") + "\n";
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// ../../packages/core/src/brief/write.ts
|
|
1133
|
-
var import_promises5 = require("node:fs/promises");
|
|
1134
|
-
var import_node_path6 = require("node:path");
|
|
1135
|
-
async function allocateHandoffId(handoffsDir, baseId) {
|
|
1136
|
-
for (let i = 1; i < 1e3; i++) {
|
|
1137
|
-
const id = i === 1 ? baseId : `${baseId}-${i}`;
|
|
1138
|
-
try {
|
|
1139
|
-
await (0, import_promises5.access)((0, import_node_path6.join)(handoffsDir, id));
|
|
1140
|
-
} catch {
|
|
1141
|
-
return id;
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
throw new HarlayError(`Could not allocate a handoff folder for ${baseId}`);
|
|
1145
|
-
}
|
|
1146
|
-
async function ensureGitExclude(workdir) {
|
|
1147
|
-
const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
1148
|
-
const file = (0, import_node_path6.isAbsolute)(p) ? p : (0, import_node_path6.join)(workdir, p);
|
|
1149
|
-
let current2 = "";
|
|
1150
|
-
try {
|
|
1151
|
-
current2 = await (0, import_promises5.readFile)(file, "utf8");
|
|
1152
|
-
} catch {
|
|
1153
|
-
current2 = "";
|
|
1154
|
-
}
|
|
1155
|
-
if (current2.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
|
|
1156
|
-
await (0, import_promises5.mkdir)((0, import_node_path6.dirname)(file), { recursive: true });
|
|
1157
|
-
await (0, import_promises5.appendFile)(file, `${current2 && !current2.endsWith("\n") ? "\n" : ""}.harlay/
|
|
1158
|
-
`);
|
|
1159
|
-
}
|
|
1160
|
-
async function readHandoffState(workdir) {
|
|
1161
|
-
let raw;
|
|
1162
|
-
try {
|
|
1163
|
-
raw = JSON.parse(await (0, import_promises5.readFile)((0, import_node_path6.join)(workdir, ".harlay", "state.json"), "utf8"));
|
|
1164
|
-
} catch {
|
|
1165
|
-
return null;
|
|
1166
|
-
}
|
|
1167
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
1168
|
-
const s = raw;
|
|
1169
|
-
if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
|
|
1170
|
-
if (s.goal !== null && typeof s.goal !== "string") return null;
|
|
1171
|
-
return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
|
|
1172
|
-
}
|
|
1173
|
-
async function readIfExists(file) {
|
|
1174
|
-
try {
|
|
1175
|
-
return await (0, import_promises5.readFile)(file, "utf8");
|
|
1176
|
-
} catch {
|
|
1177
|
-
return null;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
async function writeHandoff(d) {
|
|
1181
|
-
const harlayDir = (0, import_node_path6.join)(d.workdir, ".harlay");
|
|
1182
|
-
const latestPath = (0, import_node_path6.join)(harlayDir, "HANDOFF.md");
|
|
1183
|
-
const id = await allocateHandoffId((0, import_node_path6.join)(harlayDir, "handoffs"), d.baseId);
|
|
1184
|
-
const dir = (0, import_node_path6.join)(harlayDir, "handoffs", id);
|
|
1185
|
-
await (0, import_promises5.mkdir)(dir, { recursive: true });
|
|
1186
|
-
const r = new Redactor();
|
|
1187
|
-
const previous = await readIfExists(latestPath);
|
|
1188
|
-
if (previous !== null) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "previous-HANDOFF.md"), r.apply(previous));
|
|
1189
|
-
const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
|
|
1190
|
-
const briefPath = (0, import_node_path6.join)(dir, "HANDOFF.md");
|
|
1191
|
-
await (0, import_promises5.writeFile)(briefPath, brief);
|
|
1192
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "transcript.md"), r.apply(d.transcript));
|
|
1193
|
-
if (d.git) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1194
|
-
if (d.blocker) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "last-error.txt"), r.apply(d.blocker.output));
|
|
1195
|
-
const from = {
|
|
1196
|
-
...d.from,
|
|
1197
|
-
message: d.from.message === null ? null : r.apply(d.from.message),
|
|
1198
|
-
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
|
|
1199
|
-
};
|
|
1200
|
-
const goal = d.goal === null ? null : r.apply(d.goal);
|
|
1201
|
-
const title = r.apply(d.title);
|
|
1202
|
-
const meta = {
|
|
1203
|
-
harlay: 1,
|
|
1204
|
-
id,
|
|
1205
|
-
createdAt: d.createdAt.toISOString(),
|
|
1206
|
-
title,
|
|
1207
|
-
goal,
|
|
1208
|
-
from,
|
|
1209
|
-
workdir: d.workdir,
|
|
1210
|
-
unknownRecords: d.unknownRecords,
|
|
1211
|
-
git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
|
|
1212
|
-
redactions: { ...r.counts }
|
|
1213
|
-
};
|
|
1214
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
1215
|
-
await (0, import_promises5.writeFile)(latestPath, brief);
|
|
1216
|
-
const state2 = { harlay: 1, lastId: id, goal, title };
|
|
1217
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(harlayDir, "state.json"), JSON.stringify(state2, null, 2) + "\n");
|
|
1218
|
-
if (d.git) await ensureGitExclude(d.workdir);
|
|
1219
|
-
return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// ../../packages/core/src/launch/launchers.ts
|
|
1223
|
-
var import_node_child_process2 = require("node:child_process");
|
|
1224
|
-
var import_promises6 = require("node:fs/promises");
|
|
1225
|
-
var import_node_path7 = require("node:path");
|
|
1226
|
-
var import_node_util2 = require("node:util");
|
|
1227
|
-
var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
1228
|
-
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
1229
|
-
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
1230
|
-
var defaultRunner = {
|
|
1231
|
-
async open(args) {
|
|
1232
|
-
await pexec2("/usr/bin/open", args);
|
|
1233
|
-
},
|
|
1234
|
-
async writeExecutable(path, content) {
|
|
1235
|
-
await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(path), { recursive: true });
|
|
1236
|
-
await (0, import_promises6.writeFile)(path, content, { mode: 493 });
|
|
1237
|
-
await (0, import_promises6.chmod)(path, 493);
|
|
1238
|
-
},
|
|
1239
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1240
|
-
};
|
|
1241
|
-
function shq(s) {
|
|
1242
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1243
|
-
}
|
|
1244
|
-
var enc = encodeURIComponent;
|
|
1245
|
-
function planLaunch(o) {
|
|
1246
|
-
if (o.mode === "app") {
|
|
1247
|
-
switch (o.target) {
|
|
1248
|
-
case "claude-code":
|
|
1249
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
1250
|
-
case "codex":
|
|
1251
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
1252
|
-
case "cursor":
|
|
1253
|
-
return {
|
|
1254
|
-
target: o.target,
|
|
1255
|
-
mode: o.mode,
|
|
1256
|
-
steps: [
|
|
1257
|
-
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
1258
|
-
{ kind: "wait", ms: 1500 },
|
|
1259
|
-
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
1260
|
-
]
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1597
|
+
if (!(0, import_node_path8.isAbsolute)(r) && !filesTouched.includes(r)) filesTouched.push(r);
|
|
1598
|
+
if (filesTouched.length === 12) break;
|
|
1263
1599
|
}
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
const
|
|
1267
|
-
const
|
|
1268
|
-
const content = `#!/bin/zsh -l
|
|
1269
|
-
cd -- ${shq(o.cwd)} || exit 1
|
|
1270
|
-
exec ${argv.map(shq).join(" ")}
|
|
1271
|
-
`;
|
|
1600
|
+
const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
|
|
1601
|
+
const pad = (n2) => String(n2).padStart(2, "0");
|
|
1602
|
+
const n = o.now;
|
|
1603
|
+
const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
|
|
1272
1604
|
return {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1605
|
+
baseId,
|
|
1606
|
+
title,
|
|
1607
|
+
createdAt: o.now,
|
|
1608
|
+
from: {
|
|
1609
|
+
tool: s.ref.tool,
|
|
1610
|
+
model: s.model,
|
|
1611
|
+
session: s.ref.id,
|
|
1612
|
+
reason: o.reason ?? s.halt?.reason ?? "manual",
|
|
1613
|
+
resetsAt: s.halt?.resetsAt ?? null,
|
|
1614
|
+
message: s.halt?.message ?? null
|
|
1615
|
+
},
|
|
1616
|
+
workdir,
|
|
1617
|
+
git: git2,
|
|
1618
|
+
originalGoal,
|
|
1619
|
+
userMessages: userMessages2,
|
|
1620
|
+
omittedMessages,
|
|
1621
|
+
whereItStopped,
|
|
1622
|
+
lastRequest: userMessages2.at(-1) ?? null,
|
|
1623
|
+
todos: openTodos,
|
|
1624
|
+
results,
|
|
1625
|
+
references,
|
|
1626
|
+
filesTouched,
|
|
1627
|
+
lastFailure,
|
|
1628
|
+
transcript: buildTranscript(s, title, rel, redactor),
|
|
1629
|
+
unknownRecords: s.unknownRecords,
|
|
1630
|
+
redactions: { ...redactor.counts },
|
|
1631
|
+
carriedFrom: carried ? carried.id : null
|
|
1279
1632
|
};
|
|
1280
1633
|
}
|
|
1281
|
-
async function executeLaunch(plan, runner = defaultRunner) {
|
|
1282
|
-
for (const step of plan.steps) {
|
|
1283
|
-
if (step.kind === "open") await runner.open(step.args);
|
|
1284
|
-
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
1285
|
-
else await runner.sleep(step.ms);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
1634
|
|
|
1289
1635
|
// ../../packages/core/src/launch/resolve.ts
|
|
1290
|
-
var
|
|
1291
|
-
var
|
|
1636
|
+
var import_node_fs2 = require("node:fs");
|
|
1637
|
+
var import_promises8 = require("node:fs/promises");
|
|
1292
1638
|
var import_node_os = require("node:os");
|
|
1293
|
-
var
|
|
1639
|
+
var import_node_path9 = require("node:path");
|
|
1294
1640
|
function defaultResolveOptions(env = process.env) {
|
|
1295
1641
|
return {
|
|
1296
1642
|
home: env.HARLAY_HOME ?? (0, import_node_os.homedir)(),
|
|
@@ -1304,28 +1650,28 @@ var APP_BUNDLES = { "claude-code": ["Claude.app"], codex: ["ChatGPT.app", "Codex
|
|
|
1304
1650
|
function knownPaths(tool, o) {
|
|
1305
1651
|
switch (tool) {
|
|
1306
1652
|
case "claude-code":
|
|
1307
|
-
return [(0,
|
|
1653
|
+
return [(0, import_node_path9.join)(o.home, ".local", "bin", "claude")];
|
|
1308
1654
|
case "codex":
|
|
1309
1655
|
return [
|
|
1310
|
-
(0,
|
|
1311
|
-
(0,
|
|
1656
|
+
(0, import_node_path9.join)(o.applicationsDir, "ChatGPT.app", "Contents", "Resources", "codex-cli", "bin", "codex"),
|
|
1657
|
+
(0, import_node_path9.join)(o.applicationsDir, "Codex.app", "Contents", "Resources", "codex-cli", "bin", "codex")
|
|
1312
1658
|
];
|
|
1313
1659
|
case "cursor":
|
|
1314
|
-
return [(0,
|
|
1660
|
+
return [(0, import_node_path9.join)(o.home, ".local", "bin", "cursor-agent")];
|
|
1315
1661
|
}
|
|
1316
1662
|
}
|
|
1317
1663
|
async function isExecutable(p) {
|
|
1318
1664
|
try {
|
|
1319
|
-
if (!(await (0,
|
|
1320
|
-
await (0,
|
|
1665
|
+
if (!(await (0, import_promises8.stat)(p)).isFile()) return false;
|
|
1666
|
+
await (0, import_promises8.access)(p, import_node_fs2.constants.X_OK);
|
|
1321
1667
|
return true;
|
|
1322
1668
|
} catch {
|
|
1323
1669
|
return false;
|
|
1324
1670
|
}
|
|
1325
1671
|
}
|
|
1326
|
-
async function
|
|
1672
|
+
async function exists2(p) {
|
|
1327
1673
|
try {
|
|
1328
|
-
await (0,
|
|
1674
|
+
await (0, import_promises8.access)(p);
|
|
1329
1675
|
return true;
|
|
1330
1676
|
} catch {
|
|
1331
1677
|
return false;
|
|
@@ -1334,7 +1680,7 @@ async function exists(p) {
|
|
|
1334
1680
|
async function resolveBinary(tool, o) {
|
|
1335
1681
|
const dirs = [...(o.pathEnv ?? "").split(":").filter(Boolean), ...o.systemDirs];
|
|
1336
1682
|
for (const d of dirs) {
|
|
1337
|
-
const p = (0,
|
|
1683
|
+
const p = (0, import_node_path9.join)(d, BIN_NAME[tool]);
|
|
1338
1684
|
if (await isExecutable(p)) return p;
|
|
1339
1685
|
}
|
|
1340
1686
|
for (const p of knownPaths(tool, o)) if (await isExecutable(p)) return p;
|
|
@@ -1353,7 +1699,7 @@ async function availableTargets(o) {
|
|
|
1353
1699
|
const out = {};
|
|
1354
1700
|
for (const t of TOOLS) {
|
|
1355
1701
|
let app2 = false;
|
|
1356
|
-
for (const b of APP_BUNDLES[t]) if (await
|
|
1702
|
+
for (const b of APP_BUNDLES[t]) if (await exists2((0, import_node_path9.join)(o.applicationsDir, b))) app2 = true;
|
|
1357
1703
|
out[t] = { app: app2, terminal: bins[t] !== void 0 };
|
|
1358
1704
|
}
|
|
1359
1705
|
return out;
|
|
@@ -1368,53 +1714,109 @@ function readerFor(readers2, tool) {
|
|
|
1368
1714
|
if (!r) throw new HarlayError(`No reader for ${tool}`);
|
|
1369
1715
|
return r;
|
|
1370
1716
|
}
|
|
1371
|
-
async function
|
|
1372
|
-
const
|
|
1373
|
-
|
|
1717
|
+
async function findSessionById(readers2, tool, id) {
|
|
1718
|
+
const reader = readerFor(readers2, tool);
|
|
1719
|
+
const files = (await listSessionFiles(reader.root, reader.isSessionFile)).map((f) => ({ path: f.path, id: reader.idFromPath(f.path) }));
|
|
1720
|
+
const exact = files.filter((f) => f.id === id);
|
|
1721
|
+
const prefix = files.filter((f) => f.id !== null && f.id !== id && f.id.startsWith(id));
|
|
1722
|
+
for (const f of [...exact, ...prefix]) {
|
|
1723
|
+
const ref = await reader.refForFile(f.path);
|
|
1724
|
+
if (ref) return ref;
|
|
1725
|
+
}
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
function parseTool(name) {
|
|
1729
|
+
const n = name.trim().toLowerCase();
|
|
1730
|
+
if (n === "claude" || n === "claude-code" || n === "cc") return "claude-code";
|
|
1731
|
+
if (n === "codex") return "codex";
|
|
1732
|
+
if (n === "cursor") return "cursor";
|
|
1733
|
+
throw new HarlayError(`Unknown tool "${name}". Use claude, codex or cursor.`);
|
|
1734
|
+
}
|
|
1735
|
+
function parseSessionSpec(spec) {
|
|
1736
|
+
const i = spec.indexOf(":");
|
|
1737
|
+
if (i <= 0 || i === spec.length - 1) throw new HarlayError(`Invalid --session "${spec}". Use <tool>:<id>, for example codex:019a\u2026`);
|
|
1738
|
+
return { tool: parseTool(spec.slice(0, i)), id: spec.slice(i + 1) };
|
|
1374
1739
|
}
|
|
1375
1740
|
|
|
1376
1741
|
// ../../packages/core/src/handoff.ts
|
|
1377
|
-
var
|
|
1742
|
+
var import_promises9 = require("node:fs/promises");
|
|
1743
|
+
var import_node_os2 = require("node:os");
|
|
1744
|
+
var import_node_path10 = require("node:path");
|
|
1745
|
+
async function launchHandoff(o) {
|
|
1746
|
+
const binaries = await resolveBinaries(o.resolve ?? defaultResolveOptions());
|
|
1747
|
+
const plan = planLaunch({
|
|
1748
|
+
target: o.target,
|
|
1749
|
+
mode: o.mode,
|
|
1750
|
+
cwd: o.cwd,
|
|
1751
|
+
prompt: o.prompt,
|
|
1752
|
+
scriptDir: (0, import_node_path10.join)(o.scriptDir ?? (0, import_node_path10.join)((0, import_node_os2.tmpdir)(), "harlay"), o.id),
|
|
1753
|
+
binaries,
|
|
1754
|
+
terminalApp: o.terminalApp ?? "Terminal"
|
|
1755
|
+
});
|
|
1756
|
+
await executeLaunch(plan, o.runner);
|
|
1757
|
+
return plan;
|
|
1758
|
+
}
|
|
1378
1759
|
async function runHandoff(o) {
|
|
1379
1760
|
const cwd = o.session.cwd;
|
|
1380
1761
|
if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
|
|
1381
1762
|
try {
|
|
1382
|
-
if (!(await (0,
|
|
1763
|
+
if (!(await (0, import_promises9.stat)(cwd)).isDirectory()) throw new Error("not a directory");
|
|
1383
1764
|
} catch {
|
|
1384
1765
|
throw new HarlayError(`The session's folder no longer exists: ${cwd}`);
|
|
1385
1766
|
}
|
|
1386
1767
|
const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
|
|
1387
1768
|
const git2 = await takeGitSnapshot(cwd);
|
|
1388
|
-
const
|
|
1389
|
-
const previous = state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title } : null;
|
|
1769
|
+
const previous = await readPreviousHandoff(o.store, git2?.root ?? cwd);
|
|
1390
1770
|
const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
|
|
1391
|
-
const written = await writeHandoff(data);
|
|
1771
|
+
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
|
|
1392
1772
|
if (o.launch === false) return { written, plan: null, data };
|
|
1393
1773
|
let plan;
|
|
1394
1774
|
try {
|
|
1395
|
-
|
|
1396
|
-
plan = planLaunch({
|
|
1397
|
-
target: o.target,
|
|
1398
|
-
mode: o.mode,
|
|
1399
|
-
cwd: data.workdir,
|
|
1400
|
-
prompt: HANDOFF_PROMPT,
|
|
1401
|
-
scriptDir: written.dir,
|
|
1402
|
-
binaries,
|
|
1403
|
-
terminalApp: o.terminalApp ?? "Terminal"
|
|
1404
|
-
});
|
|
1405
|
-
await executeLaunch(plan, o.runner);
|
|
1775
|
+
plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
|
|
1406
1776
|
} catch (e) {
|
|
1407
1777
|
const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
|
|
1408
|
-
throw new LaunchError(`${reason}. The
|
|
1778
|
+
throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
|
|
1409
1779
|
}
|
|
1410
1780
|
return { written, plan, data };
|
|
1411
1781
|
}
|
|
1412
1782
|
|
|
1783
|
+
// ../../packages/core/src/delivery.ts
|
|
1784
|
+
var SLACK_MS = 2e3;
|
|
1785
|
+
function firstPrompt(s) {
|
|
1786
|
+
const t = s.timeline.find((x) => x.kind === "user");
|
|
1787
|
+
return t && t.kind === "user" ? cleanPrompt(t.text) ?? t.text : null;
|
|
1788
|
+
}
|
|
1789
|
+
function hasRead(s, h) {
|
|
1790
|
+
if (s.handoffIds?.includes(h.id)) return true;
|
|
1791
|
+
return s.timeline.some(
|
|
1792
|
+
(t) => t.kind === "command" && /\.harlay\b/.test(t.run.cmd) && handoffIdIn(t.run.output) === h.id || t.kind === "read" && t.path === h.path
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
async function checkDelivery(readers2, h) {
|
|
1796
|
+
const reader = readerFor(readers2, h.target);
|
|
1797
|
+
const after = h.since.getTime() - SLACK_MS;
|
|
1798
|
+
let sent = null;
|
|
1799
|
+
for (const ref of await reader.listSessions(20)) {
|
|
1800
|
+
if (ref.lastActivityAt.getTime() < after) continue;
|
|
1801
|
+
let s;
|
|
1802
|
+
try {
|
|
1803
|
+
s = await reader.readSession(ref);
|
|
1804
|
+
} catch {
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
const p = firstPrompt(s);
|
|
1808
|
+
if (p === null || !splitHandoffPrompt(p).handoff || harlayFileIn(p) !== h.path) continue;
|
|
1809
|
+
if (hasRead(s, h)) return { state: "read", session: ref };
|
|
1810
|
+
sent ??= { state: "sent", session: ref };
|
|
1811
|
+
}
|
|
1812
|
+
return sent;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1413
1815
|
// ../../packages/core/src/watch/watcher.ts
|
|
1414
1816
|
var import_node_events = require("node:events");
|
|
1415
|
-
var
|
|
1416
|
-
var
|
|
1417
|
-
var
|
|
1817
|
+
var import_node_fs3 = require("node:fs");
|
|
1818
|
+
var import_promises10 = require("node:fs/promises");
|
|
1819
|
+
var import_node_path11 = require("node:path");
|
|
1418
1820
|
var import_node_string_decoder = require("node:string_decoder");
|
|
1419
1821
|
var Watcher = class extends import_node_events.EventEmitter {
|
|
1420
1822
|
constructor(readers2, opts = {}) {
|
|
@@ -1438,14 +1840,14 @@ var Watcher = class extends import_node_events.EventEmitter {
|
|
|
1438
1840
|
for (const r of this.readers) {
|
|
1439
1841
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1440
1842
|
try {
|
|
1441
|
-
this.offsets.set(f.path, (await (0,
|
|
1843
|
+
this.offsets.set(f.path, (await (0, import_promises10.stat)(f.path)).size);
|
|
1442
1844
|
} catch {
|
|
1443
1845
|
}
|
|
1444
1846
|
}
|
|
1445
1847
|
if (this.opts.fsWatch === false) continue;
|
|
1446
1848
|
try {
|
|
1447
|
-
const w = (0,
|
|
1448
|
-
if (name) this.schedule((0,
|
|
1849
|
+
const w = (0, import_node_fs3.watch)(r.root, { recursive: true }, (_event, name) => {
|
|
1850
|
+
if (name) this.schedule((0, import_node_path11.join)(r.root, name.toString()));
|
|
1449
1851
|
});
|
|
1450
1852
|
w.on("error", (e) => this.emit("error", e));
|
|
1451
1853
|
this.watchers.push(w);
|
|
@@ -1466,7 +1868,7 @@ var Watcher = class extends import_node_events.EventEmitter {
|
|
|
1466
1868
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1467
1869
|
let size;
|
|
1468
1870
|
try {
|
|
1469
|
-
size = (await (0,
|
|
1871
|
+
size = (await (0, import_promises10.stat)(f.path)).size;
|
|
1470
1872
|
} catch {
|
|
1471
1873
|
continue;
|
|
1472
1874
|
}
|
|
@@ -1523,7 +1925,7 @@ var Watcher = class extends import_node_events.EventEmitter {
|
|
|
1523
1925
|
if (!reader) return [];
|
|
1524
1926
|
let size;
|
|
1525
1927
|
try {
|
|
1526
|
-
size = (await (0,
|
|
1928
|
+
size = (await (0, import_promises10.stat)(file)).size;
|
|
1527
1929
|
} catch {
|
|
1528
1930
|
return [];
|
|
1529
1931
|
}
|
|
@@ -1535,7 +1937,7 @@ var Watcher = class extends import_node_events.EventEmitter {
|
|
|
1535
1937
|
}
|
|
1536
1938
|
if (size === offset) return [];
|
|
1537
1939
|
const buf = Buffer.alloc(size - offset);
|
|
1538
|
-
const fh = await (0,
|
|
1940
|
+
const fh = await (0, import_promises10.open)(file, "r");
|
|
1539
1941
|
try {
|
|
1540
1942
|
await fh.read(buf, 0, buf.length, offset);
|
|
1541
1943
|
} finally {
|
|
@@ -1547,10 +1949,10 @@ var Watcher = class extends import_node_events.EventEmitter {
|
|
|
1547
1949
|
decoder = new import_node_string_decoder.StringDecoder("utf8");
|
|
1548
1950
|
this.decoders.set(file, decoder);
|
|
1549
1951
|
}
|
|
1550
|
-
const
|
|
1551
|
-
this.partial.set(file,
|
|
1952
|
+
const lines2 = ((this.partial.get(file) ?? "") + decoder.write(buf)).split("\n");
|
|
1953
|
+
this.partial.set(file, lines2.pop() ?? "");
|
|
1552
1954
|
const detected = [];
|
|
1553
|
-
for (const line of
|
|
1955
|
+
for (const line of lines2) {
|
|
1554
1956
|
if (!line.trim()) continue;
|
|
1555
1957
|
let rec;
|
|
1556
1958
|
try {
|
|
@@ -1612,8 +2014,8 @@ function fullestWindow(s) {
|
|
|
1612
2014
|
|
|
1613
2015
|
// ../../packages/core/src/usage/codex.ts
|
|
1614
2016
|
var import_node_child_process3 = require("node:child_process");
|
|
1615
|
-
var
|
|
1616
|
-
var
|
|
2017
|
+
var import_promises11 = require("node:fs/promises");
|
|
2018
|
+
var import_node_path12 = require("node:path");
|
|
1617
2019
|
var PACE_MS = 60 * 6e4;
|
|
1618
2020
|
var TAIL_BYTES = 1024 * 1024;
|
|
1619
2021
|
function num(x) {
|
|
@@ -1638,7 +2040,7 @@ function planOf(rl) {
|
|
|
1638
2040
|
return typeof p === "string" ? p : null;
|
|
1639
2041
|
}
|
|
1640
2042
|
async function readTail(file, bytes) {
|
|
1641
|
-
const fh = await (0,
|
|
2043
|
+
const fh = await (0, import_promises11.open)(file, "r");
|
|
1642
2044
|
try {
|
|
1643
2045
|
const { size } = await fh.stat();
|
|
1644
2046
|
const start = Math.max(0, size - bytes);
|
|
@@ -1651,7 +2053,7 @@ async function readTail(file, bytes) {
|
|
|
1651
2053
|
}
|
|
1652
2054
|
}
|
|
1653
2055
|
async function readCodexReadings(home2, files = 8) {
|
|
1654
|
-
const logs = (await listSessionFiles((0,
|
|
2056
|
+
const logs = (await listSessionFiles((0, import_node_path12.join)(home2, ".codex", "sessions"), (f) => f.endsWith(".jsonl"))).slice(0, files);
|
|
1655
2057
|
const out = [];
|
|
1656
2058
|
for (const { path } of logs) {
|
|
1657
2059
|
let text;
|
|
@@ -1755,8 +2157,8 @@ function queryCodexUsage(codexBin, timeoutMs = 15e3) {
|
|
|
1755
2157
|
}
|
|
1756
2158
|
|
|
1757
2159
|
// ../../packages/core/src/usage/claude.ts
|
|
1758
|
-
var
|
|
1759
|
-
var
|
|
2160
|
+
var import_promises12 = require("node:fs/promises");
|
|
2161
|
+
var import_node_path13 = require("node:path");
|
|
1760
2162
|
var HOUR = 36e5;
|
|
1761
2163
|
var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
|
|
1762
2164
|
var DISPROVE_FACTOR = 1.15;
|
|
@@ -1782,18 +2184,18 @@ var ClaudeUsageLog = class {
|
|
|
1782
2184
|
replies = /* @__PURE__ */ new Map();
|
|
1783
2185
|
hits = /* @__PURE__ */ new Map();
|
|
1784
2186
|
async refresh(now = /* @__PURE__ */ new Date(), maxAgeMs = 8 * 24 * HOUR) {
|
|
1785
|
-
const root = (0,
|
|
2187
|
+
const root = (0, import_node_path13.join)(this.home, ".claude", "projects");
|
|
1786
2188
|
let names;
|
|
1787
2189
|
try {
|
|
1788
|
-
names = await (0,
|
|
2190
|
+
names = await (0, import_promises12.readdir)(root, { recursive: true });
|
|
1789
2191
|
} catch {
|
|
1790
2192
|
return;
|
|
1791
2193
|
}
|
|
1792
2194
|
for (const name of names) {
|
|
1793
2195
|
if (!name.endsWith(".jsonl")) continue;
|
|
1794
|
-
const path = (0,
|
|
2196
|
+
const path = (0, import_node_path13.join)(root, name);
|
|
1795
2197
|
try {
|
|
1796
|
-
const s = await (0,
|
|
2198
|
+
const s = await (0, import_promises12.stat)(path);
|
|
1797
2199
|
if (now.getTime() - s.mtimeMs > maxAgeMs) continue;
|
|
1798
2200
|
await this.readFrom(path, s.size);
|
|
1799
2201
|
} catch {
|
|
@@ -1807,15 +2209,15 @@ var ClaudeUsageLog = class {
|
|
|
1807
2209
|
const st = this.files.get(path) ?? { offset: 0, partial: "" };
|
|
1808
2210
|
if (size < st.offset) Object.assign(st, { offset: 0, partial: "" });
|
|
1809
2211
|
if (size === st.offset) return;
|
|
1810
|
-
const fh = await (0,
|
|
2212
|
+
const fh = await (0, import_promises12.open)(path, "r");
|
|
1811
2213
|
try {
|
|
1812
2214
|
const buf = Buffer.alloc(size - st.offset);
|
|
1813
2215
|
await fh.read(buf, 0, buf.length, st.offset);
|
|
1814
2216
|
st.offset = size;
|
|
1815
2217
|
const text = st.partial + buf.toString("utf8");
|
|
1816
|
-
const
|
|
1817
|
-
st.partial =
|
|
1818
|
-
for (const line of
|
|
2218
|
+
const lines2 = text.split("\n");
|
|
2219
|
+
st.partial = lines2.pop() ?? "";
|
|
2220
|
+
for (const line of lines2) this.ingest(line);
|
|
1819
2221
|
} finally {
|
|
1820
2222
|
await fh.close();
|
|
1821
2223
|
this.files.set(path, st);
|
|
@@ -2010,18 +2412,18 @@ function claudeSnapshot({ entries, hits, exact, samples, now }) {
|
|
|
2010
2412
|
}
|
|
2011
2413
|
|
|
2012
2414
|
// ../../packages/core/src/usage/bridge.ts
|
|
2013
|
-
var
|
|
2014
|
-
var
|
|
2415
|
+
var import_promises13 = require("node:fs/promises");
|
|
2416
|
+
var import_node_path14 = require("node:path");
|
|
2015
2417
|
var BRIDGE_MARKER = "# harlay-usage";
|
|
2016
2418
|
function claudeSettingsPath(home2) {
|
|
2017
|
-
return (0,
|
|
2419
|
+
return (0, import_node_path14.join)(home2, ".claude", "settings.json");
|
|
2018
2420
|
}
|
|
2019
2421
|
function claudeStatusFile(dataDir2) {
|
|
2020
|
-
return (0,
|
|
2422
|
+
return (0, import_node_path14.join)(dataDir2, "claude-status.json");
|
|
2021
2423
|
}
|
|
2022
|
-
var scriptPath = (dataDir2) => (0,
|
|
2023
|
-
var previousJson = (dataDir2) => (0,
|
|
2024
|
-
var previousCommand = (dataDir2) => (0,
|
|
2424
|
+
var scriptPath = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.sh");
|
|
2425
|
+
var previousJson = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.previous.json");
|
|
2426
|
+
var previousCommand = (dataDir2) => (0, import_node_path14.join)(dataDir2, "claude-statusline.previous");
|
|
2025
2427
|
var BRIDGE_SCRIPT = `#!/bin/sh
|
|
2026
2428
|
# Harlay's Claude Code status line. Saves Claude Code's status line input (it includes your exact usage)
|
|
2027
2429
|
# for Harlay, then shows your previous status line, or a short usage summary if you had none.
|
|
@@ -2047,28 +2449,28 @@ function bridgeCommand(dataDir2) {
|
|
|
2047
2449
|
const q = `'${scriptPath(dataDir2).replace(/'/g, `'\\''`)}'`;
|
|
2048
2450
|
return `${q} ${BRIDGE_MARKER}`;
|
|
2049
2451
|
}
|
|
2050
|
-
function
|
|
2452
|
+
function isObj3(x) {
|
|
2051
2453
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
2052
2454
|
}
|
|
2053
2455
|
function isOurs(statusLine) {
|
|
2054
|
-
return
|
|
2456
|
+
return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
|
|
2055
2457
|
}
|
|
2056
2458
|
async function readSettings(path) {
|
|
2057
2459
|
let text;
|
|
2058
2460
|
try {
|
|
2059
|
-
text = await (0,
|
|
2461
|
+
text = await (0, import_promises13.readFile)(path, "utf8");
|
|
2060
2462
|
} catch (e) {
|
|
2061
2463
|
if (e.code === "ENOENT") return {};
|
|
2062
2464
|
throw e;
|
|
2063
2465
|
}
|
|
2064
2466
|
const parsed = JSON.parse(text);
|
|
2065
|
-
if (!
|
|
2467
|
+
if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
|
|
2066
2468
|
return parsed;
|
|
2067
2469
|
}
|
|
2068
2470
|
async function writeAtomic(path, text) {
|
|
2069
2471
|
const tmp = `${path}.harlay-tmp`;
|
|
2070
|
-
await (0,
|
|
2071
|
-
await (0,
|
|
2472
|
+
await (0, import_promises13.writeFile)(tmp, text);
|
|
2473
|
+
await (0, import_promises13.rename)(tmp, path);
|
|
2072
2474
|
}
|
|
2073
2475
|
async function bridgeState(home2) {
|
|
2074
2476
|
try {
|
|
@@ -2080,21 +2482,21 @@ async function bridgeState(home2) {
|
|
|
2080
2482
|
async function installBridge(home2, dataDir2) {
|
|
2081
2483
|
const path = claudeSettingsPath(home2);
|
|
2082
2484
|
const s = await readSettings(path);
|
|
2083
|
-
await (0,
|
|
2084
|
-
await (0,
|
|
2085
|
-
await (0,
|
|
2485
|
+
await (0, import_promises13.mkdir)(dataDir2, { recursive: true });
|
|
2486
|
+
await (0, import_promises13.writeFile)(scriptPath(dataDir2), BRIDGE_SCRIPT);
|
|
2487
|
+
await (0, import_promises13.chmod)(scriptPath(dataDir2), 493);
|
|
2086
2488
|
if (isOurs(s.statusLine)) return;
|
|
2087
2489
|
const prev = s.statusLine;
|
|
2088
2490
|
if (prev !== void 0) {
|
|
2089
2491
|
await writeAtomic(previousJson(dataDir2), JSON.stringify(prev, null, 2) + "\n");
|
|
2090
|
-
const cmd =
|
|
2492
|
+
const cmd = isObj3(prev) && typeof prev.command === "string" ? prev.command : "";
|
|
2091
2493
|
await writeAtomic(previousCommand(dataDir2), cmd);
|
|
2092
2494
|
} else {
|
|
2093
|
-
await (0,
|
|
2094
|
-
await (0,
|
|
2495
|
+
await (0, import_promises13.rm)(previousJson(dataDir2), { force: true });
|
|
2496
|
+
await (0, import_promises13.rm)(previousCommand(dataDir2), { force: true });
|
|
2095
2497
|
}
|
|
2096
|
-
s.statusLine = { ...
|
|
2097
|
-
await (0,
|
|
2498
|
+
s.statusLine = { ...isObj3(prev) ? prev : {}, type: "command", command: bridgeCommand(dataDir2) };
|
|
2499
|
+
await (0, import_promises13.mkdir)((0, import_node_path14.join)(home2, ".claude"), { recursive: true });
|
|
2098
2500
|
await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
|
|
2099
2501
|
}
|
|
2100
2502
|
async function removeBridge(home2, dataDir2) {
|
|
@@ -2103,25 +2505,435 @@ async function removeBridge(home2, dataDir2) {
|
|
|
2103
2505
|
if (!isOurs(s.statusLine)) return;
|
|
2104
2506
|
let prev;
|
|
2105
2507
|
try {
|
|
2106
|
-
prev = JSON.parse(await (0,
|
|
2508
|
+
prev = JSON.parse(await (0, import_promises13.readFile)(previousJson(dataDir2), "utf8"));
|
|
2107
2509
|
} catch {
|
|
2108
2510
|
prev = void 0;
|
|
2109
2511
|
}
|
|
2110
2512
|
if (prev === void 0) delete s.statusLine;
|
|
2111
2513
|
else s.statusLine = prev;
|
|
2112
2514
|
await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
|
|
2113
|
-
await (0,
|
|
2114
|
-
await (0,
|
|
2515
|
+
await (0, import_promises13.rm)(previousJson(dataDir2), { force: true });
|
|
2516
|
+
await (0, import_promises13.rm)(previousCommand(dataDir2), { force: true });
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// ../../packages/core/src/health/context.ts
|
|
2520
|
+
var import_promises14 = require("node:fs/promises");
|
|
2521
|
+
var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
2522
|
+
function parse(line) {
|
|
2523
|
+
try {
|
|
2524
|
+
const v = JSON.parse(line);
|
|
2525
|
+
return isObj(v) ? v : null;
|
|
2526
|
+
} catch {
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
function scanLine(tool, line, s) {
|
|
2531
|
+
if (tool === "claude-code") {
|
|
2532
|
+
if (line.includes('"compact_boundary"') && parse(line)?.subtype === "compact_boundary") s.boundaries++;
|
|
2533
|
+
else if (line.includes('"isCompactSummary":true')) s.summaries++;
|
|
2534
|
+
else if (line.includes('"type":"assistant"') && line.includes('"usage"')) {
|
|
2535
|
+
const rec = parse(line);
|
|
2536
|
+
if (!rec || rec.type !== "assistant" || rec.isSidechain === true || !isObj(rec.message) || !isObj(rec.message.usage)) return;
|
|
2537
|
+
const u = rec.message.usage;
|
|
2538
|
+
const tokens = num2(u.input_tokens) + num2(u.cache_creation_input_tokens) + num2(u.cache_read_input_tokens);
|
|
2539
|
+
if (tokens > 0) s.info.tokens = tokens;
|
|
2540
|
+
}
|
|
2541
|
+
s.info.compactions = Math.max(s.boundaries, s.summaries);
|
|
2542
|
+
} else if (tool === "codex") {
|
|
2543
|
+
if (line.includes('"type":"compacted"') && parse(line)?.type === "compacted") s.info.compactions++;
|
|
2544
|
+
else if (line.includes('"token_count"')) {
|
|
2545
|
+
const p = parse(line)?.payload;
|
|
2546
|
+
if (!isObj(p) || p.type !== "token_count" || !isObj(p.info)) return;
|
|
2547
|
+
const last = isObj(p.info.last_token_usage) ? num2(p.info.last_token_usage.input_tokens) : 0;
|
|
2548
|
+
if (last > 0) s.info.tokens = last;
|
|
2549
|
+
if (num2(p.info.model_context_window) > 0) s.info.window = num2(p.info.model_context_window);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
async function readFrom(file, start, end) {
|
|
2554
|
+
const fh = await (0, import_promises14.open)(file, "r");
|
|
2555
|
+
try {
|
|
2556
|
+
const buf = Buffer.alloc(end - start);
|
|
2557
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, start);
|
|
2558
|
+
return buf.subarray(0, bytesRead);
|
|
2559
|
+
} finally {
|
|
2560
|
+
await fh.close();
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
var ContextTracker = class {
|
|
2564
|
+
scans = /* @__PURE__ */ new Map();
|
|
2565
|
+
async read(ref) {
|
|
2566
|
+
if (ref.tool === "cursor") return null;
|
|
2567
|
+
let size;
|
|
2568
|
+
try {
|
|
2569
|
+
size = (await (0, import_promises14.stat)(ref.file)).size;
|
|
2570
|
+
} catch {
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
let s = this.scans.get(ref.file);
|
|
2574
|
+
if (!s || size < s.size) s = { size: 0, rest: Buffer.alloc(0), info: { tokens: null, window: null, compactions: 0 }, boundaries: 0, summaries: 0 };
|
|
2575
|
+
if (size > s.size) {
|
|
2576
|
+
const buf = Buffer.concat([s.rest, await readFrom(ref.file, s.size, size)]);
|
|
2577
|
+
const end = buf.lastIndexOf(10);
|
|
2578
|
+
for (const line of end === -1 ? [] : buf.subarray(0, end).toString("utf8").split("\n")) scanLine(ref.tool, line, s);
|
|
2579
|
+
s.rest = end === -1 ? buf : Buffer.from(buf.subarray(end + 1));
|
|
2580
|
+
s.size = size;
|
|
2581
|
+
}
|
|
2582
|
+
this.scans.set(ref.file, s);
|
|
2583
|
+
return { ...s.info };
|
|
2584
|
+
}
|
|
2585
|
+
forget(file) {
|
|
2586
|
+
this.scans.delete(file);
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
|
|
2590
|
+
// ../../packages/core/src/health/tail.ts
|
|
2591
|
+
var import_promises15 = require("node:fs/promises");
|
|
2592
|
+
var TAIL_BYTES2 = 15e5;
|
|
2593
|
+
async function readSessionTail(ref, maxBytes = TAIL_BYTES2) {
|
|
2594
|
+
const fh = await (0, import_promises15.open)(ref.file, "r");
|
|
2595
|
+
let text;
|
|
2596
|
+
try {
|
|
2597
|
+
const { size } = await fh.stat();
|
|
2598
|
+
const start = Math.max(0, size - maxBytes);
|
|
2599
|
+
const buf = Buffer.alloc(size - start);
|
|
2600
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, start);
|
|
2601
|
+
let body = buf.subarray(0, bytesRead);
|
|
2602
|
+
if (start > 0) body = body.subarray(body.indexOf(10) + 1);
|
|
2603
|
+
text = body.toString("utf8");
|
|
2604
|
+
} finally {
|
|
2605
|
+
await fh.close();
|
|
2606
|
+
}
|
|
2607
|
+
const { records, invalid } = parseJsonLines(text);
|
|
2608
|
+
if (ref.tool === "claude-code") return normalizeClaude(records, ref, invalid);
|
|
2609
|
+
if (ref.tool === "codex") return normalizeCodex(records, ref, invalid);
|
|
2610
|
+
return normalizeCursor(records, ref, invalid);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// ../../packages/core/src/health/signals.ts
|
|
2614
|
+
var import_node_path15 = require("node:path");
|
|
2615
|
+
|
|
2616
|
+
// ../../packages/core/src/extract/rules.ts
|
|
2617
|
+
var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
|
|
2618
|
+
var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
|
|
2619
|
+
var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2620
|
+
function findPinnedRules(prompts, recent) {
|
|
2621
|
+
const recentText = norm2(recent.join("\n"));
|
|
2622
|
+
const found = [];
|
|
2623
|
+
prompts.forEach((p, message) => {
|
|
2624
|
+
if (wordCount(p) > RULES2.pastedWords) return;
|
|
2625
|
+
for (const sentence of splitSentences(p)) {
|
|
2626
|
+
if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
|
|
2627
|
+
if (recentText.includes(norm2(sentence))) continue;
|
|
2628
|
+
found.push({ text: clipWords(sentence, RULES2.words), message });
|
|
2629
|
+
}
|
|
2630
|
+
});
|
|
2631
|
+
return capRules(latestCopies(found));
|
|
2632
|
+
}
|
|
2633
|
+
function latestCopies(rules) {
|
|
2634
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2635
|
+
for (const r of rules) {
|
|
2636
|
+
const key = norm2(r.text);
|
|
2637
|
+
byKey.delete(key);
|
|
2638
|
+
byKey.set(key, r);
|
|
2639
|
+
}
|
|
2640
|
+
return [...byKey.values()];
|
|
2641
|
+
}
|
|
2642
|
+
function capRules(all) {
|
|
2643
|
+
const kept = [];
|
|
2644
|
+
let used = 0;
|
|
2645
|
+
for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
|
|
2646
|
+
const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
|
|
2647
|
+
if (used + cost > RULES2.budgetTokens) break;
|
|
2648
|
+
kept.unshift(all[i]);
|
|
2649
|
+
used += cost;
|
|
2650
|
+
}
|
|
2651
|
+
return { rules: kept, more: all.length - kept.length };
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// ../../packages/core/src/health/signals.ts
|
|
2655
|
+
var SIGNALS = {
|
|
2656
|
+
/** Timeline items (messages, commands, edits, reads) looked at; about the last hour of a busy chat. */
|
|
2657
|
+
recentItems: 80,
|
|
2658
|
+
/** Recent real commands looked at for repeated failures. */
|
|
2659
|
+
recentCommands: 15,
|
|
2660
|
+
/** The same command failing this many times in a row, with no success since. */
|
|
2661
|
+
repeatFailures: 3,
|
|
2662
|
+
recentUserMessages: 6,
|
|
2663
|
+
recentAgentMessages: 8,
|
|
2664
|
+
churnEdits: 6,
|
|
2665
|
+
/** No progress: at least this many steps and failures (across 2+ commands) since the last fix or commit. */
|
|
2666
|
+
stuckSteps: 10,
|
|
2667
|
+
stuckFailures: 4,
|
|
2668
|
+
/** Reading the same file (or running the same lookup) this many times without changing it. */
|
|
2669
|
+
rereads: 3,
|
|
2670
|
+
/** Context sizes at which answers tend to get less reliable (the context rot research measures length, not %). */
|
|
2671
|
+
longTokens: 1e5,
|
|
2672
|
+
veryLongTokens: 22e4
|
|
2673
|
+
};
|
|
2674
|
+
var LEADING_CD_RE = /^\s*cd\s+(?:"[^"]*"|'[^']*'|[^\s;&]+)\s*(?:&&|;)\s*/;
|
|
2675
|
+
var ENV_RE = /^(?:[A-Z_][A-Z0-9_]*=\S*\s+)+/;
|
|
2676
|
+
function bare(cmd) {
|
|
2677
|
+
return cmd.replace(LEADING_CD_RE, "").replace(ENV_RE, "").replace(/^sudo\s+/, "").trim();
|
|
2678
|
+
}
|
|
2679
|
+
function signature(cmd) {
|
|
2680
|
+
return bare(cmd).split(/\s+/).slice(0, 3).join(" ");
|
|
2681
|
+
}
|
|
2682
|
+
var failed = (r) => r.exitCode !== null && r.exitCode !== 0;
|
|
2683
|
+
var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2684
|
+
var realRuns = (t) => t.filter((i) => i.kind === "command" && !isProbeCommand(i.run.cmd));
|
|
2685
|
+
var clipQuote = (s, max = 40) => s.length > max ? `${s.slice(0, max - 1).trimEnd()}\u2026` : s;
|
|
2686
|
+
var PUSHBACK_RE = /\b(still (not|isn'?t|doesn'?t|didn'?t|broken|failing|wrong|the same)|(is|are|it'?s)? ?not working|doesn'?t work|didn'?t (work|fix)|same (error|issue|problem|bug)|that'?s (not|wrong)|you (already|keep|still|broke)|i (said|told you|asked you)|why (did|are|would) you|undo (that|this|it)|revert (that|this|it)|wrong again|not what i (asked|wanted|said|meant)|(it|that|this) broke|nothing (changed|happened|works)|wtf|what the (hell|heck|fuck)|are you (kidding|serious)|come on|seriously|ugh+|nope|bro|brother|dude)\b/i;
|
|
2687
|
+
var SHOUT_RE = /(?:\b[A-Z]{3,}\b.*){3,}|[!?]{2,}/;
|
|
2688
|
+
function userMessages(t) {
|
|
2689
|
+
return t.filter((i) => i.kind === "user").map((i) => i.text).filter((x) => !isSlashCommand(x) && !isHandoffPrompt(x));
|
|
2690
|
+
}
|
|
2691
|
+
function pushbackSignals(t) {
|
|
2692
|
+
const msgs = userMessages(t).slice(-SIGNALS.recentUserMessages);
|
|
2693
|
+
let pushback = msgs.filter((m) => PUSHBACK_RE.test(m) || SHOUT_RE.test(m)).length;
|
|
2694
|
+
for (let i = 1; i < msgs.length; i++) if (norm3(msgs[i]).length > 12 && norm3(msgs[i]) === norm3(msgs[i - 1])) pushback++;
|
|
2695
|
+
if (pushback >= 2) return [{ weight: 2, behavior: true, text: `You pushed back ${pushback} times in your last ${msgs.length} messages` }];
|
|
2696
|
+
if (pushback === 1) return [{ weight: 1, behavior: true, text: "You pushed back on a recent answer" }];
|
|
2697
|
+
return [];
|
|
2698
|
+
}
|
|
2699
|
+
var CLAIM_RE = /\b(fixed|(that|this) (should|will) (fix|work|solve)|should (now )?(work|pass)|works now|now works|it'?s working|(is|are) working now|all (the )?tests pass|tests (now )?pass|(build|tests?) (is|are) (now )?(green|passing)|resolved|all set|problem solved)\b/i;
|
|
2700
|
+
function falseFixSignals(t) {
|
|
2701
|
+
const failing = /* @__PURE__ */ new Set();
|
|
2702
|
+
let claim = null;
|
|
2703
|
+
let hits = 0;
|
|
2704
|
+
let example = null;
|
|
2705
|
+
for (const i of t) {
|
|
2706
|
+
if (i.kind === "assistant") {
|
|
2707
|
+
if (CLAIM_RE.test(i.text)) claim = { failing: new Set(failing) };
|
|
2708
|
+
} else if (i.kind === "command" && !isProbeCommand(i.run.cmd)) {
|
|
2709
|
+
const sig = signature(i.run.cmd);
|
|
2710
|
+
if (failed(i.run)) {
|
|
2711
|
+
if (claim?.failing.has(sig)) {
|
|
2712
|
+
hits++;
|
|
2713
|
+
example ??= sig;
|
|
2714
|
+
claim = null;
|
|
2715
|
+
}
|
|
2716
|
+
failing.add(sig);
|
|
2717
|
+
} else {
|
|
2718
|
+
failing.delete(sig);
|
|
2719
|
+
claim?.failing.delete(sig);
|
|
2720
|
+
}
|
|
2721
|
+
} else if (i.kind === "user" && claim) {
|
|
2722
|
+
if (PUSHBACK_RE.test(i.text) || SHOUT_RE.test(i.text)) hits++;
|
|
2723
|
+
claim = null;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
if (!hits) return [];
|
|
2727
|
+
if (hits === 1) return [{ weight: 1, behavior: true, text: example ? `Said it was fixed, but \`${oneLine(example, 40)}\` failed again` : "Said it was fixed, but it wasn\u2019t" }];
|
|
2728
|
+
return [{ weight: 2, behavior: true, text: `Said it was fixed ${hits} times, but it wasn\u2019t` }];
|
|
2729
|
+
}
|
|
2730
|
+
var APOLOGY_RE = /\b(you'?re (absolutely |completely |totally )?right|i apologi[sz]e|my apologies|sorry about|my mistake|i was wrong)\b/i;
|
|
2731
|
+
var SWITCH_RE = /\b(let me try (a different|another|something else)|(a )?different approach|another approach|alternative approach|let me take a step back|let'?s try something else)\b/i;
|
|
2732
|
+
function flipFlopSignals(t) {
|
|
2733
|
+
const n = t.filter((i) => i.kind === "assistant").slice(-SIGNALS.recentAgentMessages).filter((i) => APOLOGY_RE.test(i.text) || SWITCH_RE.test(i.text)).length;
|
|
2734
|
+
return n >= 2 ? [{ weight: 1, behavior: true, text: `It apologized or changed course ${n} times recently` }] : [];
|
|
2735
|
+
}
|
|
2736
|
+
function repeatFailureSignals(t) {
|
|
2737
|
+
const streak = /* @__PURE__ */ new Map();
|
|
2738
|
+
for (const { run } of realRuns(t).slice(-SIGNALS.recentCommands)) {
|
|
2739
|
+
const key = signature(run.cmd);
|
|
2740
|
+
streak.set(key, failed(run) ? (streak.get(key) ?? 0) + 1 : 0);
|
|
2741
|
+
}
|
|
2742
|
+
const [worst, times] = [...streak].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
|
|
2743
|
+
return times >= SIGNALS.repeatFailures ? [{ weight: 2, behavior: true, text: `\`${oneLine(worst, 40)}\` failed ${times} times in a row` }] : [];
|
|
2744
|
+
}
|
|
2745
|
+
function noProgressSignals(t) {
|
|
2746
|
+
const everFailed = /* @__PURE__ */ new Set();
|
|
2747
|
+
let since = { steps: 0, failures: 0, failing: /* @__PURE__ */ new Set() };
|
|
2748
|
+
for (const i of t) {
|
|
2749
|
+
if (i.kind === "edit") since.steps++;
|
|
2750
|
+
if (i.kind !== "command" || isProbeCommand(i.run.cmd)) continue;
|
|
2751
|
+
since.steps++;
|
|
2752
|
+
const sig = signature(i.run.cmd);
|
|
2753
|
+
if (failed(i.run)) {
|
|
2754
|
+
everFailed.add(sig);
|
|
2755
|
+
since.failures++;
|
|
2756
|
+
since.failing.add(sig);
|
|
2757
|
+
} else if (i.run.exitCode === 0 && (everFailed.has(sig) || /^git commit\b/.test(bare(i.run.cmd)))) {
|
|
2758
|
+
since = { steps: 0, failures: 0, failing: /* @__PURE__ */ new Set() };
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
if (since.steps < SIGNALS.stuckSteps || since.failures < SIGNALS.stuckFailures || since.failing.size < 2) return [];
|
|
2762
|
+
return [{ weight: since.failures >= 2 * SIGNALS.stuckFailures ? 2 : 1, behavior: true, text: `No progress in its last ${since.steps} steps (${since.failures} failures)` }];
|
|
2763
|
+
}
|
|
2764
|
+
var DISCARD_FILES_RE = /^git\s+(?:checkout\s+(?:\S+\s+)?--|restore)\s+(.+)$/;
|
|
2765
|
+
var DISCARD_ALL_RE = /^git\s+(?:reset\s+--hard|stash(?:\s+(?:push|save))?\s*$)/;
|
|
2766
|
+
function discardsOwnWork(cmd, edited) {
|
|
2767
|
+
const c = bare(cmd).split("\n")[0].split(/\s*(?:;|&&|\|\|)\s*/)[0];
|
|
2768
|
+
if (DISCARD_ALL_RE.test(c)) return edited.length > 0;
|
|
2769
|
+
const m = DISCARD_FILES_RE.exec(c);
|
|
2770
|
+
if (!m) return false;
|
|
2771
|
+
const files = m[1].split(/\s+/).filter((x) => x && !x.startsWith("-") && x !== ".");
|
|
2772
|
+
return files.some((f) => edited.some((p) => p === f || p.endsWith(`/${f.replace(/^\.\//, "")}`)));
|
|
2773
|
+
}
|
|
2774
|
+
function undoSignals(t) {
|
|
2775
|
+
const earlier = [];
|
|
2776
|
+
const edited = [];
|
|
2777
|
+
let undone = 0;
|
|
2778
|
+
for (const i of t) {
|
|
2779
|
+
if (i.kind === "command" && i.run.exitCode !== null && !failed(i.run) && discardsOwnWork(i.run.cmd, edited)) undone++;
|
|
2780
|
+
if (i.kind === "edit") edited.push(i.edit.path);
|
|
2781
|
+
if (i.kind !== "edit" || i.edit.before === void 0 || i.edit.after === void 0) continue;
|
|
2782
|
+
const e = i.edit;
|
|
2783
|
+
const swap = earlier.some((p) => p.path === e.path && norm3(p.after ?? "") === norm3(e.before ?? "") && norm3(p.before ?? "") === norm3(e.after ?? "") && norm3(e.before ?? "") !== norm3(e.after ?? ""));
|
|
2784
|
+
if (swap) undone++;
|
|
2785
|
+
earlier.push(e);
|
|
2786
|
+
}
|
|
2787
|
+
if (undone >= 2) return [{ weight: 2, behavior: true, text: `Undid its own changes ${undone} times` }];
|
|
2788
|
+
return undone === 1 ? [{ weight: 1, behavior: true, text: "Undid one of its own changes" }] : [];
|
|
2789
|
+
}
|
|
2790
|
+
function rereadSignals(t, cwd = null) {
|
|
2791
|
+
const reads = /* @__PURE__ */ new Map();
|
|
2792
|
+
const lookups = /* @__PURE__ */ new Map();
|
|
2793
|
+
const shown = /* @__PURE__ */ new Map();
|
|
2794
|
+
for (const i of t) {
|
|
2795
|
+
if (i.kind === "read" && !i.how) {
|
|
2796
|
+
if (!cwd || i.path.startsWith(cwd.endsWith("/") ? cwd : `${cwd}/`)) reads.set(i.path, (reads.get(i.path) ?? 0) + 1);
|
|
2797
|
+
} else if (i.kind === "edit") reads.delete(i.edit.path);
|
|
2798
|
+
else if (i.kind === "command" && isProbeCommand(i.run.cmd)) {
|
|
2799
|
+
const key = norm3(bare(i.run.cmd));
|
|
2800
|
+
lookups.set(key, (lookups.get(key) ?? 0) + 1);
|
|
2801
|
+
if (!shown.has(key)) shown.set(key, bare(i.run.cmd));
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
const [path, n] = [...reads].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
|
|
2805
|
+
if (n >= SIGNALS.rereads) return [{ weight: 1, behavior: true, text: `Re-read ${(0, import_node_path15.basename)(path)} ${n} times without changing it` }];
|
|
2806
|
+
const [cmd, m] = [...lookups].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
|
|
2807
|
+
return m >= SIGNALS.rereads ? [{ weight: 1, behavior: true, text: `Ran \`${oneLine(shown.get(cmd) ?? cmd, 40)}\` ${m} times` }] : [];
|
|
2808
|
+
}
|
|
2809
|
+
function churnSignals(t) {
|
|
2810
|
+
if (!t.some((i) => i.kind === "command" && failed(i.run))) return [];
|
|
2811
|
+
const edits = /* @__PURE__ */ new Map();
|
|
2812
|
+
for (const i of t) if (i.kind === "edit") edits.set(i.edit.path, (edits.get(i.edit.path) ?? 0) + 1);
|
|
2813
|
+
const [path, n] = [...edits].reduce((best, cur) => cur[1] > best[1] ? cur : best, ["", 0]);
|
|
2814
|
+
return n >= SIGNALS.churnEdits ? [{ weight: 1, behavior: true, text: `Edited ${(0, import_node_path15.basename)(path)} ${n} times while things kept failing` }] : [];
|
|
2815
|
+
}
|
|
2816
|
+
var NEGATIVE_RE = /\b(never|don'?t|do not|avoid|stop|no)\b/i;
|
|
2817
|
+
var JS_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
2818
|
+
var PY_MANAGERS = ["pip", "pip3", "uv", "poetry"];
|
|
2819
|
+
var PATH_RE = /`([^`]+)`|((?:[\w.-]+\/)+[\w.-]+|\.env\b|\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|json|md|py|go|rs|yml|yaml|toml|lock|css|html|sh|sql|env))/g;
|
|
2820
|
+
function managerOf(cmd) {
|
|
2821
|
+
const first = bare(cmd).split(/\s+/)[0] ?? "";
|
|
2822
|
+
const m = first === "npx" ? "npm" : first === "python" && /-m pip\b/.test(cmd) ? "pip" : first;
|
|
2823
|
+
return [...JS_MANAGERS, ...PY_MANAGERS].includes(m) ? m : null;
|
|
2824
|
+
}
|
|
2825
|
+
function ruleCheck(rule) {
|
|
2826
|
+
const r = rule.toLowerCase();
|
|
2827
|
+
const negative = NEGATIVE_RE.test(r);
|
|
2828
|
+
const ran = (i) => i.kind === "command" ? `ran \`${oneLine(bare(i.run.cmd), 30)}\`` : null;
|
|
2829
|
+
const named = [...JS_MANAGERS, ...PY_MANAGERS].filter((m) => new RegExp(`\\b${m}\\b`).test(r));
|
|
2830
|
+
if (named.length === 1) {
|
|
2831
|
+
const m = named[0];
|
|
2832
|
+
const family = JS_MANAGERS.includes(m) ? JS_MANAGERS : PY_MANAGERS;
|
|
2833
|
+
const banned = negative ? [m] : /\b(use|only|always|prefer|stick (to|with)|switch to)\b/.test(r) ? family.filter((x) => x !== m) : [];
|
|
2834
|
+
if (banned.length) return (i) => i.kind === "command" && banned.includes(managerOf(i.run.cmd) ?? "") ? ran(i) : null;
|
|
2835
|
+
}
|
|
2836
|
+
if (!negative) return null;
|
|
2837
|
+
if (/force[- ]?push|push\s+(--force|-f\b)/.test(r)) return (i) => i.kind === "command" && /^git\s+push\b.*(\s-f\b|--force)/.test(bare(i.run.cmd)) ? ran(i) : null;
|
|
2838
|
+
if (/\bpush\b/.test(r)) {
|
|
2839
|
+
const toMain = /\b(main|master)\b/.test(r);
|
|
2840
|
+
return (i) => {
|
|
2841
|
+
if (i.kind !== "command") return null;
|
|
2842
|
+
const c = bare(i.run.cmd);
|
|
2843
|
+
if (!/^git\s+push\b/.test(c)) return null;
|
|
2844
|
+
return !toMain || /\b(main|master)\b/.test(c) || /^git\s+push(\s+-\S+)*(\s+origin)?\s*$/.test(c) ? ran(i) : null;
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
if (/\bcommit\b/.test(r)) return (i) => i.kind === "command" && /^git\s+commit\b/.test(bare(i.run.cmd)) ? ran(i) : null;
|
|
2848
|
+
const targets = [...rule.matchAll(PATH_RE)].map((m) => (m[1] ?? m[2]).trim()).filter((x) => x.length > 1);
|
|
2849
|
+
if (/\b(edit|touch|modify|change|delete|remove|rename|overwrite)\b/.test(r) && targets.some((x) => /[./]/.test(x))) {
|
|
2850
|
+
const files = targets.filter((x) => /[./]/.test(x));
|
|
2851
|
+
return (i) => i.kind === "edit" && files.some((f) => i.edit.path.endsWith(f) || i.edit.path.includes(`/${f}`)) ? `edited ${(0, import_node_path15.basename)(i.edit.path)}` : null;
|
|
2852
|
+
}
|
|
2853
|
+
const quoted = [...rule.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim());
|
|
2854
|
+
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;
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
function ruleSignals(t) {
|
|
2858
|
+
const users = t.map((i, at) => ({ i, at })).filter((x) => x.i.kind === "user");
|
|
2859
|
+
const { rules } = findPinnedRules(users.map((u) => u.i.text), []);
|
|
2860
|
+
const broken = [];
|
|
2861
|
+
for (const rule of rules) {
|
|
2862
|
+
const check = ruleCheck(rule.text);
|
|
2863
|
+
const from = users[rule.message]?.at;
|
|
2864
|
+
if (!check || from === void 0) continue;
|
|
2865
|
+
for (const i of t.slice(from + 1)) {
|
|
2866
|
+
const how = check(i);
|
|
2867
|
+
if (how) {
|
|
2868
|
+
broken.push({ rule: rule.text, how });
|
|
2869
|
+
break;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
if (!broken.length) return [];
|
|
2874
|
+
const first = broken[0];
|
|
2875
|
+
const text = broken.length === 1 ? `Broke your rule \u201C${clipQuote(first.rule)}\u201D (${first.how})` : `Broke ${broken.length} of your rules, like \u201C${clipQuote(first.rule)}\u201D`;
|
|
2876
|
+
return [{ weight: 2, behavior: true, text }];
|
|
2877
|
+
}
|
|
2878
|
+
function formatTokens(n) {
|
|
2879
|
+
return n >= 1e6 ? `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M` : `${Math.round(n / 1e3)}K`;
|
|
2880
|
+
}
|
|
2881
|
+
function contextSignals(c) {
|
|
2882
|
+
if (!c) return [];
|
|
2883
|
+
const out = [];
|
|
2884
|
+
if (c.tokens !== null && c.tokens >= SIGNALS.longTokens) {
|
|
2885
|
+
const pct = c.window ? ` (${Math.round(c.tokens / c.window * 100)}% of its window)` : "";
|
|
2886
|
+
const very = c.tokens >= SIGNALS.veryLongTokens;
|
|
2887
|
+
out.push({ weight: very ? 2 : 1, behavior: false, text: `Long context: ${formatTokens(c.tokens)} tokens${pct}` });
|
|
2888
|
+
}
|
|
2889
|
+
if (c.compactions >= 2) out.push({ weight: 2, behavior: false, text: `Compacted ${c.compactions} times, so early details are summaries` });
|
|
2890
|
+
else if (c.compactions === 1) out.push({ weight: 1, behavior: false, text: "Compacted once" });
|
|
2891
|
+
return out;
|
|
2892
|
+
}
|
|
2893
|
+
function allSignals(timeline, context, cwd = null) {
|
|
2894
|
+
const t = timeline.slice(-SIGNALS.recentItems);
|
|
2895
|
+
return [
|
|
2896
|
+
...falseFixSignals(t),
|
|
2897
|
+
...ruleSignals(t),
|
|
2898
|
+
...repeatFailureSignals(t),
|
|
2899
|
+
...undoSignals(t),
|
|
2900
|
+
...pushbackSignals(t),
|
|
2901
|
+
...noProgressSignals(t),
|
|
2902
|
+
...rereadSignals(t, cwd),
|
|
2903
|
+
...flipFlopSignals(t),
|
|
2904
|
+
...churnSignals(t),
|
|
2905
|
+
...contextSignals(context)
|
|
2906
|
+
];
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
// ../../packages/core/src/health/assess.ts
|
|
2910
|
+
var HEALTH = {
|
|
2911
|
+
...SIGNALS,
|
|
2912
|
+
maxReasons: 3
|
|
2913
|
+
};
|
|
2914
|
+
function assessHealth(s, context) {
|
|
2915
|
+
const signals = allSignals(s.timeline, context, s.ref.cwd);
|
|
2916
|
+
const points = signals.reduce((sum, x) => sum + x.weight, 0);
|
|
2917
|
+
const circling = signals.filter((x) => x.behavior).reduce((sum, x) => sum + x.weight, 0);
|
|
2918
|
+
const state2 = points >= 3 && circling >= 2 ? "struggling" : points >= 1 ? "long" : "fresh";
|
|
2919
|
+
const reasons = [...signals].sort((a, b) => b.weight - a.weight || Number(b.behavior) - Number(a.behavior)).slice(0, HEALTH.maxReasons).map((x) => x.text);
|
|
2920
|
+
return { state: state2, reasons, context };
|
|
2115
2921
|
}
|
|
2116
2922
|
|
|
2117
2923
|
// src/menu.ts
|
|
2118
|
-
var
|
|
2924
|
+
var import_node_path17 = require("node:path");
|
|
2119
2925
|
|
|
2120
2926
|
// src/settings.ts
|
|
2121
|
-
var
|
|
2122
|
-
var
|
|
2927
|
+
var import_promises16 = require("node:fs/promises");
|
|
2928
|
+
var import_node_path16 = require("node:path");
|
|
2123
2929
|
var THEMES = ["system", "light", "dark"];
|
|
2124
2930
|
var isTheme = (x) => THEMES.includes(x);
|
|
2931
|
+
var MAX_PINNED = 12;
|
|
2932
|
+
function isPinnedKey(x) {
|
|
2933
|
+
if (typeof x !== "string") return false;
|
|
2934
|
+
const i = x.indexOf(":");
|
|
2935
|
+
return i > 0 && i < x.length - 1 && isTool(x.slice(0, i));
|
|
2936
|
+
}
|
|
2125
2937
|
var DEFAULT_SETTINGS = {
|
|
2126
2938
|
defaultTarget: "codex",
|
|
2127
2939
|
autoContinue: false,
|
|
@@ -2130,17 +2942,19 @@ var DEFAULT_SETTINGS = {
|
|
|
2130
2942
|
terminalApp: "Terminal",
|
|
2131
2943
|
openAtLogin: true,
|
|
2132
2944
|
theme: "system",
|
|
2133
|
-
claudeBridgeAsked: false
|
|
2945
|
+
claudeBridgeAsked: false,
|
|
2946
|
+
pinned: []
|
|
2134
2947
|
};
|
|
2135
2948
|
async function loadSettings(file) {
|
|
2136
2949
|
let raw = {};
|
|
2137
2950
|
try {
|
|
2138
|
-
raw = JSON.parse(await (0,
|
|
2951
|
+
raw = JSON.parse(await (0, import_promises16.readFile)(file, "utf8"));
|
|
2139
2952
|
} catch {
|
|
2140
2953
|
raw = {};
|
|
2141
2954
|
}
|
|
2142
2955
|
const r = typeof raw === "object" && raw !== null ? raw : {};
|
|
2143
|
-
const s = { ...DEFAULT_SETTINGS, modes: { ...DEFAULT_SETTINGS.modes } };
|
|
2956
|
+
const s = { ...DEFAULT_SETTINGS, modes: { ...DEFAULT_SETTINGS.modes }, pinned: [] };
|
|
2957
|
+
if (Array.isArray(r.pinned)) s.pinned = [...new Set(r.pinned.filter(isPinnedKey))].slice(0, MAX_PINNED);
|
|
2144
2958
|
if (isTool(r.defaultTarget)) s.defaultTarget = r.defaultTarget;
|
|
2145
2959
|
if (typeof r.autoContinue === "boolean") s.autoContinue = r.autoContinue;
|
|
2146
2960
|
if (typeof r.openAtLogin === "boolean") s.openAtLogin = r.openAtLogin;
|
|
@@ -2157,12 +2971,23 @@ async function loadSettings(file) {
|
|
|
2157
2971
|
return s;
|
|
2158
2972
|
}
|
|
2159
2973
|
async function saveSettings(file, s) {
|
|
2160
|
-
await (0,
|
|
2161
|
-
await (0,
|
|
2974
|
+
await (0, import_promises16.mkdir)((0, import_node_path16.dirname)(file), { recursive: true });
|
|
2975
|
+
await (0, import_promises16.writeFile)(file, JSON.stringify(s, null, 2) + "\n");
|
|
2162
2976
|
}
|
|
2163
2977
|
|
|
2164
2978
|
// src/state.ts
|
|
2165
|
-
var
|
|
2979
|
+
var MISSED_MS = 3 * 6e4;
|
|
2980
|
+
var DELIVERY_WATCH_MS = 30 * 6e4;
|
|
2981
|
+
var DELIVERY_TEXT = {
|
|
2982
|
+
waiting: (a, mode) => mode === "app" ? `press Enter in ${a} to send it` : `waiting for ${a} to start`,
|
|
2983
|
+
sent: (a) => `${a} has the prompt`,
|
|
2984
|
+
read: (a) => `${a} read it`,
|
|
2985
|
+
missed: (a) => `${a} hasn't picked it up`
|
|
2986
|
+
};
|
|
2987
|
+
function deliveryDetail(d) {
|
|
2988
|
+
const agent = TOOL_LABEL[d.target];
|
|
2989
|
+
return `Handed off to ${agent} \xB7 ${DELIVERY_TEXT[d.state](agent, d.mode)}`;
|
|
2990
|
+
}
|
|
2166
2991
|
var DEFAULT_LIMIT_MS = 60 * 6e4;
|
|
2167
2992
|
var sessionKey = (ref) => `${ref.tool}:${ref.id}`;
|
|
2168
2993
|
function createState() {
|
|
@@ -2173,7 +2998,7 @@ function seedSessions(state2, refs) {
|
|
|
2173
2998
|
const k = sessionKey(ref);
|
|
2174
2999
|
const t = state2.sessions.get(k);
|
|
2175
3000
|
if (!t) {
|
|
2176
|
-
state2.sessions.set(k, { ref, status: "idle", detail: "Idle", updatedAt: ref.lastActivityAt
|
|
3001
|
+
state2.sessions.set(k, { ref, status: "idle", detail: "Idle", updatedAt: ref.lastActivityAt });
|
|
2177
3002
|
continue;
|
|
2178
3003
|
}
|
|
2179
3004
|
if (t.status !== "idle" && ref.lastActivityAt.getTime() > t.updatedAt.getTime() + 6e4) {
|
|
@@ -2189,7 +3014,7 @@ function clock(d) {
|
|
|
2189
3014
|
}
|
|
2190
3015
|
function applyWatchEvent(state2, ev, now) {
|
|
2191
3016
|
const k = sessionKey(ev.ref);
|
|
2192
|
-
const t = state2.sessions.get(k) ?? { ref: ev.ref, status: "idle", detail: "Idle", updatedAt: now
|
|
3017
|
+
const t = state2.sessions.get(k) ?? { ref: ev.ref, status: "idle", detail: "Idle", updatedAt: now };
|
|
2193
3018
|
t.ref = ev.ref;
|
|
2194
3019
|
t.updatedAt = now;
|
|
2195
3020
|
if (ev.event.kind === "halt") {
|
|
@@ -2204,17 +3029,41 @@ function applyWatchEvent(state2, ev, now) {
|
|
|
2204
3029
|
state2.sessions.set(k, t);
|
|
2205
3030
|
return t;
|
|
2206
3031
|
}
|
|
2207
|
-
function markHandedOff(state2, key, target, now) {
|
|
3032
|
+
function markHandedOff(state2, key, target, now, delivery) {
|
|
2208
3033
|
const t = state2.sessions.get(key);
|
|
2209
3034
|
if (!t) return null;
|
|
2210
3035
|
t.status = "handed_off";
|
|
2211
|
-
t.detail = `Handed off to ${TOOL_LABEL[target]}`;
|
|
2212
3036
|
t.updatedAt = now;
|
|
3037
|
+
t.delivery = delivery ? { ...delivery, target, sentAt: now, state: "waiting" } : void 0;
|
|
3038
|
+
t.detail = t.delivery ? deliveryDetail(t.delivery) : `Handed off to ${TOOL_LABEL[target]}`;
|
|
2213
3039
|
return t;
|
|
2214
3040
|
}
|
|
3041
|
+
function pendingDeliveries(state2, now) {
|
|
3042
|
+
return [...state2.sessions.values()].filter(
|
|
3043
|
+
(t) => t.status === "handed_off" && t.delivery && t.delivery.state !== "read" && now.getTime() - t.delivery.since.getTime() < DELIVERY_WATCH_MS
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
function applyDelivery(t, got, now) {
|
|
3047
|
+
const d = t.delivery;
|
|
3048
|
+
if (!d) return { changed: false, becameMissed: false };
|
|
3049
|
+
const before = d.state;
|
|
3050
|
+
if (got) d.state = got.state;
|
|
3051
|
+
else if (d.state !== "sent" && now.getTime() - d.sentAt.getTime() >= MISSED_MS) d.state = "missed";
|
|
3052
|
+
t.detail = deliveryDetail(d);
|
|
3053
|
+
return { changed: d.state !== before, becameMissed: d.state === "missed" && before !== "missed" };
|
|
3054
|
+
}
|
|
3055
|
+
function markResent(t, now) {
|
|
3056
|
+
if (!t.delivery) return;
|
|
3057
|
+
t.delivery.sentAt = now;
|
|
3058
|
+
t.delivery.state = "waiting";
|
|
3059
|
+
t.detail = deliveryDetail(t.delivery);
|
|
3060
|
+
}
|
|
2215
3061
|
var RANK = { limit: 0, warning: 1, idle: 2, handed_off: 2 };
|
|
3062
|
+
function byAttention(a, b) {
|
|
3063
|
+
return RANK[a.status] - RANK[b.status] || b.updatedAt.getTime() - a.updatedAt.getTime();
|
|
3064
|
+
}
|
|
2216
3065
|
function recentSessions(state2, limit = 8) {
|
|
2217
|
-
return [...state2.sessions.values()].sort(
|
|
3066
|
+
return [...state2.sessions.values()].sort(byAttention).slice(0, limit);
|
|
2218
3067
|
}
|
|
2219
3068
|
function latestSession(state2) {
|
|
2220
3069
|
let best = null;
|
|
@@ -2224,15 +3073,6 @@ function latestSession(state2) {
|
|
|
2224
3073
|
}
|
|
2225
3074
|
return best;
|
|
2226
3075
|
}
|
|
2227
|
-
async function existingBrief(t) {
|
|
2228
|
-
if (!t?.briefPath) return null;
|
|
2229
|
-
try {
|
|
2230
|
-
await (0, import_promises14.access)(t.briefPath);
|
|
2231
|
-
return t.briefPath;
|
|
2232
|
-
} catch {
|
|
2233
|
-
return null;
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
3076
|
|
|
2237
3077
|
// src/menu.ts
|
|
2238
3078
|
var HOTKEY = { accelerator: "Alt+H", label: "\u2325H" };
|
|
@@ -2240,7 +3080,7 @@ var DOT = { limit: "\u25CF", warning: "\u25D0", idle: "\u25CB", handed_off: "\u2
|
|
|
2240
3080
|
var MODES = ["app", "terminal"];
|
|
2241
3081
|
var THEME_LABEL = { system: "System", light: "Light", dark: "Dark" };
|
|
2242
3082
|
function sessionTitle(t) {
|
|
2243
|
-
return t.ref.title ?? (t.ref.cwd ? (0,
|
|
3083
|
+
return t.ref.title ?? (t.ref.cwd ? (0, import_node_path17.basename)(t.ref.cwd) : t.ref.id.slice(0, 8));
|
|
2244
3084
|
}
|
|
2245
3085
|
function buildMenuModel(sessions, settings2, available2) {
|
|
2246
3086
|
const nodes = [
|
|
@@ -2257,9 +3097,7 @@ function buildMenuModel(sessions, settings2, available2) {
|
|
|
2257
3097
|
{ type: "separator" },
|
|
2258
3098
|
...TOOLS.filter((x) => x !== t.ref.tool).map(
|
|
2259
3099
|
(x) => ({ label: `Hand off to ${TOOL_LABEL[x]}`, enabled: available2[x][settings2.modes[x]], action: { type: "handoff", key, target: x } })
|
|
2260
|
-
)
|
|
2261
|
-
{ type: "separator" },
|
|
2262
|
-
{ label: "Preview brief", action: { type: "previewBrief", key } }
|
|
3100
|
+
)
|
|
2263
3101
|
]
|
|
2264
3102
|
});
|
|
2265
3103
|
}
|
|
@@ -2293,30 +3131,51 @@ function buildMenuModel(sessions, settings2, available2) {
|
|
|
2293
3131
|
{ label: "Auto-continue when an agent stops", type: "checkbox", checked: settings2.autoContinue, action: { type: "toggleAuto" } },
|
|
2294
3132
|
{ label: "Open at login", type: "checkbox", checked: settings2.openAtLogin, action: { type: "toggleLogin" } },
|
|
2295
3133
|
{ type: "separator" },
|
|
2296
|
-
{ label: `Hotkey ${HOTKEY.label}
|
|
3134
|
+
{ label: `Hotkey ${HOTKEY.label} routes your latest chat`, enabled: false },
|
|
2297
3135
|
{ label: "Quit Harlay", action: { type: "quit" } }
|
|
2298
3136
|
);
|
|
2299
3137
|
return nodes;
|
|
2300
3138
|
}
|
|
2301
3139
|
|
|
2302
|
-
// src/
|
|
2303
|
-
var
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
3140
|
+
// src/health.ts
|
|
3141
|
+
var import_promises17 = require("node:fs/promises");
|
|
3142
|
+
var HealthMonitor = class {
|
|
3143
|
+
context = new ContextTracker();
|
|
3144
|
+
cache = /* @__PURE__ */ new Map();
|
|
3145
|
+
get(key) {
|
|
3146
|
+
return this.cache.get(key)?.health ?? null;
|
|
3147
|
+
}
|
|
3148
|
+
/** Reads the given chats (one at a time, to stay light) and returns those whose state changed. */
|
|
3149
|
+
async update(refs) {
|
|
3150
|
+
const changes = [];
|
|
3151
|
+
for (const ref of refs) {
|
|
3152
|
+
const key = sessionKey(ref);
|
|
3153
|
+
try {
|
|
3154
|
+
const st = await (0, import_promises17.stat)(ref.file);
|
|
3155
|
+
const cached = this.cache.get(key);
|
|
3156
|
+
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) continue;
|
|
3157
|
+
const health2 = assessHealth(await readSessionTail(ref), await this.context.read(ref));
|
|
3158
|
+
this.cache.set(key, { size: st.size, mtimeMs: st.mtimeMs, health: health2 });
|
|
3159
|
+
if (cached?.health.state !== health2.state) changes.push({ ref, before: cached?.health.state ?? null, after: health2 });
|
|
3160
|
+
} catch {
|
|
3161
|
+
this.cache.delete(key);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
return changes;
|
|
3165
|
+
}
|
|
3166
|
+
};
|
|
2308
3167
|
|
|
2309
3168
|
// src/popover.ts
|
|
2310
|
-
var import_node_path16 = require("node:path");
|
|
2311
3169
|
var POPOVER_WIDTH = 392;
|
|
3170
|
+
var PER_AGENT = 3;
|
|
2312
3171
|
function timeAgo(d, now) {
|
|
2313
3172
|
const s = Math.max(0, Math.floor((now.getTime() - d.getTime()) / 1e3));
|
|
2314
3173
|
if (s < 60) return "now";
|
|
2315
3174
|
const m = Math.floor(s / 60);
|
|
2316
|
-
if (m < 60) return `${m}m
|
|
3175
|
+
if (m < 60) return `${m}m`;
|
|
2317
3176
|
const h = Math.floor(m / 60);
|
|
2318
|
-
if (h < 24) return `${h}h
|
|
2319
|
-
return `${Math.floor(h / 24)}d
|
|
3177
|
+
if (h < 24) return `${h}h`;
|
|
3178
|
+
return `${Math.floor(h / 24)}d`;
|
|
2320
3179
|
}
|
|
2321
3180
|
function resetsText(d, now) {
|
|
2322
3181
|
if (!d) return null;
|
|
@@ -2355,28 +3214,47 @@ function buildUsageRows(snapshots, available2, bridge, now) {
|
|
|
2355
3214
|
}
|
|
2356
3215
|
return rows;
|
|
2357
3216
|
}
|
|
2358
|
-
function
|
|
3217
|
+
function shownByAgent(sessions, settings2) {
|
|
3218
|
+
const pinned = new Set(settings2.pinned);
|
|
3219
|
+
const out = {};
|
|
3220
|
+
for (const tool of TOOLS) {
|
|
3221
|
+
const mine = sessions.filter((t) => t.ref.tool === tool).sort(byAttention);
|
|
3222
|
+
out[tool] = [...mine.slice(0, PER_AGENT), ...mine.slice(PER_AGENT).filter((t) => pinned.has(sessionKey(t.ref)))];
|
|
3223
|
+
}
|
|
3224
|
+
return out;
|
|
3225
|
+
}
|
|
3226
|
+
function buildPopoverModel(sessions, settings2, available2, busy, now, usage2 = [], claudeBridge2 = "off", askClaudeBridge = false, icons = {}, health2 = () => null) {
|
|
3227
|
+
const pinned = new Set(settings2.pinned);
|
|
2359
3228
|
const order = [settings2.defaultTarget, ...TOOLS.filter((t) => t !== settings2.defaultTarget)];
|
|
3229
|
+
const toSession = (t) => {
|
|
3230
|
+
const key = sessionKey(t.ref);
|
|
3231
|
+
return {
|
|
3232
|
+
key,
|
|
3233
|
+
title: sessionTitle(t),
|
|
3234
|
+
status: t.status,
|
|
3235
|
+
detail: t.detail,
|
|
3236
|
+
cwd: t.ref.cwd ?? null,
|
|
3237
|
+
ago: timeAgo(t.updatedAt, now),
|
|
3238
|
+
busy: busy.has(key),
|
|
3239
|
+
pinned: pinned.has(key),
|
|
3240
|
+
targets: order.filter((x) => x !== t.ref.tool && available2[x][settings2.modes[x]]).map((x) => ({ tool: x, label: TOOL_LABEL[x], mode: settings2.modes[x] })),
|
|
3241
|
+
health: health2(key),
|
|
3242
|
+
canStartFresh: available2[t.ref.tool][settings2.modes[t.ref.tool]],
|
|
3243
|
+
delivery: t.status === "handed_off" ? t.delivery?.state ?? null : null
|
|
3244
|
+
};
|
|
3245
|
+
};
|
|
3246
|
+
const shown = shownByAgent(sessions, settings2);
|
|
3247
|
+
const groups = [];
|
|
3248
|
+
for (const tool of TOOLS) {
|
|
3249
|
+
if (!shown[tool].length && !available2[tool].app && !available2[tool].terminal) continue;
|
|
3250
|
+
groups.push({ tool, label: TOOL_LABEL[tool], sessions: shown[tool].map(toSession) });
|
|
3251
|
+
}
|
|
2360
3252
|
return {
|
|
2361
|
-
|
|
2362
|
-
const key = sessionKey(t.ref);
|
|
2363
|
-
return {
|
|
2364
|
-
key,
|
|
2365
|
-
title: sessionTitle(t),
|
|
2366
|
-
toolLabel: TOOL_LABEL[t.ref.tool],
|
|
2367
|
-
status: t.status,
|
|
2368
|
-
detail: t.detail,
|
|
2369
|
-
cwd: t.ref.cwd ?? null,
|
|
2370
|
-
repo: t.ref.cwd ? (0, import_node_path16.basename)(t.ref.cwd) : null,
|
|
2371
|
-
ago: timeAgo(t.updatedAt, now),
|
|
2372
|
-
busy: busy.has(key),
|
|
2373
|
-
targets: order.filter((x) => x !== t.ref.tool).map((x) => ({ tool: x, label: TOOL_LABEL[x], enabled: available2[x][settings2.modes[x]], isDefault: x === settings2.defaultTarget }))
|
|
2374
|
-
};
|
|
2375
|
-
}),
|
|
3253
|
+
groups,
|
|
2376
3254
|
usage: usage2,
|
|
3255
|
+
icons,
|
|
2377
3256
|
askClaudeBridge,
|
|
2378
|
-
settings: {
|
|
2379
|
-
tools: TOOLS.map((x) => ({ tool: x, label: TOOL_LABEL[x], mode: settings2.modes[x], ...available2[x] })),
|
|
3257
|
+
settings: { autoContinue: settings2.autoContinue, openAtLogin: settings2.openAtLogin, theme: settings2.theme, claudeBridge: claudeBridge2 },
|
|
2380
3258
|
hotkey: HOTKEY.label
|
|
2381
3259
|
};
|
|
2382
3260
|
}
|
|
@@ -2386,9 +3264,10 @@ function isMenuAction(x) {
|
|
|
2386
3264
|
switch (a.type) {
|
|
2387
3265
|
case "handoff":
|
|
2388
3266
|
return typeof a.key === "string" && isTool(a.target);
|
|
2389
|
-
case "
|
|
2390
|
-
case "
|
|
2391
|
-
case "
|
|
3267
|
+
case "unpinChat":
|
|
3268
|
+
case "freshStart":
|
|
3269
|
+
case "resendHandoff":
|
|
3270
|
+
case "copyPrompt":
|
|
2392
3271
|
return typeof a.key === "string";
|
|
2393
3272
|
case "setTheme":
|
|
2394
3273
|
return isTheme(a.theme);
|
|
@@ -2396,10 +3275,10 @@ function isMenuAction(x) {
|
|
|
2396
3275
|
return isTool(a.target);
|
|
2397
3276
|
case "setMode":
|
|
2398
3277
|
return isTool(a.target) && (a.mode === "app" || a.mode === "terminal");
|
|
2399
|
-
case "toggleAuto":
|
|
2400
|
-
case "toggleLogin":
|
|
2401
3278
|
case "answerClaudeBridge":
|
|
2402
3279
|
return typeof a.allow === "boolean";
|
|
3280
|
+
case "toggleAuto":
|
|
3281
|
+
case "toggleLogin":
|
|
2403
3282
|
case "toggleClaudeBridge":
|
|
2404
3283
|
case "openCursorUsage":
|
|
2405
3284
|
case "quit":
|
|
@@ -2408,6 +3287,12 @@ function isMenuAction(x) {
|
|
|
2408
3287
|
return false;
|
|
2409
3288
|
}
|
|
2410
3289
|
}
|
|
3290
|
+
function panelPosition(icon, iconArea, userArea, width) {
|
|
3291
|
+
const same = iconArea.x === userArea.x && iconArea.y === userArea.y && iconArea.width === userArea.width && iconArea.height === userArea.height;
|
|
3292
|
+
if (!same) return { x: userArea.x + userArea.width - width - 8, y: userArea.y };
|
|
3293
|
+
const x = Math.min(Math.max(icon.x + icon.width / 2 - width / 2, userArea.x), userArea.x + userArea.width - width);
|
|
3294
|
+
return { x, y: icon.y + icon.height };
|
|
3295
|
+
}
|
|
2411
3296
|
|
|
2412
3297
|
// src/targets.ts
|
|
2413
3298
|
function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}, now = /* @__PURE__ */ new Date()) {
|
|
@@ -2417,9 +3302,9 @@ function pickTargets(source, defaultTarget, available2, modes, limitedUntil = {}
|
|
|
2417
3302
|
|
|
2418
3303
|
// src/usage.ts
|
|
2419
3304
|
var import_node_events2 = require("node:events");
|
|
2420
|
-
var
|
|
2421
|
-
var
|
|
2422
|
-
var
|
|
3305
|
+
var import_node_fs4 = require("node:fs");
|
|
3306
|
+
var import_promises18 = require("node:fs/promises");
|
|
3307
|
+
var import_node_path18 = require("node:path");
|
|
2423
3308
|
var CODEX_STALE_MS = 30 * 6e4;
|
|
2424
3309
|
var CODEX_QUERY_GAP_MS = 30 * 6e4;
|
|
2425
3310
|
var DEBOUNCE_MS = 2e3;
|
|
@@ -2450,22 +3335,22 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
2450
3335
|
return claudeStatusFile(this.opts.dataDir);
|
|
2451
3336
|
}
|
|
2452
3337
|
get samplesFile() {
|
|
2453
|
-
return (0,
|
|
3338
|
+
return (0, import_node_path18.join)(this.opts.dataDir, "usage-calibration.json");
|
|
2454
3339
|
}
|
|
2455
3340
|
current() {
|
|
2456
3341
|
return this.snapshots;
|
|
2457
3342
|
}
|
|
2458
3343
|
async start() {
|
|
2459
|
-
await (0,
|
|
3344
|
+
await (0, import_promises18.mkdir)(this.opts.dataDir, { recursive: true });
|
|
2460
3345
|
try {
|
|
2461
|
-
const raw = JSON.parse(await (0,
|
|
3346
|
+
const raw = JSON.parse(await (0, import_promises18.readFile)(this.samplesFile, "utf8"));
|
|
2462
3347
|
if (Array.isArray(raw)) this.samples = raw.filter((s) => typeof s?.capacity === "number" && typeof s?.at === "number" && typeof s?.kind === "string");
|
|
2463
3348
|
} catch {
|
|
2464
3349
|
this.samples = [];
|
|
2465
3350
|
}
|
|
2466
3351
|
const watchDir = (dir, tool, recursive) => {
|
|
2467
3352
|
try {
|
|
2468
|
-
const w = (0,
|
|
3353
|
+
const w = (0, import_node_fs4.watch)(dir, { recursive }, (_e, name) => {
|
|
2469
3354
|
if (!name || String(name).endsWith(".jsonl") || String(name).startsWith("claude-status")) this.schedule(tool);
|
|
2470
3355
|
});
|
|
2471
3356
|
w.on("error", () => void 0);
|
|
@@ -2473,8 +3358,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
2473
3358
|
} catch {
|
|
2474
3359
|
}
|
|
2475
3360
|
};
|
|
2476
|
-
watchDir((0,
|
|
2477
|
-
watchDir((0,
|
|
3361
|
+
watchDir((0, import_node_path18.join)(this.opts.home, ".codex", "sessions"), "codex", true);
|
|
3362
|
+
watchDir((0, import_node_path18.join)(this.opts.home, ".claude", "projects"), "claude-code", true);
|
|
2478
3363
|
watchDir(this.opts.dataDir, "claude-code", false);
|
|
2479
3364
|
await Promise.all([this.update("codex"), this.update("claude-code")]);
|
|
2480
3365
|
void this.topUpCodex();
|
|
@@ -2531,8 +3416,8 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
2531
3416
|
await this.claudeLog.refresh(now);
|
|
2532
3417
|
let exact = null;
|
|
2533
3418
|
try {
|
|
2534
|
-
const s = await (0,
|
|
2535
|
-
exact = parseClaudeStatusline(JSON.parse(await (0,
|
|
3419
|
+
const s = await (0, import_promises18.stat)(this.statusFile);
|
|
3420
|
+
exact = parseClaudeStatusline(JSON.parse(await (0, import_promises18.readFile)(this.statusFile, "utf8")), s.mtimeMs);
|
|
2536
3421
|
} catch {
|
|
2537
3422
|
exact = null;
|
|
2538
3423
|
}
|
|
@@ -2541,7 +3426,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
2541
3426
|
const merged = mergeSamples(this.samples, capacitySamples(entries, hits, exact), now);
|
|
2542
3427
|
if (JSON.stringify(merged) !== JSON.stringify(this.samples)) {
|
|
2543
3428
|
this.samples = merged;
|
|
2544
|
-
await (0,
|
|
3429
|
+
await (0, import_promises18.writeFile)(this.samplesFile, JSON.stringify(merged)).catch(() => void 0);
|
|
2545
3430
|
}
|
|
2546
3431
|
return claudeSnapshot({ entries, hits, exact, samples: this.samples, now });
|
|
2547
3432
|
}
|
|
@@ -2575,7 +3460,7 @@ var UsageMonitor = class extends import_node_events2.EventEmitter {
|
|
|
2575
3460
|
// src/main.ts
|
|
2576
3461
|
var MAX_LIVE_NOTIFICATIONS = 50;
|
|
2577
3462
|
var AUTO_HANDOFF_COOLDOWN_MS = 2 * 6e4;
|
|
2578
|
-
var home = process.env.HARLAY_HOME ?? (0,
|
|
3463
|
+
var home = process.env.HARLAY_HOME ?? (0, import_node_os3.homedir)();
|
|
2579
3464
|
var readers = createReaders({ home });
|
|
2580
3465
|
var state = createState();
|
|
2581
3466
|
var liveNotifications = /* @__PURE__ */ new Set();
|
|
@@ -2590,7 +3475,8 @@ var watcher = null;
|
|
|
2590
3475
|
var usage = null;
|
|
2591
3476
|
var claudeBridge = "off";
|
|
2592
3477
|
var lastAutoHandoffAt = 0;
|
|
2593
|
-
var
|
|
3478
|
+
var agentIcons = {};
|
|
3479
|
+
var health = new HealthMonitor();
|
|
2594
3480
|
var errorMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
2595
3481
|
function toElectron(nodes) {
|
|
2596
3482
|
return nodes.map((n) => {
|
|
@@ -2612,7 +3498,8 @@ function refreshUi() {
|
|
|
2612
3498
|
const rows = buildUsageRows(usage?.current() ?? {}, available, claudeBridge, now);
|
|
2613
3499
|
const claudeInstalled = available["claude-code"].app || available["claude-code"].terminal;
|
|
2614
3500
|
const ask = !settings.claudeBridgeAsked && claudeBridge === "off" && claudeInstalled;
|
|
2615
|
-
|
|
3501
|
+
const model = buildPopoverModel([...state.sessions.values()], settings, available, handoffsInFlight, now, rows, claudeBridge, ask, agentIcons, (k) => health.get(k));
|
|
3502
|
+
popover.webContents.send("harlay:model", model);
|
|
2616
3503
|
}
|
|
2617
3504
|
function nativeMenu() {
|
|
2618
3505
|
return import_electron.Menu.buildFromTemplate(toElectron(buildMenuModel(recentSessions(state, 8), settings, available)));
|
|
@@ -2639,52 +3526,65 @@ async function handoff(ref, target) {
|
|
|
2639
3526
|
if (handoffsInFlight.has(key)) return;
|
|
2640
3527
|
handoffsInFlight.add(key);
|
|
2641
3528
|
refreshUi();
|
|
3529
|
+
const since = /* @__PURE__ */ new Date();
|
|
2642
3530
|
try {
|
|
2643
3531
|
const res = await runHandoff({
|
|
2644
3532
|
session: ref,
|
|
2645
3533
|
target,
|
|
2646
3534
|
mode: settings.modes[target],
|
|
2647
3535
|
readers,
|
|
3536
|
+
store: handoffStore(home),
|
|
2648
3537
|
terminalApp: settings.terminalApp,
|
|
2649
3538
|
resolve: defaultResolveOptions()
|
|
2650
3539
|
});
|
|
2651
|
-
const
|
|
2652
|
-
|
|
2653
|
-
|
|
3540
|
+
const mode = settings.modes[target];
|
|
3541
|
+
const { id, path, prompt } = res.written;
|
|
3542
|
+
markHandedOff(state, key, target, /* @__PURE__ */ new Date(), { id, path, prompt, mode, cwd: res.data.workdir, since });
|
|
3543
|
+
closeResolvedAlert();
|
|
3544
|
+
notify(`Continuing in ${TOOL_LABEL[target]}`, mode === "app" ? "The prompt is typed in: press Enter there to send it." : "It picks up where this chat left off.");
|
|
2654
3545
|
} catch (e) {
|
|
2655
|
-
const tracked = state.sessions.get(key);
|
|
2656
|
-
if (e instanceof LaunchError && tracked) tracked.briefPath = e.written.latestPath;
|
|
2657
3546
|
notify("Harlay could not hand off", errorMessage(e));
|
|
2658
3547
|
} finally {
|
|
2659
3548
|
handoffsInFlight.delete(key);
|
|
2660
3549
|
}
|
|
2661
3550
|
refreshUi();
|
|
2662
3551
|
}
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
if (popover && !popover.isDestroyed()) popover.webContents.send("harlay:brief", { key, title, ...brief });
|
|
2669
|
-
};
|
|
2670
|
-
if (!popover?.isVisible()) revealPopover();
|
|
2671
|
-
send({ loading: true });
|
|
3552
|
+
var DELIVERY_CHECK_MS = 3e3;
|
|
3553
|
+
var checkingDeliveries = false;
|
|
3554
|
+
async function checkDeliveries() {
|
|
3555
|
+
if (checkingDeliveries) return;
|
|
3556
|
+
checkingDeliveries = true;
|
|
2672
3557
|
try {
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
const
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3558
|
+
for (const t of pendingDeliveries(state, /* @__PURE__ */ new Date())) {
|
|
3559
|
+
const d = t.delivery;
|
|
3560
|
+
const got = await checkDelivery(readers, { id: d.id, path: d.path, target: d.target, since: d.since }).catch(() => null);
|
|
3561
|
+
const { changed, becameMissed } = applyDelivery(t, got, /* @__PURE__ */ new Date());
|
|
3562
|
+
if (changed) refreshUi();
|
|
3563
|
+
if (becameMissed) {
|
|
3564
|
+
const n = new import_electron.Notification({
|
|
3565
|
+
title: `${TOOL_LABEL[d.target]} hasn't picked up the handoff`,
|
|
3566
|
+
body: d.mode === "app" ? `Press Enter where the prompt is typed in ${TOOL_LABEL[d.target]}, or open Harlay to send it again.` : "Open Harlay to send it again."
|
|
3567
|
+
});
|
|
3568
|
+
keep(n);
|
|
3569
|
+
n.on("click", () => revealPopover());
|
|
3570
|
+
n.show();
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
} finally {
|
|
3574
|
+
checkingDeliveries = false;
|
|
2683
3575
|
}
|
|
2684
3576
|
}
|
|
2685
|
-
async function
|
|
2686
|
-
const
|
|
2687
|
-
|
|
3577
|
+
async function resendHandoff(key) {
|
|
3578
|
+
const t = state.sessions.get(key);
|
|
3579
|
+
const d = t?.delivery;
|
|
3580
|
+
if (!t || !d) return;
|
|
3581
|
+
try {
|
|
3582
|
+
await launchHandoff({ target: d.target, mode: d.mode, cwd: d.cwd, id: d.id, prompt: d.prompt, terminalApp: settings.terminalApp, resolve: defaultResolveOptions() });
|
|
3583
|
+
markResent(t, /* @__PURE__ */ new Date());
|
|
3584
|
+
} catch (e) {
|
|
3585
|
+
notify("Harlay could not open it again", `${errorMessage(e)}. The prompt is: ${d.prompt}`);
|
|
3586
|
+
}
|
|
3587
|
+
refreshUi();
|
|
2688
3588
|
}
|
|
2689
3589
|
async function persist() {
|
|
2690
3590
|
await saveSettings(settingsFile, settings);
|
|
@@ -2698,19 +3598,6 @@ async function onAction(a) {
|
|
|
2698
3598
|
if (t) await handoff(t.ref, a.target);
|
|
2699
3599
|
break;
|
|
2700
3600
|
}
|
|
2701
|
-
case "previewBrief": {
|
|
2702
|
-
const t = state.sessions.get(a.key);
|
|
2703
|
-
if (t) await previewBrief(t.ref);
|
|
2704
|
-
break;
|
|
2705
|
-
}
|
|
2706
|
-
case "copyBrief":
|
|
2707
|
-
await copyBrief(a.key);
|
|
2708
|
-
break;
|
|
2709
|
-
case "openBrief": {
|
|
2710
|
-
const path = previewedBriefs.get(a.key);
|
|
2711
|
-
if (path) await import_electron.shell.openPath(path);
|
|
2712
|
-
break;
|
|
2713
|
-
}
|
|
2714
3601
|
case "setTheme":
|
|
2715
3602
|
settings.theme = a.theme;
|
|
2716
3603
|
import_electron.nativeTheme.themeSource = a.theme;
|
|
@@ -2751,6 +3638,23 @@ async function onAction(a) {
|
|
|
2751
3638
|
case "openCursorUsage":
|
|
2752
3639
|
await import_electron.shell.openExternal("https://cursor.com/dashboard?tab=usage");
|
|
2753
3640
|
break;
|
|
3641
|
+
case "unpinChat":
|
|
3642
|
+
settings.pinned = settings.pinned.filter((k) => k !== a.key);
|
|
3643
|
+
await persist();
|
|
3644
|
+
break;
|
|
3645
|
+
case "freshStart": {
|
|
3646
|
+
const t = state.sessions.get(a.key);
|
|
3647
|
+
if (t) await startFresh(t.ref);
|
|
3648
|
+
break;
|
|
3649
|
+
}
|
|
3650
|
+
case "resendHandoff":
|
|
3651
|
+
await resendHandoff(a.key);
|
|
3652
|
+
break;
|
|
3653
|
+
case "copyPrompt": {
|
|
3654
|
+
const d = state.sessions.get(a.key)?.delivery;
|
|
3655
|
+
if (d) import_electron.clipboard.writeText(d.prompt);
|
|
3656
|
+
break;
|
|
3657
|
+
}
|
|
2754
3658
|
case "quit":
|
|
2755
3659
|
import_electron.app.quit();
|
|
2756
3660
|
break;
|
|
@@ -2761,32 +3665,19 @@ async function onAction(a) {
|
|
|
2761
3665
|
}
|
|
2762
3666
|
}
|
|
2763
3667
|
function onWatchEvent(ev) {
|
|
3668
|
+
debug("watch event", ev.ref.tool, ev.ref.id, ev.event.kind);
|
|
2764
3669
|
const now = /* @__PURE__ */ new Date();
|
|
2765
3670
|
const tracked = applyWatchEvent(state, ev, now);
|
|
2766
3671
|
refreshUi();
|
|
2767
3672
|
const targets = pickTargets(ev.ref.tool, settings.defaultTarget, available, settings.modes, state.limitedUntil, now);
|
|
2768
3673
|
const first = targets[0];
|
|
2769
|
-
const name = sessionTitle(tracked);
|
|
2770
3674
|
if (ev.event.kind === "halt") {
|
|
2771
3675
|
if (settings.autoContinue && first && now.getTime() - lastAutoHandoffAt >= AUTO_HANDOFF_COOLDOWN_MS) {
|
|
2772
3676
|
lastAutoHandoffAt = now.getTime();
|
|
2773
3677
|
void handoff(ev.ref, first);
|
|
2774
3678
|
return;
|
|
2775
3679
|
}
|
|
2776
|
-
|
|
2777
|
-
title: `${TOOL_LABEL[ev.ref.tool]} stopped: ${name}`,
|
|
2778
|
-
body: haltNotificationBody(tracked, first),
|
|
2779
|
-
actions: targets.map((t) => ({ type: "button", text: `Continue in ${TOOL_LABEL[t]}` }))
|
|
2780
|
-
});
|
|
2781
|
-
keep(n);
|
|
2782
|
-
n.on("action", (_e, index) => {
|
|
2783
|
-
const t = targets[index];
|
|
2784
|
-
if (t) void handoff(ev.ref, t);
|
|
2785
|
-
});
|
|
2786
|
-
n.on("click", () => {
|
|
2787
|
-
if (first) void handoff(ev.ref, first);
|
|
2788
|
-
});
|
|
2789
|
-
n.show();
|
|
3680
|
+
alertPopover(sessionKey(ev.ref));
|
|
2790
3681
|
return;
|
|
2791
3682
|
}
|
|
2792
3683
|
}
|
|
@@ -2804,27 +3695,78 @@ function dataDir() {
|
|
|
2804
3695
|
return import_electron.app.getPath("userData");
|
|
2805
3696
|
}
|
|
2806
3697
|
function onHotkey() {
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
notify("Harlay", "No recent agent session to hand off.");
|
|
3698
|
+
if (popover?.isVisible()) {
|
|
3699
|
+
hidePopover();
|
|
2810
3700
|
return;
|
|
2811
3701
|
}
|
|
2812
|
-
const
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
3702
|
+
const latest = latestSession(state);
|
|
3703
|
+
revealPopover(latest ? sessionKey(latest.ref) : null, "user");
|
|
3704
|
+
}
|
|
3705
|
+
async function trackPinned() {
|
|
3706
|
+
const refs = await Promise.all(
|
|
3707
|
+
settings.pinned.filter((k) => !state.sessions.has(k)).map(async (k) => {
|
|
3708
|
+
const { tool, id } = parseSessionSpec(k);
|
|
3709
|
+
return findSessionById(readers, tool, id).catch(() => null);
|
|
3710
|
+
})
|
|
3711
|
+
);
|
|
3712
|
+
seedSessions(state, refs.filter((r) => r !== null));
|
|
3713
|
+
}
|
|
3714
|
+
async function addChat(tool, raw) {
|
|
3715
|
+
const id = (0, import_node_path19.basename)(raw.trim()).replace(/\.jsonl?$/, "");
|
|
3716
|
+
if (!/^[\w.-]{4,200}$/.test(id)) return { ok: false, error: "That doesn\u2019t look like a chat ID." };
|
|
3717
|
+
const ref = await findSessionById(readers, tool, id).catch(() => null);
|
|
3718
|
+
if (!ref) return { ok: false, error: `No ${TOOL_LABEL[tool]} chat with that ID on this Mac.` };
|
|
3719
|
+
const key = sessionKey(ref);
|
|
3720
|
+
if (!settings.pinned.includes(key)) {
|
|
3721
|
+
if (settings.pinned.length >= MAX_PINNED) return { ok: false, error: `You can add up to ${MAX_PINNED} chats. Remove one first.` };
|
|
3722
|
+
settings.pinned.push(key);
|
|
3723
|
+
await saveSettings(settingsFile, settings);
|
|
3724
|
+
}
|
|
3725
|
+
seedSessions(state, [ref]);
|
|
3726
|
+
refreshUi();
|
|
3727
|
+
return { ok: true };
|
|
3728
|
+
}
|
|
3729
|
+
async function loadAgentIcons() {
|
|
3730
|
+
const dir = defaultResolveOptions().applicationsDir;
|
|
3731
|
+
for (const tool of TOOLS) {
|
|
3732
|
+
for (const bundle of APP_BUNDLES[tool]) {
|
|
3733
|
+
const path = (0, import_node_path19.join)(dir, bundle);
|
|
3734
|
+
try {
|
|
3735
|
+
await (0, import_promises19.access)(path);
|
|
3736
|
+
const thumb = await import_electron.nativeImage.createThumbnailFromPath(path, { width: 64, height: 64 });
|
|
3737
|
+
agentIcons[tool] = thumb.resize({ width: 36, height: 36, quality: "best" }).toDataURL();
|
|
3738
|
+
break;
|
|
3739
|
+
} catch {
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
2816
3742
|
}
|
|
2817
|
-
void handoff(latest.ref, target);
|
|
2818
3743
|
}
|
|
2819
3744
|
async function refreshSessions() {
|
|
2820
3745
|
try {
|
|
2821
|
-
seedSessions(state, await
|
|
3746
|
+
seedSessions(state, (await Promise.all(readers.map((r) => r.listSessions(PER_AGENT)))).flat());
|
|
3747
|
+
await trackPinned();
|
|
2822
3748
|
available = await availableTargets(defaultResolveOptions());
|
|
2823
3749
|
refreshUi();
|
|
3750
|
+
closeResolvedAlert();
|
|
3751
|
+
await refreshHealth();
|
|
2824
3752
|
} catch (e) {
|
|
2825
3753
|
console.error("[harlay] refresh failed", e);
|
|
2826
3754
|
}
|
|
2827
3755
|
}
|
|
3756
|
+
async function refreshHealth() {
|
|
3757
|
+
const shown = Object.values(shownByAgent([...state.sessions.values()], settings)).flat();
|
|
3758
|
+
const changes = await health.update(shown.map((t) => t.ref));
|
|
3759
|
+
if (changes.length) refreshUi();
|
|
3760
|
+
}
|
|
3761
|
+
async function startFresh(ref) {
|
|
3762
|
+
const tool = ref.tool;
|
|
3763
|
+
const mode = settings.modes[tool];
|
|
3764
|
+
if (!available[tool][mode]) {
|
|
3765
|
+
notify("Harlay", `${TOOL_LABEL[tool]} isn't installed for ${mode === "app" ? "App" : "Terminal"} mode, so Harlay can't open a new chat.`);
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
await handoff(ref, tool);
|
|
3769
|
+
}
|
|
2828
3770
|
function createPopover() {
|
|
2829
3771
|
const win = new import_electron.BrowserWindow({
|
|
2830
3772
|
width: POPOVER_WIDTH,
|
|
@@ -2840,34 +3782,62 @@ function createPopover() {
|
|
|
2840
3782
|
fullscreenable: false,
|
|
2841
3783
|
skipTaskbar: true,
|
|
2842
3784
|
alwaysOnTop: true,
|
|
2843
|
-
|
|
3785
|
+
// The limit panel opens without focus (see alertPopover): the first click on it must still press the button.
|
|
3786
|
+
acceptFirstMouse: true,
|
|
3787
|
+
// backgroundThrottling off: the limit panel opens while another app is in front, and a throttled page would leave
|
|
3788
|
+
// its fade-in (and so the whole panel) invisible.
|
|
3789
|
+
webPreferences: { preload: (0, import_node_path19.join)(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false }
|
|
2844
3790
|
});
|
|
2845
3791
|
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
2846
3792
|
win.on("blur", hidePopover);
|
|
2847
3793
|
win.webContents.on("will-navigate", (e) => e.preventDefault());
|
|
2848
3794
|
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
2849
|
-
void win.loadFile((0,
|
|
3795
|
+
void win.loadFile((0, import_node_path19.join)(__dirname, "..", "renderer", "popover.html"));
|
|
2850
3796
|
return win;
|
|
2851
3797
|
}
|
|
2852
|
-
|
|
3798
|
+
var debug = process.env.HARLAY_DEBUG ? (...a) => console.log("[harlay]", ...a) : () => void 0;
|
|
3799
|
+
var alertKey = null;
|
|
3800
|
+
function alertPopover(key) {
|
|
3801
|
+
debug("limit panel for", key, { popover: !!popover, tray: tray?.getBounds(), visible: popover?.isVisible(), focused: popover?.isFocused() });
|
|
3802
|
+
if (!popover || !tray) return;
|
|
3803
|
+
if (popover.isVisible() && popover.isFocused()) return;
|
|
3804
|
+
alertKey = key;
|
|
3805
|
+
const show = () => showPopover(key, false, "user");
|
|
3806
|
+
if (popover.webContents.isLoading()) popover.webContents.once("did-finish-load", show);
|
|
3807
|
+
else show();
|
|
3808
|
+
}
|
|
3809
|
+
function closeResolvedAlert() {
|
|
3810
|
+
if (!alertKey || !popover?.isVisible() || popover.isFocused()) return;
|
|
3811
|
+
if (state.sessions.get(alertKey)?.status === "limit") return;
|
|
3812
|
+
alertKey = null;
|
|
3813
|
+
hidePopover();
|
|
3814
|
+
}
|
|
3815
|
+
function showPopover(route = null, focus = true, where = "icon") {
|
|
2853
3816
|
if (!popover || !tray) return;
|
|
2854
3817
|
const t = tray.getBounds();
|
|
2855
|
-
const
|
|
2856
|
-
const
|
|
2857
|
-
|
|
3818
|
+
const iconArea = import_electron.screen.getDisplayNearestPoint({ x: t.x, y: t.y }).workArea;
|
|
3819
|
+
const userArea = where === "user" ? import_electron.screen.getDisplayNearestPoint(import_electron.screen.getCursorScreenPoint()).workArea : iconArea;
|
|
3820
|
+
const { x, y } = panelPosition(t, iconArea, userArea, POPOVER_WIDTH);
|
|
3821
|
+
popover.setPosition(Math.round(x), Math.round(y));
|
|
2858
3822
|
refreshUi();
|
|
2859
3823
|
void usage?.topUpCodex();
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
3824
|
+
void refreshHealth();
|
|
3825
|
+
if (focus) {
|
|
3826
|
+
alertKey = null;
|
|
3827
|
+
popover.show();
|
|
3828
|
+
popover.focus();
|
|
3829
|
+
} else popover.showInactive();
|
|
3830
|
+
debug("panel shown", { focus, bounds: popover.getBounds(), visible: popover.isVisible() });
|
|
3831
|
+
popover.webContents.send("harlay:shown", { route, alert: !focus });
|
|
3832
|
+
}
|
|
3833
|
+
function revealPopover(route = null, where = "icon") {
|
|
2865
3834
|
if (!popover || !tray) return;
|
|
2866
3835
|
import_electron.app.focus({ steal: true });
|
|
2867
|
-
if (popover.webContents.isLoading()) popover.webContents.once("did-finish-load", showPopover);
|
|
2868
|
-
else showPopover();
|
|
3836
|
+
if (popover.webContents.isLoading()) popover.webContents.once("did-finish-load", () => showPopover(route, true, where));
|
|
3837
|
+
else showPopover(route, true, where);
|
|
2869
3838
|
}
|
|
2870
3839
|
function hidePopover() {
|
|
3840
|
+
alertKey = null;
|
|
2871
3841
|
if (!popover?.isVisible()) return;
|
|
2872
3842
|
popover.hide();
|
|
2873
3843
|
popoverHiddenAt = Date.now();
|
|
@@ -2893,9 +3863,14 @@ function listenToPopover() {
|
|
|
2893
3863
|
const area = import_electron.screen.getDisplayMatching(popover.getBounds()).workArea;
|
|
2894
3864
|
popover.setSize(POPOVER_WIDTH, Math.round(Math.min(Math.max(height, 120), area.height - 24)));
|
|
2895
3865
|
});
|
|
3866
|
+
import_electron.ipcMain.handle("harlay:addChat", async (e, req) => {
|
|
3867
|
+
const r = typeof req === "object" && req !== null ? req : {};
|
|
3868
|
+
if (!fromPopover(e) || !isTool(r.tool) || typeof r.id !== "string") return { ok: false, error: "Paste a chat ID." };
|
|
3869
|
+
return addChat(r.tool, r.id);
|
|
3870
|
+
});
|
|
2896
3871
|
import_electron.ipcMain.on("harlay:action", (e, action) => {
|
|
2897
3872
|
if (!fromPopover(e) || !isMenuAction(action)) return;
|
|
2898
|
-
if (action.type === "handoff" || action.type === "
|
|
3873
|
+
if (action.type === "handoff" || action.type === "freshStart" || action.type === "resendHandoff" || action.type === "openCursorUsage") hidePopover();
|
|
2899
3874
|
void onAction(action);
|
|
2900
3875
|
});
|
|
2901
3876
|
}
|
|
@@ -2904,16 +3879,20 @@ if (!import_electron.app.requestSingleInstanceLock()) {
|
|
|
2904
3879
|
} else {
|
|
2905
3880
|
import_electron.app.on("window-all-closed", () => {
|
|
2906
3881
|
});
|
|
2907
|
-
import_electron.app.on("activate",
|
|
2908
|
-
|
|
3882
|
+
import_electron.app.on("activate", () => {
|
|
3883
|
+
debug("activate", { alertKey });
|
|
3884
|
+
revealPopover();
|
|
3885
|
+
});
|
|
3886
|
+
import_electron.app.on("second-instance", () => revealPopover());
|
|
2909
3887
|
import_electron.app.whenReady().then(async () => {
|
|
2910
3888
|
import_electron.app.dock?.hide();
|
|
2911
|
-
settingsFile = (0,
|
|
3889
|
+
settingsFile = (0, import_node_path19.join)(import_electron.app.getPath("userData"), "settings.json");
|
|
2912
3890
|
settings = await loadSettings(settingsFile);
|
|
2913
3891
|
import_electron.nativeTheme.themeSource = settings.theme;
|
|
2914
3892
|
if (import_electron.app.isPackaged) import_electron.app.setLoginItemSettings({ openAtLogin: settings.openAtLogin });
|
|
2915
3893
|
available = await availableTargets(defaultResolveOptions());
|
|
2916
|
-
|
|
3894
|
+
await loadAgentIcons();
|
|
3895
|
+
const iconPath = (0, import_node_path19.join)(__dirname, "..", "assets", "trayTemplate.png");
|
|
2917
3896
|
const icon = import_electron.nativeImage.createFromPath(iconPath);
|
|
2918
3897
|
icon.setTemplateImage(true);
|
|
2919
3898
|
tray = new import_electron.Tray(icon);
|
|
@@ -2932,6 +3911,7 @@ if (!import_electron.app.requestSingleInstanceLock()) {
|
|
|
2932
3911
|
void refreshSessions();
|
|
2933
3912
|
void usage?.refresh();
|
|
2934
3913
|
}, 6e4);
|
|
3914
|
+
setInterval(() => void checkDeliveries(), DELIVERY_CHECK_MS);
|
|
2935
3915
|
watcher = new Watcher(readers, { usageThreshold: settings.usageThreshold });
|
|
2936
3916
|
watcher.on("event", onWatchEvent);
|
|
2937
3917
|
watcher.on("error", (e) => console.error("[harlay] watcher error", e));
|