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/dist/harlay.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
// ../core/src/types.ts
|
|
8
8
|
var TOOLS = ["claude-code", "codex", "cursor"];
|
|
9
9
|
var TOOL_LABEL = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
|
|
10
|
+
function isTool(v) {
|
|
11
|
+
return v === "claude-code" || v === "codex" || v === "cursor";
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
// ../core/src/errors.ts
|
|
12
15
|
var HarlayError = class extends Error {
|
|
@@ -25,31 +28,83 @@ var LaunchError = class extends HarlayError {
|
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
// ../core/src/util/text.ts
|
|
28
|
-
var
|
|
29
|
-
|
|
30
|
-
const max = maxTokens * CHARS_PER_TOKEN;
|
|
31
|
-
if (s.length <= max) return s;
|
|
32
|
-
const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
|
|
33
|
-
return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
|
|
34
|
-
}
|
|
31
|
+
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
32
|
+
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
35
33
|
function tail(s, maxChars) {
|
|
36
|
-
|
|
34
|
+
if (s.length <= maxChars) return s;
|
|
35
|
+
let start = s.length - maxChars + 1;
|
|
36
|
+
if (start < s.length && isLow(s.charCodeAt(start))) start++;
|
|
37
|
+
return "\u2026" + s.slice(start);
|
|
38
|
+
}
|
|
39
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
40
|
+
function wellFormed(s) {
|
|
41
|
+
return s.replace(LONE_SURROGATE, "\uFFFD");
|
|
37
42
|
}
|
|
38
43
|
function oneLine(s, max = 120) {
|
|
39
44
|
const t = s.replace(/\s+/g, " ").trim();
|
|
40
|
-
|
|
45
|
+
if (t.length <= max) return t;
|
|
46
|
+
let end = max - 1;
|
|
47
|
+
if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
|
|
48
|
+
return t.slice(0, end) + "\u2026";
|
|
49
|
+
}
|
|
50
|
+
function clipWords(s, max) {
|
|
51
|
+
const w = s.split(/\s+/).filter(Boolean);
|
|
52
|
+
return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
|
|
41
53
|
}
|
|
42
54
|
var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
|
|
43
55
|
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/;
|
|
56
|
+
var CODEX_REQUEST_RE = /^## My request:[ \t]*\n/m;
|
|
44
57
|
function cleanPrompt(raw) {
|
|
45
58
|
let s = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(CONTEXT_BLOCK_RE, "").trim();
|
|
46
59
|
const query = /<user_query>([\s\S]*?)<\/user_query>/.exec(s);
|
|
47
60
|
if (query) s = query[1].trim();
|
|
61
|
+
const request = CODEX_REQUEST_RE.exec(s);
|
|
62
|
+
if (request) s = s.slice(request.index + request[0].length).trim();
|
|
48
63
|
if (MACHINE_TAG_RE.test(s)) return null;
|
|
49
64
|
return s ? s : null;
|
|
50
65
|
}
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
var LEGACY_HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
67
|
+
var HANDOFF_TAG = '<handoff from="';
|
|
68
|
+
var HANDOFF_END = "</handoff>";
|
|
69
|
+
var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
|
|
70
|
+
var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
|
|
71
|
+
var HARLAY_FILE_ID_RE = /"schema":\s*"harlay\/handoff@\d+",\s*(?:\d+\s+)?"id":\s*"([^"\\]+)"/;
|
|
72
|
+
var HARLAY_FILE_END_RE = /"endOfHandoff":\s*"([^"\\]+)"\s*(?:\d+\s+)?\}\s*$/;
|
|
73
|
+
function handoffIdIn(text) {
|
|
74
|
+
return HARLAY_FILE_ID_RE.exec(text)?.[1] ?? HARLAY_FILE_END_RE.exec(text)?.[1] ?? [...text.matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
75
|
+
}
|
|
76
|
+
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;
|
|
77
|
+
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;
|
|
78
|
+
function harlayFileIn(p) {
|
|
79
|
+
const m = HARLAY_FILE_RE.exec(p);
|
|
80
|
+
if (!m) return null;
|
|
81
|
+
const quotedOrBare = m[1] ?? m[2];
|
|
82
|
+
if (quotedOrBare) return quotedOrBare;
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(m[3]);
|
|
85
|
+
} catch {
|
|
86
|
+
return m[3];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function splitHandoffPrompt(p) {
|
|
90
|
+
const start = p.indexOf(HANDOFF_TAG);
|
|
91
|
+
const end = p.lastIndexOf(HANDOFF_END);
|
|
92
|
+
if (start !== -1 && end > start) {
|
|
93
|
+
const before = p.slice(0, start).trim();
|
|
94
|
+
const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
|
|
95
|
+
const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
96
|
+
return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
|
|
97
|
+
}
|
|
98
|
+
if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === LEGACY_HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
|
|
99
|
+
const m = HARLAY_PROMPT_RE.exec(p);
|
|
100
|
+
if (m) return { handoff: true, rest: p.slice(m[0].length).replace(/^[.,;:!?)]+/, "").trim(), id: null };
|
|
101
|
+
return { handoff: false, rest: p, id: null };
|
|
102
|
+
}
|
|
103
|
+
var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
104
|
+
var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
105
|
+
var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
106
|
+
function stripControl(s) {
|
|
107
|
+
return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
|
|
53
108
|
}
|
|
54
109
|
function slugify(s, max = 40) {
|
|
55
110
|
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
|
|
@@ -57,12 +112,14 @@ function slugify(s, max = 40) {
|
|
|
57
112
|
}
|
|
58
113
|
|
|
59
114
|
// ../core/src/sources/claude.ts
|
|
60
|
-
import { readFile, stat as
|
|
61
|
-
import { basename, join as
|
|
115
|
+
import { readFile as readFile3, stat as stat4 } from "node:fs/promises";
|
|
116
|
+
import { basename as basename3, join as join4, relative, sep } from "node:path";
|
|
62
117
|
|
|
63
118
|
// ../core/src/util/files.ts
|
|
119
|
+
import { createReadStream } from "node:fs";
|
|
64
120
|
import { open, readdir, stat } from "node:fs/promises";
|
|
65
121
|
import { join } from "node:path";
|
|
122
|
+
import { createInterface } from "node:readline";
|
|
66
123
|
async function listSessionFiles(root, accept) {
|
|
67
124
|
let names;
|
|
68
125
|
try {
|
|
@@ -82,27 +139,14 @@ async function listSessionFiles(root, accept) {
|
|
|
82
139
|
}
|
|
83
140
|
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
84
141
|
}
|
|
85
|
-
async function
|
|
86
|
-
const
|
|
142
|
+
async function eachLine(file, each, maxBytes = 8 * 1024 * 1024) {
|
|
143
|
+
const input = createReadStream(file, { encoding: "utf8", end: maxBytes - 1 });
|
|
144
|
+
const lines2 = createInterface({ input, crlfDelay: Infinity });
|
|
87
145
|
try {
|
|
88
|
-
const
|
|
89
|
-
const buf = Buffer.alloc(65536);
|
|
90
|
-
let total = 0;
|
|
91
|
-
while (total < maxBytes) {
|
|
92
|
-
const { bytesRead } = await fh.read(buf, 0, buf.length, total);
|
|
93
|
-
if (bytesRead === 0) break;
|
|
94
|
-
const slice = buf.subarray(0, bytesRead);
|
|
95
|
-
const nl = slice.indexOf(10);
|
|
96
|
-
if (nl >= 0) {
|
|
97
|
-
chunks.push(Buffer.from(slice.subarray(0, nl)));
|
|
98
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
99
|
-
}
|
|
100
|
-
chunks.push(Buffer.from(slice));
|
|
101
|
-
total += bytesRead;
|
|
102
|
-
}
|
|
103
|
-
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
146
|
+
for await (const line of lines2) if (each(line)) break;
|
|
104
147
|
} finally {
|
|
105
|
-
|
|
148
|
+
lines2.close();
|
|
149
|
+
input.destroy();
|
|
106
150
|
}
|
|
107
151
|
}
|
|
108
152
|
|
|
@@ -135,6 +179,445 @@ function blockText(content) {
|
|
|
135
179
|
return parts.join("\n");
|
|
136
180
|
}
|
|
137
181
|
|
|
182
|
+
// ../core/src/sources/folder.ts
|
|
183
|
+
import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
|
|
184
|
+
import { basename as basename2, dirname as dirname2 } from "node:path";
|
|
185
|
+
|
|
186
|
+
// ../core/src/brief/write.ts
|
|
187
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
188
|
+
import { link, mkdir as mkdir2, readFile, readdir as readdir2, rm, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
189
|
+
import { basename, join as join3 } from "node:path";
|
|
190
|
+
|
|
191
|
+
// ../core/src/launch/launchers.ts
|
|
192
|
+
import { execFile } from "node:child_process";
|
|
193
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
194
|
+
import { dirname, join as join2 } from "node:path";
|
|
195
|
+
import { promisify } from "node:util";
|
|
196
|
+
var pexec = promisify(execFile);
|
|
197
|
+
var HARLAY_PROMPT = "This is the context from Harlay";
|
|
198
|
+
function launchPrompt(file) {
|
|
199
|
+
return `${HARLAY_PROMPT} @${/\s/.test(file) ? `"${file}"` : file}`;
|
|
200
|
+
}
|
|
201
|
+
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
202
|
+
var defaultRunner = {
|
|
203
|
+
async open(args) {
|
|
204
|
+
await pexec("/usr/bin/open", args);
|
|
205
|
+
},
|
|
206
|
+
async writeExecutable(path, content) {
|
|
207
|
+
await mkdir(dirname(path), { recursive: true });
|
|
208
|
+
await writeFile(path, content, { mode: 493 });
|
|
209
|
+
await chmod(path, 493);
|
|
210
|
+
},
|
|
211
|
+
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
212
|
+
};
|
|
213
|
+
function shq(s) {
|
|
214
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
215
|
+
}
|
|
216
|
+
var enc = encodeURIComponent;
|
|
217
|
+
function planLaunch(o) {
|
|
218
|
+
if (o.mode === "app") {
|
|
219
|
+
switch (o.target) {
|
|
220
|
+
case "claude-code":
|
|
221
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
222
|
+
case "codex":
|
|
223
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
224
|
+
case "cursor":
|
|
225
|
+
return {
|
|
226
|
+
target: o.target,
|
|
227
|
+
mode: o.mode,
|
|
228
|
+
steps: [
|
|
229
|
+
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
230
|
+
{ kind: "wait", ms: 1500 },
|
|
231
|
+
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
232
|
+
]
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const bin = o.binaries[o.target];
|
|
237
|
+
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
238
|
+
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
239
|
+
const path = join2(o.scriptDir, `continue-${o.target}.command`);
|
|
240
|
+
const content = `#!/bin/zsh -l
|
|
241
|
+
cd -- ${shq(o.cwd)} || exit 1
|
|
242
|
+
exec ${argv.map(shq).join(" ")}
|
|
243
|
+
`;
|
|
244
|
+
return {
|
|
245
|
+
target: o.target,
|
|
246
|
+
mode: o.mode,
|
|
247
|
+
steps: [
|
|
248
|
+
{ kind: "script", path, content },
|
|
249
|
+
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
250
|
+
]
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function executeLaunch(plan, runner = defaultRunner) {
|
|
254
|
+
for (const step of plan.steps) {
|
|
255
|
+
if (step.kind === "open") await runner.open(step.args);
|
|
256
|
+
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
257
|
+
else await runner.sleep(step.ms);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ../core/src/redact/redact.ts
|
|
262
|
+
var mark = (kind) => `[REDACTED:${kind}]`;
|
|
263
|
+
var whole = (kind) => () => mark(kind);
|
|
264
|
+
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*)`;
|
|
265
|
+
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]*)`;
|
|
266
|
+
var NOT_A_VALUE = String.raw`(?:true|false|null|none|undefined|await|new|this|self|string|number|boolean|object|unknown|bool)\b`;
|
|
267
|
+
var RULES = [
|
|
268
|
+
{ kind: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replace: whole("private_key") },
|
|
269
|
+
{ kind: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, replace: whole("anthropic_key") },
|
|
270
|
+
{ kind: "openai_key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, replace: whole("openai_key") },
|
|
271
|
+
{ kind: "github_token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, replace: whole("github_token") },
|
|
272
|
+
{ kind: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: whole("aws_access_key") },
|
|
273
|
+
{ kind: "stripe_key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, replace: whole("stripe_key") },
|
|
274
|
+
{ kind: "slack_token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: whole("slack_token") },
|
|
275
|
+
{ kind: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: whole("google_api_key") },
|
|
276
|
+
{ kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: whole("jwt") },
|
|
277
|
+
{ kind: "bearer", re: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, replace: (_m, g) => `${g[0]}${mark("bearer")}` },
|
|
278
|
+
// Env rules run before config rules so an uppercase env line is counted once, as env_secret.
|
|
279
|
+
{
|
|
280
|
+
kind: "env_secret",
|
|
281
|
+
re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
|
|
282
|
+
replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
kind: "env_secret",
|
|
286
|
+
re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
|
|
287
|
+
replace: (_m, g) => `${g[0]}${mark("env_secret")}`
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
kind: "config_secret",
|
|
291
|
+
re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
|
|
292
|
+
replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
// The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
|
|
296
|
+
// backtrack into a shorter match.
|
|
297
|
+
kind: "config_secret",
|
|
298
|
+
re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
|
|
299
|
+
replace: (_m, g) => `${g[0]}${mark("config_secret")}`
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
kind: "url_credentials",
|
|
303
|
+
re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
|
|
304
|
+
replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
|
|
305
|
+
}
|
|
306
|
+
];
|
|
307
|
+
function shannon(s) {
|
|
308
|
+
const freq = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
310
|
+
let h = 0;
|
|
311
|
+
for (const n of freq.values()) {
|
|
312
|
+
const p = n / s.length;
|
|
313
|
+
h -= p * Math.log2(p);
|
|
314
|
+
}
|
|
315
|
+
return h;
|
|
316
|
+
}
|
|
317
|
+
var Redactor = class {
|
|
318
|
+
counts = {};
|
|
319
|
+
bump(kind) {
|
|
320
|
+
this.counts[kind] = (this.counts[kind] ?? 0) + 1;
|
|
321
|
+
}
|
|
322
|
+
apply(text) {
|
|
323
|
+
let out = text;
|
|
324
|
+
for (const rule of RULES) {
|
|
325
|
+
out = out.replace(rule.re, (match, ...rest) => {
|
|
326
|
+
this.bump(rule.kind);
|
|
327
|
+
const groups = rest.filter((x) => typeof x === "string");
|
|
328
|
+
return rule.replace(match, groups);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
|
|
332
|
+
if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
|
|
333
|
+
this.bump("high_entropy");
|
|
334
|
+
return mark("high_entropy");
|
|
335
|
+
});
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// ../core/src/brief/record.ts
|
|
341
|
+
var RECORD_SCHEMA = "harlay/handoff@6";
|
|
342
|
+
var MESSAGE_SCHEMAS = /* @__PURE__ */ new Set([RECORD_SCHEMA, "harlay/handoff@5"]);
|
|
343
|
+
var OLD_SCHEMAS = /* @__PURE__ */ new Set(["harlay/handoff@4", "harlay/handoff@3", "harlay/handoff@2"]);
|
|
344
|
+
var ACTIVITY_MAX = 6e3;
|
|
345
|
+
var DIFF_MAX = 6e3;
|
|
346
|
+
var FAILURE_OUTPUT_MAX = 3e3;
|
|
347
|
+
var FAILURE_OUTPUT_LINES = 40;
|
|
348
|
+
var MAX_UNCOMMITTED = 15;
|
|
349
|
+
var REASON_TEXT = {
|
|
350
|
+
usage_limit: "it hit a usage limit",
|
|
351
|
+
crash: "it stopped with an error",
|
|
352
|
+
manual: "the user moved it here"
|
|
353
|
+
};
|
|
354
|
+
function harlayAbout(from, reason, root) {
|
|
355
|
+
return `Harlay context: the user was working with ${TOOL_LABEL[from]} in ${root}, and that chat stopped (${REASON_TEXT[reason]}). This file is what happened there: everything the user typed, word for word and oldest first (task.userMessages), where it stopped and their latest request (state), a command that still fails (lastFailure), how each command went (results), what the previous agent read, was given and looked up (references), and the recent activity and diff.`;
|
|
356
|
+
}
|
|
357
|
+
function harlayInstructions(root, createdAt, head) {
|
|
358
|
+
return [
|
|
359
|
+
"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.",
|
|
360
|
+
"Don't run commands or change files until they answer.",
|
|
361
|
+
"The rules the user set and what they decided are in task.userMessages, in their own words: follow them.",
|
|
362
|
+
`Work in ${root}, even if this chat was opened somewhere else.`,
|
|
363
|
+
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.`,
|
|
364
|
+
"Open what references lists only if you need it."
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
var LINE_MAX = 500;
|
|
368
|
+
function lines(s) {
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const line of wellFormed(s).split("\n")) {
|
|
371
|
+
if (line.length <= LINE_MAX) {
|
|
372
|
+
out.push(line);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
for (let i = 0; i < line.length; ) {
|
|
376
|
+
let end = Math.min(i + LINE_MAX, line.length);
|
|
377
|
+
if (end < line.length && /[\ud800-\udbff]/.test(line[end - 1])) end--;
|
|
378
|
+
out.push(line.slice(i, end));
|
|
379
|
+
i = end;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
function tailLines(s, max) {
|
|
385
|
+
if (s.length <= max) return lines(s);
|
|
386
|
+
const cut = s.slice(s.length - max);
|
|
387
|
+
const nl = cut.indexOf("\n");
|
|
388
|
+
return ["\u2026(earlier lines cut)", ...lines(nl === -1 ? cut : cut.slice(nl + 1))];
|
|
389
|
+
}
|
|
390
|
+
function headLines(s, max, more) {
|
|
391
|
+
if (s.length <= max) return lines(s.replace(/\n$/, ""));
|
|
392
|
+
const cut = s.slice(0, max);
|
|
393
|
+
const nl = cut.lastIndexOf("\n");
|
|
394
|
+
return [...lines(nl === -1 ? cut : cut.slice(0, nl)), more];
|
|
395
|
+
}
|
|
396
|
+
function buildRecord(d, id, previousId, delivery, r) {
|
|
397
|
+
const a = (s) => wellFormed(r.apply(stripControl(s)));
|
|
398
|
+
const files = d.git?.files ?? [];
|
|
399
|
+
const failure = d.lastFailure ? a(d.lastFailure.output).split("\n").slice(-FAILURE_OUTPUT_LINES).join("\n") : "";
|
|
400
|
+
return {
|
|
401
|
+
harlay: harlayAbout(d.from.tool, d.from.reason, d.workdir),
|
|
402
|
+
// The id stays near the start: an agent may read only the file's first lines, and its copy is matched by the id.
|
|
403
|
+
schema: RECORD_SCHEMA,
|
|
404
|
+
id,
|
|
405
|
+
createdAt: d.createdAt.toISOString(),
|
|
406
|
+
instructions: harlayInstructions(d.workdir, d.createdAt.toISOString(), d.git?.head ?? null),
|
|
407
|
+
task: {
|
|
408
|
+
title: a(d.title),
|
|
409
|
+
originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
|
|
410
|
+
userMessages: d.userMessages.map(a),
|
|
411
|
+
omittedMessages: d.omittedMessages
|
|
412
|
+
},
|
|
413
|
+
state: {
|
|
414
|
+
whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
|
|
415
|
+
lastRequest: d.lastRequest === null ? null : a(d.lastRequest),
|
|
416
|
+
todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
|
|
417
|
+
},
|
|
418
|
+
lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, output: tailLines(failure, FAILURE_OUTPUT_MAX) } : null,
|
|
419
|
+
results: d.results.map((x) => ({ ...x, cmd: clipWords(a(x.cmd), 30) })),
|
|
420
|
+
references: { read: d.references.read.map(a), attached: d.references.attached.map(a), urls: d.references.urls.map(a) },
|
|
421
|
+
files: {
|
|
422
|
+
touched: d.filesTouched.map(a),
|
|
423
|
+
uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
|
|
424
|
+
moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
|
|
425
|
+
},
|
|
426
|
+
recentActivity: tailLines(a(d.transcript), ACTIVITY_MAX),
|
|
427
|
+
diff: d.git && d.git.diff ? headLines(a(d.git.diff), DIFF_MAX, "\u2026(diff cut; run git diff for the rest)") : null,
|
|
428
|
+
from: {
|
|
429
|
+
tool: d.from.tool,
|
|
430
|
+
model: d.from.model,
|
|
431
|
+
session: d.from.session,
|
|
432
|
+
reason: d.from.reason,
|
|
433
|
+
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
|
|
434
|
+
message: d.from.message === null ? null : a(d.from.message)
|
|
435
|
+
},
|
|
436
|
+
repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
|
|
437
|
+
previous: previousId ? { id: previousId } : null,
|
|
438
|
+
delivery,
|
|
439
|
+
unknownRecords: d.unknownRecords,
|
|
440
|
+
endOfHandoff: id
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
var isStr = (v) => typeof v === "string";
|
|
444
|
+
var isObj2 = (v) => typeof v === "object" && v !== null;
|
|
445
|
+
var isStrs = (v) => Array.isArray(v) && v.every(isStr);
|
|
446
|
+
function messagesOf(schema, task) {
|
|
447
|
+
if (MESSAGE_SCHEMAS.has(schema)) {
|
|
448
|
+
if (!isStrs(task.userMessages) || typeof task.omittedMessages !== "number") return null;
|
|
449
|
+
return { userMessages: task.userMessages, omittedMessages: task.omittedMessages };
|
|
450
|
+
}
|
|
451
|
+
const recent = task.recentUserMessages;
|
|
452
|
+
const rules = task.pinnedRules;
|
|
453
|
+
if (!isStrs(recent)) return null;
|
|
454
|
+
if (!Array.isArray(rules) || !rules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
|
|
455
|
+
const ruleTexts = rules.map((r) => r.text).filter((t) => !recent.some((m) => m.includes(t)));
|
|
456
|
+
return { userMessages: [...ruleTexts, ...recent], omittedMessages: 0 };
|
|
457
|
+
}
|
|
458
|
+
function validateRecord(raw) {
|
|
459
|
+
if (!isObj2(raw) || !(MESSAGE_SCHEMAS.has(raw.schema) || OLD_SCHEMAS.has(raw.schema)) || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
|
|
460
|
+
const { task, from } = raw;
|
|
461
|
+
if (!isObj2(from) || !isTool(from.tool)) return null;
|
|
462
|
+
if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
|
|
463
|
+
const messages = messagesOf(raw.schema, task);
|
|
464
|
+
if (!messages) return null;
|
|
465
|
+
const sent = isObj2(raw.delivery) ? raw.delivery : isObj2(raw.prompt) ? raw.prompt : null;
|
|
466
|
+
return {
|
|
467
|
+
id: raw.id,
|
|
468
|
+
task: { title: task.title, originalGoal: task.originalGoal, ...messages },
|
|
469
|
+
target: sent && isTool(sent.target) ? sent.target : null,
|
|
470
|
+
root: isObj2(raw.repo) && isStr(raw.repo.root) ? raw.repo.root : null
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ../core/src/brief/write.ts
|
|
475
|
+
function handoffStore(home) {
|
|
476
|
+
return join3(home, "Library", "Application Support", "Harlay", "handoffs");
|
|
477
|
+
}
|
|
478
|
+
function repoFolder(store, root) {
|
|
479
|
+
return join3(store, `${slugify(basename(root), 30)}-${createHash("sha256").update(root).digest("hex").slice(0, 8)}`);
|
|
480
|
+
}
|
|
481
|
+
var LEGACY_FILE = "handoff.harlay";
|
|
482
|
+
async function readHandoffState(workdir) {
|
|
483
|
+
let raw;
|
|
484
|
+
try {
|
|
485
|
+
raw = JSON.parse(await readFile(join3(workdir, ".harlay", "state.json"), "utf8"));
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
490
|
+
const s = raw;
|
|
491
|
+
if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
|
|
492
|
+
if (s.goal !== null && typeof s.goal !== "string") return null;
|
|
493
|
+
return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
|
|
494
|
+
}
|
|
495
|
+
async function readIfExists(file) {
|
|
496
|
+
try {
|
|
497
|
+
return await readFile(file, "utf8");
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function parseRecord(raw) {
|
|
503
|
+
try {
|
|
504
|
+
return validateRecord(JSON.parse(raw));
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function toPrevious(rec) {
|
|
510
|
+
const { task } = rec;
|
|
511
|
+
return {
|
|
512
|
+
id: rec.id,
|
|
513
|
+
goal: task.originalGoal,
|
|
514
|
+
title: task.title,
|
|
515
|
+
userMessages: task.userMessages,
|
|
516
|
+
omittedMessages: task.omittedMessages,
|
|
517
|
+
target: rec.target
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async function newestIn(dir) {
|
|
521
|
+
let names;
|
|
522
|
+
try {
|
|
523
|
+
names = await readdir2(dir);
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
let best = null;
|
|
528
|
+
for (const name of names) {
|
|
529
|
+
if (!name.endsWith(".harlay") || name.startsWith(".")) continue;
|
|
530
|
+
const file = join3(dir, name);
|
|
531
|
+
try {
|
|
532
|
+
const at = (await stat2(file)).mtimeMs;
|
|
533
|
+
if (!best || at > best.at) best = { file, at };
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return best?.file ?? null;
|
|
538
|
+
}
|
|
539
|
+
async function readPreviousHandoff(store, workdir) {
|
|
540
|
+
const newest = await newestIn(repoFolder(store, workdir));
|
|
541
|
+
for (const file of newest ? [newest] : [join3(workdir, LEGACY_FILE), join3(workdir, ".harlay", "handoff.json")]) {
|
|
542
|
+
const raw = await readIfExists(file);
|
|
543
|
+
if (raw === null) continue;
|
|
544
|
+
const rec = parseRecord(raw);
|
|
545
|
+
return rec ? toPrevious(rec) : null;
|
|
546
|
+
}
|
|
547
|
+
const state = await readHandoffState(workdir);
|
|
548
|
+
return state ? { id: state.lastId, goal: state.goal, title: state.title, userMessages: [], omittedMessages: 0, target: null } : null;
|
|
549
|
+
}
|
|
550
|
+
function addCounts(a, b) {
|
|
551
|
+
const out = { ...a };
|
|
552
|
+
for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
|
|
553
|
+
return out;
|
|
554
|
+
}
|
|
555
|
+
var writing = /* @__PURE__ */ new Map();
|
|
556
|
+
function writeHandoff(d, o) {
|
|
557
|
+
const dir = repoFolder(o.store, d.workdir);
|
|
558
|
+
const run2 = (writing.get(dir) ?? Promise.resolve()).catch(() => void 0).then(() => write(d, o, dir));
|
|
559
|
+
writing.set(dir, run2);
|
|
560
|
+
void run2.finally(() => {
|
|
561
|
+
if (writing.get(dir) === run2) writing.delete(dir);
|
|
562
|
+
}).catch(() => void 0);
|
|
563
|
+
return run2;
|
|
564
|
+
}
|
|
565
|
+
async function exists(file) {
|
|
566
|
+
try {
|
|
567
|
+
await stat2(file);
|
|
568
|
+
return true;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function write(d, o, dir) {
|
|
574
|
+
await mkdir2(dir, { recursive: true });
|
|
575
|
+
const tmp = join3(dir, `.tmp-${process.pid}-${randomUUID().slice(0, 8)}.harlay`);
|
|
576
|
+
try {
|
|
577
|
+
for (let n = 1; ; n++) {
|
|
578
|
+
const id = n === 1 ? d.baseId : `${d.baseId}-${n}`;
|
|
579
|
+
const path = join3(dir, `${id}.harlay`);
|
|
580
|
+
if (await exists(path)) continue;
|
|
581
|
+
const r = new Redactor();
|
|
582
|
+
const prompt = launchPrompt(path);
|
|
583
|
+
const record = buildRecord(d, id, o.previousId, { target: o.target, mode: o.mode, prompt }, r);
|
|
584
|
+
await writeFile2(tmp, JSON.stringify(record, null, 2) + "\n");
|
|
585
|
+
try {
|
|
586
|
+
await link(tmp, path);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
if (e.code === "EEXIST") continue;
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
return { id, path, prompt, redactions: addCounts(d.redactions ?? {}, r.counts) };
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
await rm(tmp, { force: true });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ../core/src/sources/folder.ts
|
|
599
|
+
async function isDir(p) {
|
|
600
|
+
try {
|
|
601
|
+
return (await stat3(p)).isDirectory();
|
|
602
|
+
} catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async function handoffRepo(file) {
|
|
607
|
+
let raw = null;
|
|
608
|
+
try {
|
|
609
|
+
raw = await readFile2(file, "utf8");
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
const root = raw === null ? null : parseRecord(raw)?.root;
|
|
613
|
+
if (root && await isDir(root)) return root;
|
|
614
|
+
return basename2(file) === LEGACY_FILE && await isDir(dirname2(file)) ? dirname2(file) : null;
|
|
615
|
+
}
|
|
616
|
+
async function sessionFolder(opened, firstPrompt2) {
|
|
617
|
+
const file = firstPrompt2 === null ? null : harlayFileIn(firstPrompt2);
|
|
618
|
+
return (file && await handoffRepo(file)) ?? opened;
|
|
619
|
+
}
|
|
620
|
+
|
|
138
621
|
// ../core/src/sources/todos.ts
|
|
139
622
|
var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
|
|
140
623
|
function applyTodoWrite(current, input) {
|
|
@@ -155,6 +638,14 @@ function applyTodoWrite(current, input) {
|
|
|
155
638
|
|
|
156
639
|
// ../core/src/sources/claude.ts
|
|
157
640
|
var OUTPUT_TAIL = 2e4;
|
|
641
|
+
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)/;
|
|
642
|
+
var HANDOFF_FILE_RE = /\.harlay$|\.harlay\/(?:handoffs\/[^/]+\/)?HANDOFF\.md$/;
|
|
643
|
+
function claudeBriefId(a) {
|
|
644
|
+
if (!isObj(a) || a.type !== "file" || typeof a.filename !== "string" || !HANDOFF_FILE_RE.test(a.filename)) return null;
|
|
645
|
+
const c = a.content;
|
|
646
|
+
const text = isObj(c) && isObj(c.file) && typeof c.file.content === "string" ? c.file.content : typeof c === "string" ? c : "";
|
|
647
|
+
return handoffIdIn(text);
|
|
648
|
+
}
|
|
158
649
|
function claudeHalt(record) {
|
|
159
650
|
if (!isObj(record) || record.type !== "assistant" || record.isApiErrorMessage !== true) return null;
|
|
160
651
|
if (record.error !== "rate_limit" && record.apiErrorStatus !== 429) return null;
|
|
@@ -167,16 +658,32 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
167
658
|
const timeline = [];
|
|
168
659
|
const pendingBash = /* @__PURE__ */ new Map();
|
|
169
660
|
const pendingWrite = /* @__PURE__ */ new Map();
|
|
661
|
+
const pendingEdit = /* @__PURE__ */ new Map();
|
|
662
|
+
const pushEdit = (id, edit) => {
|
|
663
|
+
const item = { kind: "edit", edit };
|
|
664
|
+
timeline.push(item);
|
|
665
|
+
if (id) pendingEdit.set(id, item);
|
|
666
|
+
};
|
|
170
667
|
let todos = null;
|
|
171
668
|
let halt = null;
|
|
172
669
|
let model = null;
|
|
173
670
|
let unknown = invalid;
|
|
671
|
+
const handoffIds = [];
|
|
174
672
|
for (const rec of records) {
|
|
175
673
|
if (!isObj(rec) || typeof rec.type !== "string") {
|
|
176
674
|
unknown++;
|
|
177
675
|
continue;
|
|
178
676
|
}
|
|
179
677
|
if (rec.isSidechain === true) continue;
|
|
678
|
+
if (rec.type === "attachment") {
|
|
679
|
+
const id = claudeBriefId(rec.attachment);
|
|
680
|
+
if (id && !handoffIds.includes(id)) handoffIds.push(id);
|
|
681
|
+
const a = rec.attachment;
|
|
682
|
+
if (!id && isObj(a) && a.type === "file" && typeof a.filename === "string" && !HANDOFF_FILE_RE.test(a.filename)) {
|
|
683
|
+
timeline.push({ kind: "read", path: a.filename, how: "attached" });
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
180
687
|
if (rec.type === "assistant") {
|
|
181
688
|
const h = claudeHalt(rec);
|
|
182
689
|
if (h) {
|
|
@@ -200,15 +707,31 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
200
707
|
if (c) pendingBash.set(id, c);
|
|
201
708
|
break;
|
|
202
709
|
}
|
|
203
|
-
case "Edit":
|
|
710
|
+
case "Edit": {
|
|
711
|
+
const p = str(input.file_path);
|
|
712
|
+
const before = str(input.old_string);
|
|
713
|
+
const after = str(input.new_string);
|
|
714
|
+
if (p) pushEdit(id, before !== null && after !== null ? { path: p, kind: "modify", before, after } : { path: p, kind: "modify" });
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
204
717
|
case "MultiEdit": {
|
|
205
718
|
const p = str(input.file_path);
|
|
206
|
-
if (p)
|
|
719
|
+
if (p) pushEdit(id, { path: p, kind: "modify" });
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
case "Read": {
|
|
723
|
+
const p = str(input.file_path);
|
|
724
|
+
if (p) timeline.push({ kind: "read", path: p });
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
case "WebFetch": {
|
|
728
|
+
const u = str(input.url);
|
|
729
|
+
if (u) timeline.push({ kind: "read", path: u, how: "fetched" });
|
|
207
730
|
break;
|
|
208
731
|
}
|
|
209
732
|
case "NotebookEdit": {
|
|
210
733
|
const p = str(input.notebook_path);
|
|
211
|
-
if (p)
|
|
734
|
+
if (p) pushEdit(id, { path: p, kind: "modify" });
|
|
212
735
|
break;
|
|
213
736
|
}
|
|
214
737
|
case "Write": {
|
|
@@ -216,7 +739,7 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
216
739
|
if (p) {
|
|
217
740
|
const edit = { path: p, kind: "modify" };
|
|
218
741
|
pendingWrite.set(id, edit);
|
|
219
|
-
|
|
742
|
+
pushEdit(id, edit);
|
|
220
743
|
}
|
|
221
744
|
break;
|
|
222
745
|
}
|
|
@@ -242,6 +765,11 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
242
765
|
if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
243
766
|
if (b.type !== "tool_result") continue;
|
|
244
767
|
const id = str(b.tool_use_id) ?? "";
|
|
768
|
+
const e = pendingEdit.get(id);
|
|
769
|
+
if (e) {
|
|
770
|
+
pendingEdit.delete(id);
|
|
771
|
+
if (b.is_error === true) timeline.splice(timeline.lastIndexOf(e), 1);
|
|
772
|
+
}
|
|
245
773
|
const w = pendingWrite.get(id);
|
|
246
774
|
if (w) {
|
|
247
775
|
pendingWrite.delete(id);
|
|
@@ -251,6 +779,10 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
251
779
|
if (cmd === void 0) continue;
|
|
252
780
|
pendingBash.delete(id);
|
|
253
781
|
const content = blockText(b.content);
|
|
782
|
+
if (b.is_error === true && NOT_RUN_RE.test(content)) {
|
|
783
|
+
timeline.push({ kind: "command", run: { cmd, exitCode: null, output: tail(content, OUTPUT_TAIL) } });
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
254
786
|
const code = /^Exit code (\d+)/.exec(content);
|
|
255
787
|
const exitCode = code ? Number(code[1]) : b.is_error === true ? 1 : 0;
|
|
256
788
|
const r = rec.toolUseResult;
|
|
@@ -263,14 +795,14 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
263
795
|
}
|
|
264
796
|
}
|
|
265
797
|
}
|
|
266
|
-
return { ref, model, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
798
|
+
return { ref, model, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown, handoffIds };
|
|
267
799
|
}
|
|
268
800
|
async function loadDesktopTitles(dir) {
|
|
269
801
|
const out = /* @__PURE__ */ new Map();
|
|
270
|
-
const files = await listSessionFiles(dir, (f) =>
|
|
802
|
+
const files = await listSessionFiles(dir, (f) => basename3(f).startsWith("local_") && f.endsWith(".json"));
|
|
271
803
|
for (const f of files) {
|
|
272
804
|
try {
|
|
273
|
-
const d = JSON.parse(await
|
|
805
|
+
const d = JSON.parse(await readFile3(f.path, "utf8"));
|
|
274
806
|
if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
|
|
275
807
|
out.set(d.cliSessionId, d.title);
|
|
276
808
|
}
|
|
@@ -280,14 +812,14 @@ async function loadDesktopTitles(dir) {
|
|
|
280
812
|
return out;
|
|
281
813
|
}
|
|
282
814
|
function createClaudeReader(o) {
|
|
283
|
-
const root =
|
|
284
|
-
const indexDir =
|
|
815
|
+
const root = join4(o.home, ".claude", "projects");
|
|
816
|
+
const indexDir = join4(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
285
817
|
const isSessionFile = (file) => file.startsWith(root + sep) && file.endsWith(".jsonl") && relative(root, file).split(sep).length === 2;
|
|
286
818
|
const cache = /* @__PURE__ */ new Map();
|
|
287
819
|
async function refForFile(file, titles) {
|
|
288
820
|
let st;
|
|
289
821
|
try {
|
|
290
|
-
st = await
|
|
822
|
+
st = await stat4(file);
|
|
291
823
|
} catch {
|
|
292
824
|
return null;
|
|
293
825
|
}
|
|
@@ -295,17 +827,20 @@ function createClaudeReader(o) {
|
|
|
295
827
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
296
828
|
let text;
|
|
297
829
|
try {
|
|
298
|
-
text = await
|
|
830
|
+
text = await readFile3(file, "utf8");
|
|
299
831
|
} catch {
|
|
300
832
|
return null;
|
|
301
833
|
}
|
|
302
834
|
let cwd = null;
|
|
303
835
|
let custom = null;
|
|
304
836
|
let ai = null;
|
|
305
|
-
|
|
837
|
+
let first;
|
|
838
|
+
const lines2 = text.split("\n");
|
|
839
|
+
for (const line of lines2) {
|
|
306
840
|
const wantCwd = cwd === null && line.includes('"cwd"');
|
|
307
841
|
const wantTitle = line.includes('-title"');
|
|
308
|
-
|
|
842
|
+
const wantPrompt = first === void 0 && line.includes('"user"');
|
|
843
|
+
if (!wantCwd && !wantTitle && !wantPrompt) continue;
|
|
309
844
|
let r;
|
|
310
845
|
try {
|
|
311
846
|
r = JSON.parse(line);
|
|
@@ -316,11 +851,26 @@ function createClaudeReader(o) {
|
|
|
316
851
|
if (wantCwd && typeof r.cwd === "string") cwd = r.cwd;
|
|
317
852
|
if (r.type === "custom-title" && typeof r.customTitle === "string") custom = r.customTitle;
|
|
318
853
|
if (r.type === "ai-title" && typeof r.aiTitle === "string") ai = r.aiTitle;
|
|
854
|
+
if (wantPrompt && r.type === "user" && r.isMeta !== true && r.isSidechain !== true && r.isCompactSummary !== true && isObj(r.message)) {
|
|
855
|
+
first = cleanPrompt(blockText(r.message.content)) ?? void 0;
|
|
856
|
+
}
|
|
319
857
|
}
|
|
858
|
+
for (let i = lines2.length - 1; i >= 0; i--) {
|
|
859
|
+
if (!lines2[i].includes('"cwd"')) continue;
|
|
860
|
+
try {
|
|
861
|
+
const r = JSON.parse(lines2[i]);
|
|
862
|
+
if (isObj(r) && typeof r.cwd === "string") {
|
|
863
|
+
cwd = r.cwd;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
} catch {
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
cwd = await sessionFolder(cwd, first ?? null);
|
|
320
870
|
entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
|
|
321
871
|
cache.set(file, entry);
|
|
322
872
|
}
|
|
323
|
-
const id =
|
|
873
|
+
const id = basename3(file, ".jsonl");
|
|
324
874
|
const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
|
|
325
875
|
if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
|
|
326
876
|
return entry.ref;
|
|
@@ -329,7 +879,7 @@ function createClaudeReader(o) {
|
|
|
329
879
|
tool: "claude-code",
|
|
330
880
|
root,
|
|
331
881
|
isSessionFile,
|
|
332
|
-
idFromPath: (file) => isSessionFile(file) ?
|
|
882
|
+
idFromPath: (file) => isSessionFile(file) ? basename3(file, ".jsonl") : null,
|
|
333
883
|
refForFile: (file) => refForFile(file),
|
|
334
884
|
async listSessions(limit = 20) {
|
|
335
885
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -343,7 +893,7 @@ function createClaudeReader(o) {
|
|
|
343
893
|
return refs;
|
|
344
894
|
},
|
|
345
895
|
async readSession(ref) {
|
|
346
|
-
const { records, invalid } = parseJsonLines(await
|
|
896
|
+
const { records, invalid } = parseJsonLines(await readFile3(ref.file, "utf8"));
|
|
347
897
|
return normalizeClaude(records, ref, invalid);
|
|
348
898
|
},
|
|
349
899
|
detect(record) {
|
|
@@ -356,11 +906,21 @@ function createClaudeReader(o) {
|
|
|
356
906
|
}
|
|
357
907
|
|
|
358
908
|
// ../core/src/sources/codex.ts
|
|
359
|
-
import { readFile as
|
|
360
|
-
import { basename as
|
|
909
|
+
import { readFile as readFile4, stat as stat5 } from "node:fs/promises";
|
|
910
|
+
import { basename as basename4, join as join5, sep as sep2 } from "node:path";
|
|
361
911
|
var OUTPUT_TAIL2 = 2e4;
|
|
362
912
|
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;
|
|
363
913
|
var KIND = { add: "create", update: "modify", delete: "delete" };
|
|
914
|
+
function diffSides(diff) {
|
|
915
|
+
const before = [];
|
|
916
|
+
const after = [];
|
|
917
|
+
for (const line of diff.split("\n")) {
|
|
918
|
+
if (line.startsWith("---") || line.startsWith("+++")) continue;
|
|
919
|
+
if (line.startsWith("-")) before.push(line.slice(1));
|
|
920
|
+
else if (line.startsWith("+")) after.push(line.slice(1));
|
|
921
|
+
}
|
|
922
|
+
return { before: before.join("\n"), after: after.join("\n") };
|
|
923
|
+
}
|
|
364
924
|
function codexCommand(command) {
|
|
365
925
|
if (typeof command === "string") return command;
|
|
366
926
|
if (!Array.isArray(command)) return "";
|
|
@@ -428,7 +988,8 @@ function normalizeCodex(records, ref, invalid = 0) {
|
|
|
428
988
|
} else if (it.type === "FileChange" && isObj(it.changes)) {
|
|
429
989
|
for (const [path, change] of Object.entries(it.changes)) {
|
|
430
990
|
const t = isObj(change) ? str(change.type) : null;
|
|
431
|
-
|
|
991
|
+
const diff = isObj(change) ? str(change.unified_diff) : null;
|
|
992
|
+
timeline.push({ kind: "edit", edit: { path, kind: t && KIND[t] || "modify", ...diff ? diffSides(diff) : {} } });
|
|
432
993
|
}
|
|
433
994
|
}
|
|
434
995
|
} else if (p.type === "user_message") {
|
|
@@ -459,11 +1020,25 @@ function parseSessionMeta(line) {
|
|
|
459
1020
|
const id = str(p.id);
|
|
460
1021
|
return id ? { id, cwd: str(p.cwd) } : null;
|
|
461
1022
|
}
|
|
1023
|
+
function typedPrompt(line) {
|
|
1024
|
+
if (!line.includes('"user_message"') && !line.includes('"UserMessage"')) return null;
|
|
1025
|
+
let r;
|
|
1026
|
+
try {
|
|
1027
|
+
r = JSON.parse(line);
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
if (!isObj(r) || r.type !== "event_msg" || !isObj(r.payload)) return null;
|
|
1032
|
+
const p = r.payload;
|
|
1033
|
+
if (p.type === "user_message") return typeof p.message === "string" ? cleanPrompt(p.message) : null;
|
|
1034
|
+
if (p.type === "item_completed" && isObj(p.item) && p.item.type === "UserMessage") return cleanPrompt(blockText(p.item.content));
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
462
1037
|
async function loadTitles(file) {
|
|
463
1038
|
const out = /* @__PURE__ */ new Map();
|
|
464
1039
|
let text;
|
|
465
1040
|
try {
|
|
466
|
-
text = await
|
|
1041
|
+
text = await readFile4(file, "utf8");
|
|
467
1042
|
} catch {
|
|
468
1043
|
return out;
|
|
469
1044
|
}
|
|
@@ -473,28 +1048,34 @@ async function loadTitles(file) {
|
|
|
473
1048
|
return out;
|
|
474
1049
|
}
|
|
475
1050
|
function createCodexReader(o) {
|
|
476
|
-
const root =
|
|
477
|
-
const indexFile =
|
|
478
|
-
const isSessionFile = (file) => file.startsWith(root + sep2) &&
|
|
1051
|
+
const root = join5(o.home, ".codex", "sessions");
|
|
1052
|
+
const indexFile = join5(o.home, ".codex", "session_index.jsonl");
|
|
1053
|
+
const isSessionFile = (file) => file.startsWith(root + sep2) && basename4(file).startsWith("rollout-") && file.endsWith(".jsonl");
|
|
479
1054
|
const cache = /* @__PURE__ */ new Map();
|
|
480
1055
|
async function refForFile(file, titles) {
|
|
481
1056
|
let st;
|
|
482
1057
|
try {
|
|
483
|
-
st = await
|
|
1058
|
+
st = await stat5(file);
|
|
484
1059
|
} catch {
|
|
485
1060
|
return null;
|
|
486
1061
|
}
|
|
487
1062
|
let entry = cache.get(file);
|
|
488
1063
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
489
|
-
let
|
|
1064
|
+
let meta = null;
|
|
1065
|
+
let first = null;
|
|
1066
|
+
let lines2 = 0;
|
|
490
1067
|
try {
|
|
491
|
-
|
|
1068
|
+
await eachLine(file, (line) => {
|
|
1069
|
+
if (lines2++ === 0) return (meta = parseSessionMeta(line)) === null;
|
|
1070
|
+
first = typedPrompt(line);
|
|
1071
|
+
return first !== null;
|
|
1072
|
+
});
|
|
492
1073
|
} catch {
|
|
493
1074
|
return null;
|
|
494
1075
|
}
|
|
495
|
-
const meta = first ? parseSessionMeta(first) : null;
|
|
496
1076
|
if (!meta) return null;
|
|
497
|
-
|
|
1077
|
+
const { id, cwd } = meta;
|
|
1078
|
+
entry = { size: st.size, mtimeMs: st.mtimeMs, id, cwd: await sessionFolder(cwd, first), ref: null };
|
|
498
1079
|
cache.set(file, entry);
|
|
499
1080
|
}
|
|
500
1081
|
const title = (titles ?? await loadTitles(indexFile)).get(entry.id) ?? null;
|
|
@@ -505,7 +1086,7 @@ function createCodexReader(o) {
|
|
|
505
1086
|
tool: "codex",
|
|
506
1087
|
root,
|
|
507
1088
|
isSessionFile,
|
|
508
|
-
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(
|
|
1089
|
+
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(basename4(file))?.[1] ?? null : null,
|
|
509
1090
|
refForFile: (file) => refForFile(file),
|
|
510
1091
|
async listSessions(limit = 20) {
|
|
511
1092
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -519,7 +1100,7 @@ function createCodexReader(o) {
|
|
|
519
1100
|
return refs;
|
|
520
1101
|
},
|
|
521
1102
|
async readSession(ref) {
|
|
522
|
-
const { records, invalid } = parseJsonLines(await
|
|
1103
|
+
const { records, invalid } = parseJsonLines(await readFile4(ref.file, "utf8"));
|
|
523
1104
|
return normalizeCodex(records, ref, invalid);
|
|
524
1105
|
},
|
|
525
1106
|
detect(record) {
|
|
@@ -544,21 +1125,21 @@ function createCodexReader(o) {
|
|
|
544
1125
|
}
|
|
545
1126
|
|
|
546
1127
|
// ../core/src/sources/cursor.ts
|
|
547
|
-
import { readdir as
|
|
548
|
-
import { join as
|
|
1128
|
+
import { readdir as readdir3, readFile as readFile5, stat as stat6 } from "node:fs/promises";
|
|
1129
|
+
import { join as join6, relative as relative2, sep as sep3 } from "node:path";
|
|
549
1130
|
var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
|
|
550
1131
|
async function resolveCursorSlug(slug, rootDir = "/") {
|
|
551
1132
|
if (!slug || /^\d+$/.test(slug)) return null;
|
|
552
1133
|
async function walk(dir, rest, depth) {
|
|
553
1134
|
if (depth > 16) return null;
|
|
554
|
-
const entries = await
|
|
1135
|
+
const entries = await readdir3(dir, { withFileTypes: true }).catch(() => null);
|
|
555
1136
|
if (!entries) return null;
|
|
556
1137
|
for (const e of entries) {
|
|
557
1138
|
if (!e.isDirectory()) continue;
|
|
558
1139
|
const n = norm(e.name);
|
|
559
|
-
if (rest === n) return
|
|
1140
|
+
if (rest === n) return join6(dir, e.name);
|
|
560
1141
|
if (rest.startsWith(n + "-")) {
|
|
561
|
-
const found = await walk(
|
|
1142
|
+
const found = await walk(join6(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
562
1143
|
if (found) return found;
|
|
563
1144
|
}
|
|
564
1145
|
}
|
|
@@ -606,7 +1187,13 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
606
1187
|
const input = isObj(b.input) ? b.input : {};
|
|
607
1188
|
if (b.name === "StrReplace" || b.name === "Write") {
|
|
608
1189
|
const paths = [str(input.path), ...Array.isArray(input.paths) ? input.paths.map(str) : []].filter((x) => !!x);
|
|
609
|
-
|
|
1190
|
+
const before = b.name === "StrReplace" ? str(input.old_string) : null;
|
|
1191
|
+
const after = b.name === "StrReplace" ? str(input.new_string) : null;
|
|
1192
|
+
const sides = before !== null && after !== null && paths.length === 1 ? { before, after } : {};
|
|
1193
|
+
for (const path of paths) timeline.push({ kind: "edit", edit: { path, kind: "modify", ...sides } });
|
|
1194
|
+
} else if (b.name === "Read") {
|
|
1195
|
+
const path = str(input.path) ?? str(input.file_path);
|
|
1196
|
+
if (path) timeline.push({ kind: "read", path });
|
|
610
1197
|
} else if (b.name === "Shell") {
|
|
611
1198
|
const cmd = str(input.command);
|
|
612
1199
|
if (cmd) timeline.push({ kind: "command", run: { cmd, exitCode: null, output: "" } });
|
|
@@ -618,7 +1205,7 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
618
1205
|
return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
619
1206
|
}
|
|
620
1207
|
function createCursorReader(o) {
|
|
621
|
-
const root =
|
|
1208
|
+
const root = join6(o.home, ".cursor", "projects");
|
|
622
1209
|
const slugCache = /* @__PURE__ */ new Map();
|
|
623
1210
|
const cwdFor = (slug) => {
|
|
624
1211
|
let p = slugCache.get(slug);
|
|
@@ -639,7 +1226,7 @@ function createCursorReader(o) {
|
|
|
639
1226
|
const parts = relative2(root, file).split(sep3);
|
|
640
1227
|
let st;
|
|
641
1228
|
try {
|
|
642
|
-
st = await
|
|
1229
|
+
st = await stat6(file);
|
|
643
1230
|
} catch {
|
|
644
1231
|
return null;
|
|
645
1232
|
}
|
|
@@ -647,21 +1234,25 @@ function createCursorReader(o) {
|
|
|
647
1234
|
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
|
|
648
1235
|
let text;
|
|
649
1236
|
try {
|
|
650
|
-
text = await
|
|
1237
|
+
text = await readFile5(file, "utf8");
|
|
651
1238
|
} catch {
|
|
652
1239
|
return null;
|
|
653
1240
|
}
|
|
654
1241
|
let title = null;
|
|
1242
|
+
let first = null;
|
|
655
1243
|
for (const r of parseJsonLines(text).records) {
|
|
656
1244
|
if (isObj(r) && r.role === "user" && isObj(r.message)) {
|
|
657
1245
|
const p = cleanPrompt(blockText(r.message.content));
|
|
658
|
-
|
|
659
|
-
|
|
1246
|
+
first ??= p;
|
|
1247
|
+
const rest = p ? splitHandoffPrompt(p).rest : "";
|
|
1248
|
+
if (rest) {
|
|
1249
|
+
title = oneLine(rest, 60);
|
|
660
1250
|
break;
|
|
661
1251
|
}
|
|
662
1252
|
}
|
|
663
1253
|
}
|
|
664
|
-
const
|
|
1254
|
+
const cwd = await sessionFolder(await cwdFor(parts[0]), first);
|
|
1255
|
+
const ref = { tool: "cursor", id: parts[2], file, cwd, title, lastActivityAt: st.mtime };
|
|
665
1256
|
cache.set(file, { size: st.size, mtimeMs: st.mtimeMs, ref });
|
|
666
1257
|
return ref;
|
|
667
1258
|
}
|
|
@@ -682,7 +1273,7 @@ function createCursorReader(o) {
|
|
|
682
1273
|
return refs;
|
|
683
1274
|
},
|
|
684
1275
|
async readSession(ref) {
|
|
685
|
-
const { records, invalid } = parseJsonLines(await
|
|
1276
|
+
const { records, invalid } = parseJsonLines(await readFile5(ref.file, "utf8"));
|
|
686
1277
|
return normalizeCursor(records, ref, invalid);
|
|
687
1278
|
},
|
|
688
1279
|
detect(record) {
|
|
@@ -695,14 +1286,14 @@ function createCursorReader(o) {
|
|
|
695
1286
|
}
|
|
696
1287
|
|
|
697
1288
|
// ../core/src/git/snapshot.ts
|
|
698
|
-
import { execFile } from "node:child_process";
|
|
699
|
-
import { createHash } from "node:crypto";
|
|
700
|
-
import { promisify } from "node:util";
|
|
701
|
-
var
|
|
1289
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1290
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1291
|
+
import { promisify as promisify2 } from "node:util";
|
|
1292
|
+
var pexec2 = promisify2(execFile2);
|
|
702
1293
|
var MAX_DIFF_CHARS = 2e6;
|
|
703
1294
|
var MAX_FILES = 500;
|
|
704
1295
|
async function git(cwd, args) {
|
|
705
|
-
const { stdout } = await
|
|
1296
|
+
const { stdout } = await pexec2("git", args, {
|
|
706
1297
|
cwd,
|
|
707
1298
|
maxBuffer: 64 * 1024 * 1024,
|
|
708
1299
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
|
|
@@ -771,97 +1362,42 @@ async function takeGitSnapshot(cwd) {
|
|
|
771
1362
|
}
|
|
772
1363
|
}
|
|
773
1364
|
let diff = await git(root, ["diff", base, "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/"]);
|
|
774
|
-
const diffSha =
|
|
1365
|
+
const diffSha = createHash2("sha256").update(diff).digest("hex");
|
|
775
1366
|
if (diff.length > MAX_DIFF_CHARS) diff = diff.slice(0, MAX_DIFF_CHARS) + "\n[diff truncated by Harlay]\n";
|
|
776
1367
|
return { root, branch, head, files, diff, diffSha };
|
|
777
1368
|
}
|
|
778
1369
|
|
|
779
|
-
// ../core/src/
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
var
|
|
784
|
-
var
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
|
|
800
|
-
replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
|
|
801
|
-
},
|
|
802
|
-
{
|
|
803
|
-
kind: "env_secret",
|
|
804
|
-
re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
|
|
805
|
-
replace: (_m, g) => `${g[0]}${mark("env_secret")}`
|
|
806
|
-
},
|
|
807
|
-
{
|
|
808
|
-
kind: "config_secret",
|
|
809
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
|
|
810
|
-
replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
|
|
811
|
-
},
|
|
812
|
-
{
|
|
813
|
-
// The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
|
|
814
|
-
// backtrack into a shorter match.
|
|
815
|
-
kind: "config_secret",
|
|
816
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
|
|
817
|
-
replace: (_m, g) => `${g[0]}${mark("config_secret")}`
|
|
818
|
-
},
|
|
819
|
-
{
|
|
820
|
-
kind: "url_credentials",
|
|
821
|
-
re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
|
|
822
|
-
replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
|
|
823
|
-
}
|
|
824
|
-
];
|
|
825
|
-
function shannon(s) {
|
|
826
|
-
const freq = /* @__PURE__ */ new Map();
|
|
827
|
-
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
828
|
-
let h = 0;
|
|
829
|
-
for (const n of freq.values()) {
|
|
830
|
-
const p = n / s.length;
|
|
831
|
-
h -= p * Math.log2(p);
|
|
832
|
-
}
|
|
833
|
-
return h;
|
|
1370
|
+
// ../core/src/extract/extract.ts
|
|
1371
|
+
import { basename as basename5, isAbsolute, relative as relative3 } from "node:path";
|
|
1372
|
+
|
|
1373
|
+
// ../core/src/extract/recent.ts
|
|
1374
|
+
var USER_MESSAGES = { budgetChars: 16e3, messageChars: 1500 };
|
|
1375
|
+
var CLIPPED = " \u2026(clipped)";
|
|
1376
|
+
function clipMessage(m) {
|
|
1377
|
+
const t = m.trim();
|
|
1378
|
+
return t.length <= USER_MESSAGES.messageChars ? t : t.slice(0, USER_MESSAGES.messageChars - CLIPPED.length).trimEnd() + CLIPPED;
|
|
1379
|
+
}
|
|
1380
|
+
function pickUserMessages(prompts) {
|
|
1381
|
+
const all = prompts.map(clipMessage).filter((m) => m.length > 0);
|
|
1382
|
+
if (all.reduce((n, m) => n + m.length, 0) <= USER_MESSAGES.budgetChars) return { messages: all, omitted: 0 };
|
|
1383
|
+
const newest = [];
|
|
1384
|
+
let used = all[0].length;
|
|
1385
|
+
for (let i = all.length - 1; i >= 1 && used + all[i].length <= USER_MESSAGES.budgetChars; i--) {
|
|
1386
|
+
newest.unshift(all[i]);
|
|
1387
|
+
used += all[i].length;
|
|
1388
|
+
}
|
|
1389
|
+
return { messages: [all[0], ...newest], omitted: all.length - 1 - newest.length };
|
|
834
1390
|
}
|
|
835
|
-
var Redactor = class {
|
|
836
|
-
counts = {};
|
|
837
|
-
bump(kind) {
|
|
838
|
-
this.counts[kind] = (this.counts[kind] ?? 0) + 1;
|
|
839
|
-
}
|
|
840
|
-
apply(text) {
|
|
841
|
-
let out = text;
|
|
842
|
-
for (const rule of RULES) {
|
|
843
|
-
out = out.replace(rule.re, (match, ...rest) => {
|
|
844
|
-
this.bump(rule.kind);
|
|
845
|
-
const groups = rest.filter((x) => typeof x === "string");
|
|
846
|
-
return rule.replace(match, groups);
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
|
|
850
|
-
if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
|
|
851
|
-
this.bump("high_entropy");
|
|
852
|
-
return mark("high_entropy");
|
|
853
|
-
});
|
|
854
|
-
return out;
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
1391
|
|
|
858
1392
|
// ../core/src/extract/extract.ts
|
|
859
|
-
import { basename as basename3, isAbsolute, relative as relative3 } from "node:path";
|
|
860
|
-
var DECISION_RE = /\b(don['’]t|do not|never|must|always|keep|avoid|make sure|without|instead of)\b/i;
|
|
861
1393
|
var TRANSCRIPT_MAX = 2e5;
|
|
1394
|
+
var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
|
|
1395
|
+
function isSlashCommand(p) {
|
|
1396
|
+
return SLASH_COMMAND_RE.test(p.trim());
|
|
1397
|
+
}
|
|
862
1398
|
function isSubstantive(p) {
|
|
863
1399
|
const t = p.trim();
|
|
864
|
-
return !t
|
|
1400
|
+
return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
|
|
865
1401
|
}
|
|
866
1402
|
var PROBES = /* @__PURE__ */ new Set([
|
|
867
1403
|
"rg",
|
|
@@ -891,57 +1427,114 @@ var PROBES = /* @__PURE__ */ new Set([
|
|
|
891
1427
|
"pwd"
|
|
892
1428
|
]);
|
|
893
1429
|
var GIT_PROBES = /* @__PURE__ */ new Set(["diff", "grep", "show", "log", "status", "rev-parse", "ls-files", "branch"]);
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
let
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1430
|
+
function splitShell(cmd, pipes = false) {
|
|
1431
|
+
const out = [];
|
|
1432
|
+
let cur = "";
|
|
1433
|
+
let quote = null;
|
|
1434
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
1435
|
+
const c = cmd[i];
|
|
1436
|
+
if (quote) {
|
|
1437
|
+
cur += c;
|
|
1438
|
+
if (c === "\\" && quote === '"' && i + 1 < cmd.length) cur += cmd[++i];
|
|
1439
|
+
else if (c === quote) quote = null;
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if (c === '"' || c === "'") {
|
|
1443
|
+
quote = c;
|
|
1444
|
+
cur += c;
|
|
1445
|
+
} else if (c === ";" || c === "&" && cmd[i + 1] === "&" || c === "|" && cmd[i + 1] === "|") {
|
|
1446
|
+
out.push(cur);
|
|
1447
|
+
cur = "";
|
|
1448
|
+
if (c !== ";") i++;
|
|
1449
|
+
} else if (c === "|" && pipes) {
|
|
1450
|
+
out.push(cur);
|
|
1451
|
+
cur = "";
|
|
1452
|
+
} else cur += c;
|
|
1453
|
+
}
|
|
1454
|
+
out.push(cur);
|
|
1455
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
1456
|
+
}
|
|
1457
|
+
var isCd = (s) => /^cd(\s|$)/.test(s);
|
|
1458
|
+
function isProbeStage(stage) {
|
|
1459
|
+
const words = stage.trim().split(/\s+/);
|
|
1460
|
+
const first = basename5(words[0] ?? "");
|
|
900
1461
|
if (first !== "git") return PROBES.has(first);
|
|
901
1462
|
let i = 1;
|
|
902
1463
|
while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
|
|
903
1464
|
return GIT_PROBES.has(words[i] ?? "");
|
|
904
1465
|
}
|
|
905
|
-
function
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1466
|
+
function isProbeCommand(cmd) {
|
|
1467
|
+
const stages = splitShell(cmd, true).filter((s) => !isCd(s));
|
|
1468
|
+
return stages.length > 0 && stages.every(isProbeStage);
|
|
1469
|
+
}
|
|
1470
|
+
var FAILURE_WINDOW = 5;
|
|
1471
|
+
function findLastFailure(cmds) {
|
|
1472
|
+
const real = cmds.filter((c) => !isProbeCommand(c.cmd));
|
|
1473
|
+
for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
|
|
1474
|
+
const c = real[i];
|
|
1475
|
+
if (c.exitCode === null || c.exitCode === 0) continue;
|
|
1476
|
+
const check = checkOf(c.cmd);
|
|
1477
|
+
if (check?.piped) continue;
|
|
1478
|
+
const later = (d) => {
|
|
1479
|
+
const k = checkOf(d.cmd);
|
|
1480
|
+
return d.cmd === c.cmd || !!check && !!k && k.key === check.key;
|
|
1481
|
+
};
|
|
1482
|
+
if (real.slice(i + 1).some((d) => later(d) && (d.exitCode === 0 || d.exitCode === null || checkOf(d.cmd)?.piped))) continue;
|
|
1483
|
+
return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
|
|
910
1484
|
}
|
|
911
1485
|
return null;
|
|
912
1486
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1487
|
+
var RESULTS_MAX = 12;
|
|
1488
|
+
var REFERENCES_MAX = 15;
|
|
1489
|
+
var REDIRECT_RE = /\s+(?:\d?>>?|&>)\s*(?:&\d|\/dev\/null)/g;
|
|
1490
|
+
function checkOf(cmd) {
|
|
1491
|
+
let piped = false;
|
|
1492
|
+
const parts = splitShell(cmd).filter((s) => !isCd(s) && !isProbeCommand(s)).map((s) => {
|
|
1493
|
+
const stages = splitShell(s, true);
|
|
1494
|
+
if (stages.length > 1) piped = true;
|
|
1495
|
+
return stages[0].replace(REDIRECT_RE, "").trim();
|
|
922
1496
|
});
|
|
923
|
-
return
|
|
924
|
-
}
|
|
925
|
-
function
|
|
926
|
-
const
|
|
927
|
-
for (const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1497
|
+
return parts.length ? { key: parts.join(" && "), piped } : null;
|
|
1498
|
+
}
|
|
1499
|
+
function commandResults(cmds) {
|
|
1500
|
+
const by = /* @__PURE__ */ new Map();
|
|
1501
|
+
for (const c of cmds) {
|
|
1502
|
+
const check = checkOf(c.cmd);
|
|
1503
|
+
if (!check) continue;
|
|
1504
|
+
const r = by.get(check.key) ?? { cmd: check.key, runs: 0, failed: 0, last: "unknown" };
|
|
1505
|
+
by.delete(check.key);
|
|
1506
|
+
r.runs++;
|
|
1507
|
+
const known = c.exitCode !== null && !check.piped;
|
|
1508
|
+
if (known && c.exitCode !== 0) r.failed++;
|
|
1509
|
+
r.last = !known ? "unknown" : c.exitCode === 0 ? "passed" : "failed";
|
|
1510
|
+
by.set(check.key, r);
|
|
1511
|
+
}
|
|
1512
|
+
return [...by.values()].slice(-RESULTS_MAX);
|
|
1513
|
+
}
|
|
1514
|
+
function dropPartialLine(output) {
|
|
1515
|
+
if (!output.startsWith("\u2026")) return output;
|
|
1516
|
+
const nl = output.indexOf("\n");
|
|
1517
|
+
return nl === -1 ? "" : output.slice(nl + 1);
|
|
1518
|
+
}
|
|
1519
|
+
function buildTranscript(s, title, rel, redactor) {
|
|
1520
|
+
const r = (x) => redactor.apply(x);
|
|
1521
|
+
const lines2 = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
|
|
937
1522
|
for (const t of s.timeline) {
|
|
938
|
-
if (t.kind === "user")
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1523
|
+
if (t.kind === "user") {
|
|
1524
|
+
const { handoff, rest } = splitHandoffPrompt(t.text);
|
|
1525
|
+
if (handoff) lines2.push("**Harlay handoff prompt** (the task came from the previous agent in a .harlay file)", "");
|
|
1526
|
+
if (rest) lines2.push("**User:**", r(rest), "");
|
|
1527
|
+
} else if (t.kind === "assistant") {
|
|
1528
|
+
const text = r(t.text);
|
|
1529
|
+
lines2.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
|
|
1530
|
+
} else if (t.kind === "command") lines2.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
|
|
1531
|
+
else if (t.kind === "edit") lines2.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
|
|
1532
|
+
}
|
|
1533
|
+
const out = lines2.join("\n");
|
|
1534
|
+
if (out.length <= TRANSCRIPT_MAX) return out;
|
|
1535
|
+
const cut = out.slice(out.length - TRANSCRIPT_MAX);
|
|
1536
|
+
const nl = cut.indexOf("\n");
|
|
1537
|
+
return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
|
|
945
1538
|
}
|
|
946
1539
|
function extractHandoff(s, git2, o) {
|
|
947
1540
|
const cwd = s.ref.cwd;
|
|
@@ -952,39 +1545,52 @@ function extractHandoff(s, git2, o) {
|
|
|
952
1545
|
const r = relative3(workdir, p);
|
|
953
1546
|
return r.startsWith("..") || isAbsolute(r) ? p : r;
|
|
954
1547
|
};
|
|
955
|
-
const
|
|
956
|
-
const prompts =
|
|
1548
|
+
const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
|
|
1549
|
+
const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
|
|
957
1550
|
const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
|
|
958
1551
|
const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
|
|
959
1552
|
const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
|
|
960
|
-
const
|
|
961
|
-
const
|
|
962
|
-
const
|
|
963
|
-
const
|
|
1553
|
+
const redactor = new Redactor();
|
|
1554
|
+
const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
|
|
1555
|
+
const prev = o.previous;
|
|
1556
|
+
const started = split.some((p) => p.handoff);
|
|
1557
|
+
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);
|
|
1558
|
+
const sameTarget = !prev?.target || prev.target === s.ref.tool;
|
|
1559
|
+
const fromPrev = prev ? started && (startedFrom === null ? sameTarget : startedFrom === prev.id) : false;
|
|
1560
|
+
const carried = prev && prev.goal !== null && fromPrev ? prev : null;
|
|
1561
|
+
const carriedMessages = carried ? carried.userMessages.map((m) => redactor.apply(m)) : [];
|
|
1562
|
+
const picked = pickUserMessages([...carriedMessages, ...realPrompts]);
|
|
1563
|
+
const userMessages = picked.messages;
|
|
1564
|
+
const omittedMessages = picked.omitted + (carried?.omittedMessages ?? 0);
|
|
1565
|
+
const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
|
|
964
1566
|
const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
|
|
965
|
-
const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
|
|
966
1567
|
const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
|
|
967
|
-
const
|
|
968
|
-
const
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
|
|
1568
|
+
const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
|
|
1569
|
+
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);
|
|
1570
|
+
const failure = findLastFailure(cmds);
|
|
1571
|
+
const results = commandResults(cmds).map((r) => ({ ...r, cmd: redactor.apply(r.cmd) }));
|
|
1572
|
+
const references = { read: [], attached: [], urls: [] };
|
|
1573
|
+
for (const t of s.timeline) {
|
|
1574
|
+
if (t.kind !== "read") continue;
|
|
1575
|
+
const list = t.how === "fetched" ? references.urls : t.how === "attached" ? references.attached : references.read;
|
|
1576
|
+
const p = redactor.apply(t.how === "fetched" ? t.path : rel(t.path));
|
|
1577
|
+
if (!list.includes(p)) list.push(p);
|
|
1578
|
+
}
|
|
1579
|
+
for (const k of ["read", "attached", "urls"]) references[k] = references[k].slice(-REFERENCES_MAX);
|
|
1580
|
+
const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
|
|
1581
|
+
const filesTouched = [];
|
|
1582
|
+
for (const e of [...edits].reverse()) {
|
|
972
1583
|
const r = rel(e.path);
|
|
973
|
-
if (!
|
|
974
|
-
|
|
1584
|
+
if (!isAbsolute(r) && !filesTouched.includes(r)) filesTouched.push(r);
|
|
1585
|
+
if (filesTouched.length === 12) break;
|
|
975
1586
|
}
|
|
976
|
-
const
|
|
977
|
-
const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
|
|
978
|
-
const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
|
|
979
|
-
const redactor = new Redactor();
|
|
980
|
-
const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
|
|
1587
|
+
const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
|
|
981
1588
|
const pad = (n2) => String(n2).padStart(2, "0");
|
|
982
1589
|
const n = o.now;
|
|
983
1590
|
const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
|
|
984
1591
|
return {
|
|
985
1592
|
baseId,
|
|
986
1593
|
title,
|
|
987
|
-
status: "in_progress",
|
|
988
1594
|
createdAt: o.now,
|
|
989
1595
|
from: {
|
|
990
1596
|
tool: s.ref.tool,
|
|
@@ -996,294 +1602,26 @@ function extractHandoff(s, git2, o) {
|
|
|
996
1602
|
},
|
|
997
1603
|
workdir,
|
|
998
1604
|
git: git2,
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
transcript: buildTranscript(s, title, rel),
|
|
1010
|
-
unknownRecords: s.unknownRecords
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
// ../core/src/brief/render.ts
|
|
1015
|
-
var BUDGET = { goal: 150, latest: 100, next: 200, blocker: 250, progress: 300, files: 300, decisions: 250, deadEnds: 200 };
|
|
1016
|
-
var REASON_TEXT = {
|
|
1017
|
-
usage_limit: "it hit a usage limit",
|
|
1018
|
-
crash: "it stopped with an error",
|
|
1019
|
-
manual: "the user handed it off"
|
|
1020
|
-
};
|
|
1021
|
-
var j = (v) => JSON.stringify(v ?? null);
|
|
1022
|
-
function quote(s) {
|
|
1023
|
-
return s.split("\n").map((l) => l.trim() ? `> ${l}` : ">").join("\n");
|
|
1024
|
-
}
|
|
1025
|
-
function listWithin(lines, maxTokens, pointer) {
|
|
1026
|
-
const max = maxTokens * CHARS_PER_TOKEN;
|
|
1027
|
-
const out = [];
|
|
1028
|
-
let used = 0;
|
|
1029
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1030
|
-
const line = lines[i];
|
|
1031
|
-
if (out.length > 0 && used + line.length + 1 > max) {
|
|
1032
|
-
out.push(`- \u2026 ${lines.length - i} more (see \`${pointer}\`)`);
|
|
1033
|
-
break;
|
|
1034
|
-
}
|
|
1035
|
-
out.push(line);
|
|
1036
|
-
used += line.length + 1;
|
|
1037
|
-
}
|
|
1038
|
-
return out;
|
|
1039
|
-
}
|
|
1040
|
-
function tailWithin(s, maxTokens) {
|
|
1041
|
-
const max = maxTokens * CHARS_PER_TOKEN;
|
|
1042
|
-
const t = s.trimEnd();
|
|
1043
|
-
if (t.length <= max) return t;
|
|
1044
|
-
const cut = t.slice(t.length - max);
|
|
1045
|
-
const nl = cut.indexOf("\n");
|
|
1046
|
-
return "\u2026\n" + (nl >= 0 ? cut.slice(nl + 1) : cut);
|
|
1047
|
-
}
|
|
1048
|
-
function fenceFor(body) {
|
|
1049
|
-
const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((r) => r.length));
|
|
1050
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
1051
|
-
}
|
|
1052
|
-
function fileLine(f) {
|
|
1053
|
-
const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
|
|
1054
|
-
return `- \`${f.path}\` (${f.status}${counts})`;
|
|
1055
|
-
}
|
|
1056
|
-
function renderBrief(d, id, opts = {}) {
|
|
1057
|
-
const dir = `.harlay/handoffs/${id}`;
|
|
1058
|
-
const transcript = `${dir}/transcript.md`;
|
|
1059
|
-
const front = [
|
|
1060
|
-
"---",
|
|
1061
|
-
"harlay: 1",
|
|
1062
|
-
`id: ${j(id)}`,
|
|
1063
|
-
`status: ${j(d.status)}`,
|
|
1064
|
-
`created: ${j(d.createdAt.toISOString())}`,
|
|
1065
|
-
"from:",
|
|
1066
|
-
` tool: ${j(d.from.tool)}`,
|
|
1067
|
-
` model: ${j(d.from.model)}`,
|
|
1068
|
-
` session: ${j(d.from.session)}`,
|
|
1069
|
-
` reason: ${j(d.from.reason)}`,
|
|
1070
|
-
` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
|
|
1071
|
-
"repo:",
|
|
1072
|
-
` path: ${j(d.workdir)}`,
|
|
1073
|
-
...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"],
|
|
1074
|
-
"---"
|
|
1075
|
-
];
|
|
1076
|
-
const header = [
|
|
1077
|
-
`# Continue: ${d.title}`,
|
|
1078
|
-
"",
|
|
1079
|
-
`> 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]}).`,
|
|
1080
|
-
"> This brief was extracted from its session log and the repo.",
|
|
1081
|
-
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."
|
|
1082
|
-
];
|
|
1083
|
-
const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
|
|
1084
|
-
if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
|
|
1085
|
-
const next = ["## 2. Next step"];
|
|
1086
|
-
if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
|
|
1087
|
-
else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
|
|
1088
|
-
else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
|
|
1089
|
-
const blocker = ["## 3. Blocker"];
|
|
1090
|
-
if (d.blocker) {
|
|
1091
|
-
const body = tailWithin(d.blocker.output, BUDGET.blocker);
|
|
1092
|
-
const fence = fenceFor(body);
|
|
1093
|
-
blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
|
|
1094
|
-
} else {
|
|
1095
|
-
blocker.push("No failing command recorded.");
|
|
1096
|
-
}
|
|
1097
|
-
const progress = ["## 4. Progress"];
|
|
1098
|
-
if (d.progressSource === "none") progress.push("Nothing recorded yet.");
|
|
1099
|
-
else {
|
|
1100
|
-
if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
|
|
1101
|
-
progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
|
|
1102
|
-
}
|
|
1103
|
-
const files = [];
|
|
1104
|
-
if (d.git) {
|
|
1105
|
-
files.push("## 5. Changed files (uncommitted)");
|
|
1106
|
-
if (d.git.files.length === 0) files.push("Working tree is clean.");
|
|
1107
|
-
else files.push(...listWithin(d.git.files.map(fileLine), BUDGET.files, `${dir}/diff.patch`), `Full diff: \`${dir}/diff.patch\``);
|
|
1108
|
-
} else {
|
|
1109
|
-
files.push("## 5. Changed files", "Not a git repository." + (d.editedFiles.length ? " Files the agent edited:" : ""));
|
|
1110
|
-
files.push(...listWithin(d.editedFiles.map((f) => `- \`${f}\``), BUDGET.files, transcript));
|
|
1111
|
-
}
|
|
1112
|
-
const decisions = [
|
|
1113
|
-
"## 6. Decisions and constraints",
|
|
1114
|
-
...d.decisions.length ? listWithin(d.decisions.map((s) => `- "${oneLine(s, 300)}"`), BUDGET.decisions, transcript) : ["None recorded."]
|
|
1115
|
-
];
|
|
1116
|
-
const deadEnds = [
|
|
1117
|
-
"## 7. Dead ends (don't retry)",
|
|
1118
|
-
...d.deadEnds.length ? listWithin(d.deadEnds.map((e) => `- \`${oneLine(e.cmd, 160)}\` failed (exit ${e.exitCode})`), BUDGET.deadEnds, transcript) : ["None recorded."]
|
|
1119
|
-
];
|
|
1120
|
-
const more = ["## 8. Read more", `- Condensed transcript: \`${transcript}\``];
|
|
1121
|
-
if (opts.previousBrief) more.push(`- Previous brief (as last updated by the previous agent): \`${dir}/previous-HANDOFF.md\``);
|
|
1122
|
-
if (d.readFirst.length) more.push(`- Read these first: ${d.readFirst.map((f) => `\`${f}\``).join(", ")}`);
|
|
1123
|
-
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."];
|
|
1124
|
-
return [front, header, goal, next, blocker, progress, files, decisions, deadEnds, more, stop].map((s) => s.join("\n")).join("\n\n") + "\n";
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// ../core/src/brief/write.ts
|
|
1128
|
-
import { access, appendFile, mkdir, readFile as readFile4, writeFile } from "node:fs/promises";
|
|
1129
|
-
import { dirname, isAbsolute as isAbsolute2, join as join5 } from "node:path";
|
|
1130
|
-
async function allocateHandoffId(handoffsDir, baseId) {
|
|
1131
|
-
for (let i = 1; i < 1e3; i++) {
|
|
1132
|
-
const id = i === 1 ? baseId : `${baseId}-${i}`;
|
|
1133
|
-
try {
|
|
1134
|
-
await access(join5(handoffsDir, id));
|
|
1135
|
-
} catch {
|
|
1136
|
-
return id;
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
throw new HarlayError(`Could not allocate a handoff folder for ${baseId}`);
|
|
1140
|
-
}
|
|
1141
|
-
async function ensureGitExclude(workdir) {
|
|
1142
|
-
const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
1143
|
-
const file = isAbsolute2(p) ? p : join5(workdir, p);
|
|
1144
|
-
let current = "";
|
|
1145
|
-
try {
|
|
1146
|
-
current = await readFile4(file, "utf8");
|
|
1147
|
-
} catch {
|
|
1148
|
-
current = "";
|
|
1149
|
-
}
|
|
1150
|
-
if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
|
|
1151
|
-
await mkdir(dirname(file), { recursive: true });
|
|
1152
|
-
await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
|
|
1153
|
-
`);
|
|
1154
|
-
}
|
|
1155
|
-
async function readHandoffState(workdir) {
|
|
1156
|
-
let raw;
|
|
1157
|
-
try {
|
|
1158
|
-
raw = JSON.parse(await readFile4(join5(workdir, ".harlay", "state.json"), "utf8"));
|
|
1159
|
-
} catch {
|
|
1160
|
-
return null;
|
|
1161
|
-
}
|
|
1162
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
1163
|
-
const s = raw;
|
|
1164
|
-
if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
|
|
1165
|
-
if (s.goal !== null && typeof s.goal !== "string") return null;
|
|
1166
|
-
return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
|
|
1167
|
-
}
|
|
1168
|
-
async function readIfExists(file) {
|
|
1169
|
-
try {
|
|
1170
|
-
return await readFile4(file, "utf8");
|
|
1171
|
-
} catch {
|
|
1172
|
-
return null;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
async function writeHandoff(d) {
|
|
1176
|
-
const harlayDir = join5(d.workdir, ".harlay");
|
|
1177
|
-
const latestPath = join5(harlayDir, "HANDOFF.md");
|
|
1178
|
-
const id = await allocateHandoffId(join5(harlayDir, "handoffs"), d.baseId);
|
|
1179
|
-
const dir = join5(harlayDir, "handoffs", id);
|
|
1180
|
-
await mkdir(dir, { recursive: true });
|
|
1181
|
-
const r = new Redactor();
|
|
1182
|
-
const previous = await readIfExists(latestPath);
|
|
1183
|
-
if (previous !== null) await writeFile(join5(dir, "previous-HANDOFF.md"), r.apply(previous));
|
|
1184
|
-
const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
|
|
1185
|
-
const briefPath = join5(dir, "HANDOFF.md");
|
|
1186
|
-
await writeFile(briefPath, brief);
|
|
1187
|
-
await writeFile(join5(dir, "transcript.md"), r.apply(d.transcript));
|
|
1188
|
-
if (d.git) await writeFile(join5(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1189
|
-
if (d.blocker) await writeFile(join5(dir, "last-error.txt"), r.apply(d.blocker.output));
|
|
1190
|
-
const from = {
|
|
1191
|
-
...d.from,
|
|
1192
|
-
message: d.from.message === null ? null : r.apply(d.from.message),
|
|
1193
|
-
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
|
|
1194
|
-
};
|
|
1195
|
-
const goal = d.goal === null ? null : r.apply(d.goal);
|
|
1196
|
-
const title = r.apply(d.title);
|
|
1197
|
-
const meta = {
|
|
1198
|
-
harlay: 1,
|
|
1199
|
-
id,
|
|
1200
|
-
createdAt: d.createdAt.toISOString(),
|
|
1201
|
-
title,
|
|
1202
|
-
goal,
|
|
1203
|
-
from,
|
|
1204
|
-
workdir: d.workdir,
|
|
1205
|
-
unknownRecords: d.unknownRecords,
|
|
1206
|
-
git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
|
|
1207
|
-
redactions: { ...r.counts }
|
|
1208
|
-
};
|
|
1209
|
-
await writeFile(join5(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
1210
|
-
await writeFile(latestPath, brief);
|
|
1211
|
-
const state = { harlay: 1, lastId: id, goal, title };
|
|
1212
|
-
await writeFile(join5(harlayDir, "state.json"), JSON.stringify(state, null, 2) + "\n");
|
|
1213
|
-
if (d.git) await ensureGitExclude(d.workdir);
|
|
1214
|
-
return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// ../core/src/launch/launchers.ts
|
|
1218
|
-
import { execFile as execFile2 } from "node:child_process";
|
|
1219
|
-
import { chmod, mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1220
|
-
import { dirname as dirname2, join as join6 } from "node:path";
|
|
1221
|
-
import { promisify as promisify2 } from "node:util";
|
|
1222
|
-
var pexec2 = promisify2(execFile2);
|
|
1223
|
-
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
1224
|
-
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
1225
|
-
var defaultRunner = {
|
|
1226
|
-
async open(args) {
|
|
1227
|
-
await pexec2("/usr/bin/open", args);
|
|
1228
|
-
},
|
|
1229
|
-
async writeExecutable(path, content) {
|
|
1230
|
-
await mkdir2(dirname2(path), { recursive: true });
|
|
1231
|
-
await writeFile2(path, content, { mode: 493 });
|
|
1232
|
-
await chmod(path, 493);
|
|
1233
|
-
},
|
|
1234
|
-
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
1235
|
-
};
|
|
1236
|
-
function shq(s) {
|
|
1237
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1238
|
-
}
|
|
1239
|
-
var enc = encodeURIComponent;
|
|
1240
|
-
function planLaunch(o) {
|
|
1241
|
-
if (o.mode === "app") {
|
|
1242
|
-
switch (o.target) {
|
|
1243
|
-
case "claude-code":
|
|
1244
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
1245
|
-
case "codex":
|
|
1246
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
1247
|
-
case "cursor":
|
|
1248
|
-
return {
|
|
1249
|
-
target: o.target,
|
|
1250
|
-
mode: o.mode,
|
|
1251
|
-
steps: [
|
|
1252
|
-
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
1253
|
-
{ kind: "wait", ms: 1500 },
|
|
1254
|
-
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
1255
|
-
]
|
|
1256
|
-
};
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
const bin = o.binaries[o.target];
|
|
1260
|
-
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
1261
|
-
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
1262
|
-
const path = join6(o.scriptDir, `continue-${o.target}.command`);
|
|
1263
|
-
const content = `#!/bin/zsh -l
|
|
1264
|
-
cd -- ${shq(o.cwd)} || exit 1
|
|
1265
|
-
exec ${argv.map(shq).join(" ")}
|
|
1266
|
-
`;
|
|
1267
|
-
return {
|
|
1268
|
-
target: o.target,
|
|
1269
|
-
mode: o.mode,
|
|
1270
|
-
steps: [
|
|
1271
|
-
{ kind: "script", path, content },
|
|
1272
|
-
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
1273
|
-
]
|
|
1605
|
+
originalGoal,
|
|
1606
|
+
userMessages,
|
|
1607
|
+
omittedMessages,
|
|
1608
|
+
whereItStopped,
|
|
1609
|
+
lastRequest: userMessages.at(-1) ?? null,
|
|
1610
|
+
todos: openTodos,
|
|
1611
|
+
results,
|
|
1612
|
+
references,
|
|
1613
|
+
filesTouched,
|
|
1614
|
+
lastFailure,
|
|
1615
|
+
transcript: buildTranscript(s, title, rel, redactor),
|
|
1616
|
+
unknownRecords: s.unknownRecords,
|
|
1617
|
+
redactions: { ...redactor.counts },
|
|
1618
|
+
carriedFrom: carried ? carried.id : null
|
|
1274
1619
|
};
|
|
1275
1620
|
}
|
|
1276
|
-
async function executeLaunch(plan, runner = defaultRunner) {
|
|
1277
|
-
for (const step of plan.steps) {
|
|
1278
|
-
if (step.kind === "open") await runner.open(step.args);
|
|
1279
|
-
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
1280
|
-
else await runner.sleep(step.ms);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
1621
|
|
|
1284
1622
|
// ../core/src/launch/resolve.ts
|
|
1285
1623
|
import { constants } from "node:fs";
|
|
1286
|
-
import { access
|
|
1624
|
+
import { access, stat as stat7 } from "node:fs/promises";
|
|
1287
1625
|
import { homedir } from "node:os";
|
|
1288
1626
|
import { join as join7 } from "node:path";
|
|
1289
1627
|
function defaultResolveOptions(env = process.env) {
|
|
@@ -1310,8 +1648,8 @@ function knownPaths(tool, o) {
|
|
|
1310
1648
|
}
|
|
1311
1649
|
async function isExecutable(p) {
|
|
1312
1650
|
try {
|
|
1313
|
-
if (!(await
|
|
1314
|
-
await
|
|
1651
|
+
if (!(await stat7(p)).isFile()) return false;
|
|
1652
|
+
await access(p, constants.X_OK);
|
|
1315
1653
|
return true;
|
|
1316
1654
|
} catch {
|
|
1317
1655
|
return false;
|
|
@@ -1393,47 +1731,93 @@ function parseSessionSpec(spec) {
|
|
|
1393
1731
|
}
|
|
1394
1732
|
|
|
1395
1733
|
// ../core/src/handoff.ts
|
|
1396
|
-
import { stat as
|
|
1734
|
+
import { stat as stat8 } from "node:fs/promises";
|
|
1735
|
+
import { tmpdir } from "node:os";
|
|
1736
|
+
import { join as join8 } from "node:path";
|
|
1737
|
+
async function launchHandoff(o) {
|
|
1738
|
+
const binaries = await resolveBinaries(o.resolve ?? defaultResolveOptions());
|
|
1739
|
+
const plan = planLaunch({
|
|
1740
|
+
target: o.target,
|
|
1741
|
+
mode: o.mode,
|
|
1742
|
+
cwd: o.cwd,
|
|
1743
|
+
prompt: o.prompt,
|
|
1744
|
+
scriptDir: join8(o.scriptDir ?? join8(tmpdir(), "harlay"), o.id),
|
|
1745
|
+
binaries,
|
|
1746
|
+
terminalApp: o.terminalApp ?? "Terminal"
|
|
1747
|
+
});
|
|
1748
|
+
await executeLaunch(plan, o.runner);
|
|
1749
|
+
return plan;
|
|
1750
|
+
}
|
|
1397
1751
|
async function runHandoff(o) {
|
|
1398
1752
|
const cwd = o.session.cwd;
|
|
1399
1753
|
if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
|
|
1400
1754
|
try {
|
|
1401
|
-
if (!(await
|
|
1755
|
+
if (!(await stat8(cwd)).isDirectory()) throw new Error("not a directory");
|
|
1402
1756
|
} catch {
|
|
1403
1757
|
throw new HarlayError(`The session's folder no longer exists: ${cwd}`);
|
|
1404
1758
|
}
|
|
1405
1759
|
const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
|
|
1406
1760
|
const git2 = await takeGitSnapshot(cwd);
|
|
1407
|
-
const
|
|
1408
|
-
const previous = state ? { id: state.lastId, goal: state.goal, title: state.title } : null;
|
|
1761
|
+
const previous = await readPreviousHandoff(o.store, git2?.root ?? cwd);
|
|
1409
1762
|
const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
|
|
1410
|
-
const written = await writeHandoff(data);
|
|
1763
|
+
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
|
|
1411
1764
|
if (o.launch === false) return { written, plan: null, data };
|
|
1412
1765
|
let plan;
|
|
1413
1766
|
try {
|
|
1414
|
-
|
|
1415
|
-
plan = planLaunch({
|
|
1416
|
-
target: o.target,
|
|
1417
|
-
mode: o.mode,
|
|
1418
|
-
cwd: data.workdir,
|
|
1419
|
-
prompt: HANDOFF_PROMPT,
|
|
1420
|
-
scriptDir: written.dir,
|
|
1421
|
-
binaries,
|
|
1422
|
-
terminalApp: o.terminalApp ?? "Terminal"
|
|
1423
|
-
});
|
|
1424
|
-
await executeLaunch(plan, o.runner);
|
|
1767
|
+
plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
|
|
1425
1768
|
} catch (e) {
|
|
1426
1769
|
const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
|
|
1427
|
-
throw new LaunchError(`${reason}. The
|
|
1770
|
+
throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
|
|
1428
1771
|
}
|
|
1429
1772
|
return { written, plan, data };
|
|
1430
1773
|
}
|
|
1431
1774
|
|
|
1775
|
+
// ../core/src/delivery.ts
|
|
1776
|
+
var SLACK_MS = 2e3;
|
|
1777
|
+
function firstPrompt(s) {
|
|
1778
|
+
const t = s.timeline.find((x) => x.kind === "user");
|
|
1779
|
+
return t && t.kind === "user" ? cleanPrompt(t.text) ?? t.text : null;
|
|
1780
|
+
}
|
|
1781
|
+
function hasRead(s, h) {
|
|
1782
|
+
if (s.handoffIds?.includes(h.id)) return true;
|
|
1783
|
+
return s.timeline.some(
|
|
1784
|
+
(t) => t.kind === "command" && /\.harlay\b/.test(t.run.cmd) && handoffIdIn(t.run.output) === h.id || t.kind === "read" && t.path === h.path
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
async function checkDelivery(readers, h) {
|
|
1788
|
+
const reader = readerFor(readers, h.target);
|
|
1789
|
+
const after = h.since.getTime() - SLACK_MS;
|
|
1790
|
+
let sent = null;
|
|
1791
|
+
for (const ref of await reader.listSessions(20)) {
|
|
1792
|
+
if (ref.lastActivityAt.getTime() < after) continue;
|
|
1793
|
+
let s;
|
|
1794
|
+
try {
|
|
1795
|
+
s = await reader.readSession(ref);
|
|
1796
|
+
} catch {
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
const p = firstPrompt(s);
|
|
1800
|
+
if (p === null || !splitHandoffPrompt(p).handoff || harlayFileIn(p) !== h.path) continue;
|
|
1801
|
+
if (hasRead(s, h)) return { state: "read", session: ref };
|
|
1802
|
+
sent ??= { state: "sent", session: ref };
|
|
1803
|
+
}
|
|
1804
|
+
return sent;
|
|
1805
|
+
}
|
|
1806
|
+
async function waitForDelivery(readers, h, o) {
|
|
1807
|
+
const end = Date.now() + o.timeoutMs;
|
|
1808
|
+
let last = null;
|
|
1809
|
+
for (; ; ) {
|
|
1810
|
+
last = await checkDelivery(readers, h) ?? last;
|
|
1811
|
+
if (last?.state === "read" || Date.now() >= end) return last;
|
|
1812
|
+
await new Promise((r) => setTimeout(r, Math.min(o.intervalMs ?? 2e3, Math.max(0, end - Date.now()))));
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1432
1816
|
// ../core/src/watch/watcher.ts
|
|
1433
1817
|
import { EventEmitter } from "node:events";
|
|
1434
1818
|
import { watch } from "node:fs";
|
|
1435
|
-
import { open as open2, stat as
|
|
1436
|
-
import { join as
|
|
1819
|
+
import { open as open2, stat as stat9 } from "node:fs/promises";
|
|
1820
|
+
import { join as join9 } from "node:path";
|
|
1437
1821
|
import { StringDecoder } from "node:string_decoder";
|
|
1438
1822
|
var Watcher = class extends EventEmitter {
|
|
1439
1823
|
constructor(readers, opts = {}) {
|
|
@@ -1457,14 +1841,14 @@ var Watcher = class extends EventEmitter {
|
|
|
1457
1841
|
for (const r of this.readers) {
|
|
1458
1842
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1459
1843
|
try {
|
|
1460
|
-
this.offsets.set(f.path, (await
|
|
1844
|
+
this.offsets.set(f.path, (await stat9(f.path)).size);
|
|
1461
1845
|
} catch {
|
|
1462
1846
|
}
|
|
1463
1847
|
}
|
|
1464
1848
|
if (this.opts.fsWatch === false) continue;
|
|
1465
1849
|
try {
|
|
1466
1850
|
const w = watch(r.root, { recursive: true }, (_event, name) => {
|
|
1467
|
-
if (name) this.schedule(
|
|
1851
|
+
if (name) this.schedule(join9(r.root, name.toString()));
|
|
1468
1852
|
});
|
|
1469
1853
|
w.on("error", (e) => this.emit("error", e));
|
|
1470
1854
|
this.watchers.push(w);
|
|
@@ -1485,7 +1869,7 @@ var Watcher = class extends EventEmitter {
|
|
|
1485
1869
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1486
1870
|
let size;
|
|
1487
1871
|
try {
|
|
1488
|
-
size = (await
|
|
1872
|
+
size = (await stat9(f.path)).size;
|
|
1489
1873
|
} catch {
|
|
1490
1874
|
continue;
|
|
1491
1875
|
}
|
|
@@ -1542,7 +1926,7 @@ var Watcher = class extends EventEmitter {
|
|
|
1542
1926
|
if (!reader) return [];
|
|
1543
1927
|
let size;
|
|
1544
1928
|
try {
|
|
1545
|
-
size = (await
|
|
1929
|
+
size = (await stat9(file)).size;
|
|
1546
1930
|
} catch {
|
|
1547
1931
|
return [];
|
|
1548
1932
|
}
|
|
@@ -1566,10 +1950,10 @@ var Watcher = class extends EventEmitter {
|
|
|
1566
1950
|
decoder = new StringDecoder("utf8");
|
|
1567
1951
|
this.decoders.set(file, decoder);
|
|
1568
1952
|
}
|
|
1569
|
-
const
|
|
1570
|
-
this.partial.set(file,
|
|
1953
|
+
const lines2 = ((this.partial.get(file) ?? "") + decoder.write(buf)).split("\n");
|
|
1954
|
+
this.partial.set(file, lines2.pop() ?? "");
|
|
1571
1955
|
const detected = [];
|
|
1572
|
-
for (const line of
|
|
1956
|
+
for (const line of lines2) {
|
|
1573
1957
|
if (!line.trim()) continue;
|
|
1574
1958
|
let rec;
|
|
1575
1959
|
try {
|
|
@@ -1620,30 +2004,30 @@ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
|
|
|
1620
2004
|
var PACE_MS2 = 30 * 6e4;
|
|
1621
2005
|
|
|
1622
2006
|
// ../core/src/usage/bridge.ts
|
|
1623
|
-
import { chmod as chmod2, mkdir as mkdir3, readFile as
|
|
1624
|
-
import { join as
|
|
2007
|
+
import { chmod as chmod2, mkdir as mkdir3, readFile as readFile6, rename, rm as rm2, writeFile as writeFile3 } from "node:fs/promises";
|
|
2008
|
+
import { join as join10 } from "node:path";
|
|
1625
2009
|
var BRIDGE_MARKER = "# harlay-usage";
|
|
1626
2010
|
function claudeSettingsPath(home) {
|
|
1627
|
-
return
|
|
2011
|
+
return join10(home, ".claude", "settings.json");
|
|
1628
2012
|
}
|
|
1629
|
-
var previousJson = (dataDir) =>
|
|
1630
|
-
var previousCommand = (dataDir) =>
|
|
1631
|
-
function
|
|
2013
|
+
var previousJson = (dataDir) => join10(dataDir, "claude-statusline.previous.json");
|
|
2014
|
+
var previousCommand = (dataDir) => join10(dataDir, "claude-statusline.previous");
|
|
2015
|
+
function isObj3(x) {
|
|
1632
2016
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1633
2017
|
}
|
|
1634
2018
|
function isOurs(statusLine) {
|
|
1635
|
-
return
|
|
2019
|
+
return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
|
|
1636
2020
|
}
|
|
1637
2021
|
async function readSettings(path) {
|
|
1638
2022
|
let text;
|
|
1639
2023
|
try {
|
|
1640
|
-
text = await
|
|
2024
|
+
text = await readFile6(path, "utf8");
|
|
1641
2025
|
} catch (e) {
|
|
1642
2026
|
if (e.code === "ENOENT") return {};
|
|
1643
2027
|
throw e;
|
|
1644
2028
|
}
|
|
1645
2029
|
const parsed = JSON.parse(text);
|
|
1646
|
-
if (!
|
|
2030
|
+
if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
|
|
1647
2031
|
return parsed;
|
|
1648
2032
|
}
|
|
1649
2033
|
async function writeAtomic(path, text) {
|
|
@@ -1657,23 +2041,50 @@ async function removeBridge(home, dataDir) {
|
|
|
1657
2041
|
if (!isOurs(s.statusLine)) return;
|
|
1658
2042
|
let prev;
|
|
1659
2043
|
try {
|
|
1660
|
-
prev = JSON.parse(await
|
|
2044
|
+
prev = JSON.parse(await readFile6(previousJson(dataDir), "utf8"));
|
|
1661
2045
|
} catch {
|
|
1662
2046
|
prev = void 0;
|
|
1663
2047
|
}
|
|
1664
2048
|
if (prev === void 0) delete s.statusLine;
|
|
1665
2049
|
else s.statusLine = prev;
|
|
1666
2050
|
await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
|
|
1667
|
-
await
|
|
1668
|
-
await
|
|
2051
|
+
await rm2(previousJson(dataDir), { force: true });
|
|
2052
|
+
await rm2(previousCommand(dataDir), { force: true });
|
|
1669
2053
|
}
|
|
1670
2054
|
|
|
2055
|
+
// ../core/src/health/signals.ts
|
|
2056
|
+
var SIGNALS = {
|
|
2057
|
+
/** Timeline items (messages, commands, edits, reads) looked at; about the last hour of a busy chat. */
|
|
2058
|
+
recentItems: 80,
|
|
2059
|
+
/** Recent real commands looked at for repeated failures. */
|
|
2060
|
+
recentCommands: 15,
|
|
2061
|
+
/** The same command failing this many times in a row, with no success since. */
|
|
2062
|
+
repeatFailures: 3,
|
|
2063
|
+
recentUserMessages: 6,
|
|
2064
|
+
recentAgentMessages: 8,
|
|
2065
|
+
churnEdits: 6,
|
|
2066
|
+
/** No progress: at least this many steps and failures (across 2+ commands) since the last fix or commit. */
|
|
2067
|
+
stuckSteps: 10,
|
|
2068
|
+
stuckFailures: 4,
|
|
2069
|
+
/** Reading the same file (or running the same lookup) this many times without changing it. */
|
|
2070
|
+
rereads: 3,
|
|
2071
|
+
/** Context sizes at which answers tend to get less reliable (the context rot research measures length, not %). */
|
|
2072
|
+
longTokens: 1e5,
|
|
2073
|
+
veryLongTokens: 22e4
|
|
2074
|
+
};
|
|
2075
|
+
|
|
2076
|
+
// ../core/src/health/assess.ts
|
|
2077
|
+
var HEALTH = {
|
|
2078
|
+
...SIGNALS,
|
|
2079
|
+
maxReasons: 3
|
|
2080
|
+
};
|
|
2081
|
+
|
|
1671
2082
|
// src/app.ts
|
|
1672
2083
|
import { execFile as execFile3 } from "node:child_process";
|
|
1673
2084
|
import { existsSync } from "node:fs";
|
|
1674
|
-
import { cp, mkdir as mkdir4, mkdtemp, rm as
|
|
1675
|
-
import { homedir as homedir3, tmpdir } from "node:os";
|
|
1676
|
-
import { dirname as dirname3, join as
|
|
2085
|
+
import { cp, mkdir as mkdir4, mkdtemp, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
2086
|
+
import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
|
|
2087
|
+
import { dirname as dirname3, join as join11, resolve } from "node:path";
|
|
1677
2088
|
import { fileURLToPath } from "node:url";
|
|
1678
2089
|
import { promisify as promisify3 } from "node:util";
|
|
1679
2090
|
var run = promisify3(execFile3);
|
|
@@ -1682,19 +2093,19 @@ var APP_BUNDLE_ID = "com.corelabs.harlay";
|
|
|
1682
2093
|
var APP_FILES = ["dist", "renderer", "assets"];
|
|
1683
2094
|
function findAppSource(cliFile = fileURLToPath(import.meta.url)) {
|
|
1684
2095
|
const pkg = resolve(dirname3(cliFile), "..");
|
|
1685
|
-
for (const dir of [
|
|
1686
|
-
if (existsSync(
|
|
2096
|
+
for (const dir of [join11(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
|
|
2097
|
+
if (existsSync(join11(dir, "dist", "main.cjs")) && existsSync(join11(dir, "renderer", "popover.html")) && existsSync(join11(dir, "assets", "icon.png"))) return dir;
|
|
1687
2098
|
}
|
|
1688
2099
|
return null;
|
|
1689
2100
|
}
|
|
1690
2101
|
async function makeIcns(png, work) {
|
|
1691
|
-
const set =
|
|
2102
|
+
const set = join11(work, "icon.iconset");
|
|
1692
2103
|
await mkdir4(set);
|
|
1693
2104
|
for (const size of [16, 32, 128, 256, 512]) {
|
|
1694
|
-
await run("sips", ["-z", String(size), String(size), png, "--out",
|
|
1695
|
-
await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out",
|
|
2105
|
+
await run("sips", ["-z", String(size), String(size), png, "--out", join11(set, `icon_${size}x${size}.png`)]);
|
|
2106
|
+
await run("sips", ["-z", String(size * 2), String(size * 2), png, "--out", join11(set, `icon_${size}x${size}@2x.png`)]);
|
|
1696
2107
|
}
|
|
1697
|
-
const icns =
|
|
2108
|
+
const icns = join11(work, "icon.icns");
|
|
1698
2109
|
await run("iconutil", ["-c", "icns", set, "-o", icns]);
|
|
1699
2110
|
return icns;
|
|
1700
2111
|
}
|
|
@@ -1713,7 +2124,7 @@ async function quitRunningApp() {
|
|
|
1713
2124
|
}
|
|
1714
2125
|
}
|
|
1715
2126
|
function harlayDataDir(home = homedir3()) {
|
|
1716
|
-
return
|
|
2127
|
+
return join11(home, "Library", "Application Support", "Harlay");
|
|
1717
2128
|
}
|
|
1718
2129
|
async function uninstallApp(opts) {
|
|
1719
2130
|
await quitRunningApp();
|
|
@@ -1724,7 +2135,7 @@ async function uninstallApp(opts) {
|
|
|
1724
2135
|
opts.log(`Left ~/.claude/settings.json unchanged: ${e instanceof Error ? e.message : String(e)}
|
|
1725
2136
|
`);
|
|
1726
2137
|
}
|
|
1727
|
-
const app =
|
|
2138
|
+
const app = join11(opts.dir, "Harlay.app");
|
|
1728
2139
|
if (!existsSync(app)) {
|
|
1729
2140
|
opts.log(`No Harlay.app in ${opts.dir}.
|
|
1730
2141
|
`);
|
|
@@ -1738,21 +2149,21 @@ async function installApp(opts) {
|
|
|
1738
2149
|
if (process.platform !== "darwin") throw new Error("The Harlay app is macOS only; the CLI works without it.");
|
|
1739
2150
|
const source = findAppSource();
|
|
1740
2151
|
if (!source) throw new Error("App files are missing from this install. Reinstall with `npm install -g harlay`.");
|
|
1741
|
-
const work = await mkdtemp(
|
|
2152
|
+
const work = await mkdtemp(join11(tmpdir2(), "harlay-app-"));
|
|
1742
2153
|
try {
|
|
1743
|
-
const appSrc =
|
|
1744
|
-
for (const f of APP_FILES) await cp(
|
|
2154
|
+
const appSrc = join11(work, "src");
|
|
2155
|
+
for (const f of APP_FILES) await cp(join11(source, f), join11(appSrc, f), { recursive: true });
|
|
1745
2156
|
await writeFile4(
|
|
1746
|
-
|
|
2157
|
+
join11(appSrc, "package.json"),
|
|
1747
2158
|
JSON.stringify({ name: "harlay-desktop", productName: "Harlay", version: opts.version, main: "dist/main.cjs" }, null, 2)
|
|
1748
2159
|
);
|
|
1749
|
-
const icon = await makeIcns(
|
|
2160
|
+
const icon = await makeIcns(join11(source, "assets", "icon.png"), work);
|
|
1750
2161
|
opts.log(`Building Harlay.app (Electron ${ELECTRON_VERSION}; the first install downloads it, about 100 MB)\u2026
|
|
1751
2162
|
`);
|
|
1752
2163
|
const { packager } = await import("@electron/packager");
|
|
1753
2164
|
const [outDir] = await packager({
|
|
1754
2165
|
dir: appSrc,
|
|
1755
|
-
out:
|
|
2166
|
+
out: join11(work, "out"),
|
|
1756
2167
|
name: "Harlay",
|
|
1757
2168
|
platform: "darwin",
|
|
1758
2169
|
arch: process.arch === "arm64" ? "arm64" : "x64",
|
|
@@ -1767,29 +2178,29 @@ async function installApp(opts) {
|
|
|
1767
2178
|
quiet: true,
|
|
1768
2179
|
extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: "alert" }
|
|
1769
2180
|
});
|
|
1770
|
-
const built =
|
|
2181
|
+
const built = join11(outDir, "Harlay.app");
|
|
1771
2182
|
await run("codesign", ["--force", "--deep", "--sign", "-", built]);
|
|
1772
|
-
const dest =
|
|
2183
|
+
const dest = join11(opts.dir, "Harlay.app");
|
|
1773
2184
|
await quitRunningApp();
|
|
1774
2185
|
await mkdir4(opts.dir, { recursive: true });
|
|
1775
|
-
await
|
|
2186
|
+
await rm3(dest, { recursive: true, force: true });
|
|
1776
2187
|
await run("ditto", [built, dest]);
|
|
1777
2188
|
opts.log(`Installed ${dest}
|
|
1778
2189
|
`);
|
|
1779
2190
|
if (opts.open) await run("open", [dest]);
|
|
1780
2191
|
return dest;
|
|
1781
2192
|
} finally {
|
|
1782
|
-
await
|
|
2193
|
+
await rm3(work, { recursive: true, force: true });
|
|
1783
2194
|
}
|
|
1784
2195
|
}
|
|
1785
2196
|
|
|
1786
2197
|
// src/main.ts
|
|
1787
|
-
var VERSION = "0.
|
|
2198
|
+
var VERSION = "0.2.0";
|
|
1788
2199
|
var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
|
|
1789
2200
|
|
|
1790
2201
|
Usage:
|
|
1791
2202
|
harlay sessions [--limit N] [--json]
|
|
1792
|
-
harlay handoff --to <claude|codex|cursor> [--session <tool>:<id>] [--mode app|terminal] [--no-launch] [--terminal <App>]
|
|
2203
|
+
harlay handoff --to <claude|codex|cursor> [--session <tool>:<id>] [--mode app|terminal] [--no-launch] [--terminal <App>] [--wait]
|
|
1793
2204
|
harlay watch [--auto <claude|codex|cursor>] [--mode app|terminal] [--threshold N]
|
|
1794
2205
|
harlay app install [--dir <folder>] [--no-open] install or update the macOS menu bar app (default folder: /Applications)
|
|
1795
2206
|
harlay app uninstall [--dir <folder>] quit it, restore Claude Code's status line, move it to the Trash
|
|
@@ -1849,7 +2260,8 @@ async function cmdHandoff(args, readers, ctx) {
|
|
|
1849
2260
|
session: { type: "string" },
|
|
1850
2261
|
mode: { type: "string" },
|
|
1851
2262
|
"no-launch": { type: "boolean" },
|
|
1852
|
-
terminal: { type: "string" }
|
|
2263
|
+
terminal: { type: "string" },
|
|
2264
|
+
wait: { type: "boolean" }
|
|
1853
2265
|
},
|
|
1854
2266
|
strict: true
|
|
1855
2267
|
});
|
|
@@ -1857,18 +2269,20 @@ async function cmdHandoff(args, readers, ctx) {
|
|
|
1857
2269
|
const target = parseTool(values.to);
|
|
1858
2270
|
const mode = parseMode(values.mode, target);
|
|
1859
2271
|
const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir4());
|
|
2272
|
+
const since = /* @__PURE__ */ new Date();
|
|
1860
2273
|
const res = await runHandoff({
|
|
1861
2274
|
session,
|
|
1862
2275
|
target,
|
|
1863
2276
|
mode,
|
|
1864
2277
|
readers,
|
|
2278
|
+
store: handoffStore(ctx.env.HARLAY_HOME ?? homedir4()),
|
|
1865
2279
|
launch: !values["no-launch"],
|
|
1866
2280
|
terminalApp: values.terminal ?? "Terminal",
|
|
1867
2281
|
runner: ctx.runner,
|
|
1868
2282
|
resolve: defaultResolveOptions(ctx.env)
|
|
1869
2283
|
});
|
|
1870
|
-
ctx.out(`
|
|
1871
|
-
|
|
2284
|
+
ctx.out(`Handoff: ${res.written.path}
|
|
2285
|
+
Prompt: ${res.written.prompt}
|
|
1872
2286
|
`);
|
|
1873
2287
|
const kinds = Object.keys(res.written.redactions);
|
|
1874
2288
|
if (kinds.length) {
|
|
@@ -1878,8 +2292,29 @@ Archived: ${res.written.dir}
|
|
|
1878
2292
|
}
|
|
1879
2293
|
ctx.out(res.plan ? `Continuing in ${TOOL_LABEL[target]} (${mode}).
|
|
1880
2294
|
` : "Not launched (--no-launch).\n");
|
|
1881
|
-
return 0;
|
|
2295
|
+
if (!values.wait) return 0;
|
|
2296
|
+
const seconds = Number(ctx.env.HARLAY_WAIT_SECONDS ?? WAIT_SECONDS);
|
|
2297
|
+
ctx.out(`Waiting for ${TOOL_LABEL[target]} to read it (up to ${seconds} s)\u2026
|
|
2298
|
+
`);
|
|
2299
|
+
const h = { id: res.written.id, path: res.written.path, target, since };
|
|
2300
|
+
const got = await waitForDelivery(readers, h, { timeoutMs: seconds * 1e3 });
|
|
2301
|
+
const took = `${Math.round((Date.now() - since.getTime()) / 1e3)} s`;
|
|
2302
|
+
if (got?.state === "read") {
|
|
2303
|
+
ctx.out(`${TOOL_LABEL[target]} read the handoff (chat ${got.session.id.slice(0, 8)}, ${took}).
|
|
2304
|
+
`);
|
|
2305
|
+
return 0;
|
|
2306
|
+
}
|
|
2307
|
+
if (got) {
|
|
2308
|
+
ctx.out(`${TOOL_LABEL[target]} got the prompt (chat ${got.session.id.slice(0, 8)}, ${took}); its log doesn't show reading the file yet.
|
|
2309
|
+
`);
|
|
2310
|
+
return 0;
|
|
2311
|
+
}
|
|
2312
|
+
ctx.err(`${TOOL_LABEL[target]} hasn't picked up the handoff after ${seconds} s. Press Enter where the prompt is typed, or paste it into any chat:
|
|
2313
|
+
${res.written.prompt}
|
|
2314
|
+
`);
|
|
2315
|
+
return 3;
|
|
1882
2316
|
}
|
|
2317
|
+
var WAIT_SECONDS = 180;
|
|
1883
2318
|
async function cmdWatch(args, readers, ctx) {
|
|
1884
2319
|
const { values } = parseArgs({
|
|
1885
2320
|
args,
|
|
@@ -1890,6 +2325,7 @@ async function cmdWatch(args, readers, ctx) {
|
|
|
1890
2325
|
const mode = auto ? parseMode(values.mode, auto) : "app";
|
|
1891
2326
|
const threshold = values.threshold ? Number(values.threshold) : 90;
|
|
1892
2327
|
if (!(threshold > 0 && threshold <= 100)) throw new UsageError("--threshold must be between 1 and 100");
|
|
2328
|
+
const store = handoffStore(ctx.env.HARLAY_HOME ?? homedir4());
|
|
1893
2329
|
const watcher = new Watcher(readers, { usageThreshold: threshold });
|
|
1894
2330
|
watcher.on("error", (e) => ctx.err(`harlay: watcher error: ${e instanceof Error ? e.message : String(e)}
|
|
1895
2331
|
`));
|
|
@@ -1904,7 +2340,7 @@ async function cmdWatch(args, readers, ctx) {
|
|
|
1904
2340
|
ctx.out(`[${when}] ${name}: stopped \u2014 ${ev.event.halt.message}
|
|
1905
2341
|
`);
|
|
1906
2342
|
if (auto && auto !== ev.ref.tool) {
|
|
1907
|
-
runHandoff({ session: ev.ref, target: auto, mode, readers, runner: ctx.runner, resolve: defaultResolveOptions(ctx.env) }).then((r) => ctx.out(` \u2192 handed off to ${TOOL_LABEL[auto]};
|
|
2343
|
+
runHandoff({ session: ev.ref, target: auto, mode, readers, store, runner: ctx.runner, resolve: defaultResolveOptions(ctx.env) }).then((r) => ctx.out(` \u2192 handed off to ${TOOL_LABEL[auto]}; handoff at ${r.written.path}
|
|
1908
2344
|
`)).catch((e) => ctx.err(` \u2192 handoff failed: ${e instanceof Error ? e.message : String(e)}
|
|
1909
2345
|
`));
|
|
1910
2346
|
}
|