harlay 0.1.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/app/dist/main.cjs +1639 -791
- package/app/dist/preload.cjs +2 -2
- package/app/renderer/popover.css +222 -154
- package/app/renderer/popover.html +10 -8
- package/app/renderer/popover.js +363 -398
- package/app/renderer/vendor/motion-LICENSE.md +21 -0
- package/app/renderer/vendor/motion.js +1 -0
- package/dist/harlay.mjs +905 -608
- package/package.json +2 -2
package/dist/harlay.mjs
CHANGED
|
@@ -27,84 +27,7 @@ var LaunchError = class extends HarlayError {
|
|
|
27
27
|
written;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
// ../core/src/launch/launchers.ts
|
|
31
|
-
import { execFile } from "node:child_process";
|
|
32
|
-
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
33
|
-
import { dirname, join } from "node:path";
|
|
34
|
-
import { promisify } from "node:util";
|
|
35
|
-
var pexec = promisify(execFile);
|
|
36
|
-
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
37
|
-
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
38
|
-
var PROMPT_LIMIT = {
|
|
39
|
-
"claude-code": { app: 6e3, terminal: 1e5 },
|
|
40
|
-
codex: { app: 6e3, terminal: 1e5 },
|
|
41
|
-
cursor: { app: 9900, terminal: 1e5 }
|
|
42
|
-
};
|
|
43
|
-
function promptFits(target, mode, prompt) {
|
|
44
|
-
const length = mode === "app" ? encodeURIComponent(prompt).length : prompt.length;
|
|
45
|
-
return length <= PROMPT_LIMIT[target][mode];
|
|
46
|
-
}
|
|
47
|
-
var defaultRunner = {
|
|
48
|
-
async open(args) {
|
|
49
|
-
await pexec("/usr/bin/open", args);
|
|
50
|
-
},
|
|
51
|
-
async writeExecutable(path, content) {
|
|
52
|
-
await mkdir(dirname(path), { recursive: true });
|
|
53
|
-
await writeFile(path, content, { mode: 493 });
|
|
54
|
-
await chmod(path, 493);
|
|
55
|
-
},
|
|
56
|
-
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
57
|
-
};
|
|
58
|
-
function shq(s) {
|
|
59
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
60
|
-
}
|
|
61
|
-
var enc = encodeURIComponent;
|
|
62
|
-
function planLaunch(o) {
|
|
63
|
-
if (o.mode === "app") {
|
|
64
|
-
switch (o.target) {
|
|
65
|
-
case "claude-code":
|
|
66
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
67
|
-
case "codex":
|
|
68
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
69
|
-
case "cursor":
|
|
70
|
-
return {
|
|
71
|
-
target: o.target,
|
|
72
|
-
mode: o.mode,
|
|
73
|
-
steps: [
|
|
74
|
-
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
75
|
-
{ kind: "wait", ms: 1500 },
|
|
76
|
-
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
77
|
-
]
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const bin = o.binaries[o.target];
|
|
82
|
-
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
83
|
-
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
84
|
-
const path = join(o.scriptDir, `continue-${o.target}.command`);
|
|
85
|
-
const content = `#!/bin/zsh -l
|
|
86
|
-
cd -- ${shq(o.cwd)} || exit 1
|
|
87
|
-
exec ${argv.map(shq).join(" ")}
|
|
88
|
-
`;
|
|
89
|
-
return {
|
|
90
|
-
target: o.target,
|
|
91
|
-
mode: o.mode,
|
|
92
|
-
steps: [
|
|
93
|
-
{ kind: "script", path, content },
|
|
94
|
-
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
95
|
-
]
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
async function executeLaunch(plan, runner = defaultRunner) {
|
|
99
|
-
for (const step of plan.steps) {
|
|
100
|
-
if (step.kind === "open") await runner.open(step.args);
|
|
101
|
-
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
102
|
-
else await runner.sleep(step.ms);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
30
|
// ../core/src/util/text.ts
|
|
107
|
-
var CHARS_PER_TOKEN = 4;
|
|
108
31
|
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
109
32
|
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
110
33
|
function tail(s, maxChars) {
|
|
@@ -124,29 +47,45 @@ function oneLine(s, max = 120) {
|
|
|
124
47
|
if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
|
|
125
48
|
return t.slice(0, end) + "\u2026";
|
|
126
49
|
}
|
|
127
|
-
function wordCount(s) {
|
|
128
|
-
return s.split(/\s+/).filter(Boolean).length;
|
|
129
|
-
}
|
|
130
50
|
function clipWords(s, max) {
|
|
131
51
|
const w = s.split(/\s+/).filter(Boolean);
|
|
132
52
|
return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
|
|
133
53
|
}
|
|
134
|
-
function splitSentences(s) {
|
|
135
|
-
return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
|
|
136
|
-
}
|
|
137
54
|
var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
|
|
138
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;
|
|
139
57
|
function cleanPrompt(raw) {
|
|
140
58
|
let s = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(CONTEXT_BLOCK_RE, "").trim();
|
|
141
59
|
const query = /<user_query>([\s\S]*?)<\/user_query>/.exec(s);
|
|
142
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();
|
|
143
63
|
if (MACHINE_TAG_RE.test(s)) return null;
|
|
144
64
|
return s ? s : null;
|
|
145
65
|
}
|
|
66
|
+
var LEGACY_HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
146
67
|
var HANDOFF_TAG = '<handoff from="';
|
|
147
68
|
var HANDOFF_END = "</handoff>";
|
|
148
69
|
var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
|
|
149
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
|
+
}
|
|
150
89
|
function splitHandoffPrompt(p) {
|
|
151
90
|
const start = p.indexOf(HANDOFF_TAG);
|
|
152
91
|
const end = p.lastIndexOf(HANDOFF_END);
|
|
@@ -156,7 +95,9 @@ function splitHandoffPrompt(p) {
|
|
|
156
95
|
const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
157
96
|
return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
|
|
158
97
|
}
|
|
159
|
-
if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() ===
|
|
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 };
|
|
160
101
|
return { handoff: false, rest: p, id: null };
|
|
161
102
|
}
|
|
162
103
|
var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
@@ -171,12 +112,14 @@ function slugify(s, max = 40) {
|
|
|
171
112
|
}
|
|
172
113
|
|
|
173
114
|
// ../core/src/sources/claude.ts
|
|
174
|
-
import { readFile, stat as
|
|
175
|
-
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";
|
|
176
117
|
|
|
177
118
|
// ../core/src/util/files.ts
|
|
119
|
+
import { createReadStream } from "node:fs";
|
|
178
120
|
import { open, readdir, stat } from "node:fs/promises";
|
|
179
|
-
import { join
|
|
121
|
+
import { join } from "node:path";
|
|
122
|
+
import { createInterface } from "node:readline";
|
|
180
123
|
async function listSessionFiles(root, accept) {
|
|
181
124
|
let names;
|
|
182
125
|
try {
|
|
@@ -186,7 +129,7 @@ async function listSessionFiles(root, accept) {
|
|
|
186
129
|
}
|
|
187
130
|
const out = [];
|
|
188
131
|
for (const name of names) {
|
|
189
|
-
const path =
|
|
132
|
+
const path = join(root, name);
|
|
190
133
|
if (!accept(path)) continue;
|
|
191
134
|
try {
|
|
192
135
|
const s = await stat(path);
|
|
@@ -196,27 +139,14 @@ async function listSessionFiles(root, accept) {
|
|
|
196
139
|
}
|
|
197
140
|
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
198
141
|
}
|
|
199
|
-
async function
|
|
200
|
-
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 });
|
|
201
145
|
try {
|
|
202
|
-
const
|
|
203
|
-
const buf = Buffer.alloc(65536);
|
|
204
|
-
let total = 0;
|
|
205
|
-
while (total < maxBytes) {
|
|
206
|
-
const { bytesRead } = await fh.read(buf, 0, buf.length, total);
|
|
207
|
-
if (bytesRead === 0) break;
|
|
208
|
-
const slice = buf.subarray(0, bytesRead);
|
|
209
|
-
const nl = slice.indexOf(10);
|
|
210
|
-
if (nl >= 0) {
|
|
211
|
-
chunks.push(Buffer.from(slice.subarray(0, nl)));
|
|
212
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
213
|
-
}
|
|
214
|
-
chunks.push(Buffer.from(slice));
|
|
215
|
-
total += bytesRead;
|
|
216
|
-
}
|
|
217
|
-
return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
|
|
146
|
+
for await (const line of lines2) if (each(line)) break;
|
|
218
147
|
} finally {
|
|
219
|
-
|
|
148
|
+
lines2.close();
|
|
149
|
+
input.destroy();
|
|
220
150
|
}
|
|
221
151
|
}
|
|
222
152
|
|
|
@@ -249,6 +179,445 @@ function blockText(content) {
|
|
|
249
179
|
return parts.join("\n");
|
|
250
180
|
}
|
|
251
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
|
+
|
|
252
621
|
// ../core/src/sources/todos.ts
|
|
253
622
|
var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
|
|
254
623
|
function applyTodoWrite(current, input) {
|
|
@@ -269,6 +638,14 @@ function applyTodoWrite(current, input) {
|
|
|
269
638
|
|
|
270
639
|
// ../core/src/sources/claude.ts
|
|
271
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
|
+
}
|
|
272
649
|
function claudeHalt(record) {
|
|
273
650
|
if (!isObj(record) || record.type !== "assistant" || record.isApiErrorMessage !== true) return null;
|
|
274
651
|
if (record.error !== "rate_limit" && record.apiErrorStatus !== 429) return null;
|
|
@@ -281,16 +658,32 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
281
658
|
const timeline = [];
|
|
282
659
|
const pendingBash = /* @__PURE__ */ new Map();
|
|
283
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
|
+
};
|
|
284
667
|
let todos = null;
|
|
285
668
|
let halt = null;
|
|
286
669
|
let model = null;
|
|
287
670
|
let unknown = invalid;
|
|
671
|
+
const handoffIds = [];
|
|
288
672
|
for (const rec of records) {
|
|
289
673
|
if (!isObj(rec) || typeof rec.type !== "string") {
|
|
290
674
|
unknown++;
|
|
291
675
|
continue;
|
|
292
676
|
}
|
|
293
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
|
+
}
|
|
294
687
|
if (rec.type === "assistant") {
|
|
295
688
|
const h = claudeHalt(rec);
|
|
296
689
|
if (h) {
|
|
@@ -314,15 +707,31 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
314
707
|
if (c) pendingBash.set(id, c);
|
|
315
708
|
break;
|
|
316
709
|
}
|
|
317
|
-
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
|
+
}
|
|
318
717
|
case "MultiEdit": {
|
|
319
718
|
const p = str(input.file_path);
|
|
320
|
-
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" });
|
|
321
730
|
break;
|
|
322
731
|
}
|
|
323
732
|
case "NotebookEdit": {
|
|
324
733
|
const p = str(input.notebook_path);
|
|
325
|
-
if (p)
|
|
734
|
+
if (p) pushEdit(id, { path: p, kind: "modify" });
|
|
326
735
|
break;
|
|
327
736
|
}
|
|
328
737
|
case "Write": {
|
|
@@ -330,7 +739,7 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
330
739
|
if (p) {
|
|
331
740
|
const edit = { path: p, kind: "modify" };
|
|
332
741
|
pendingWrite.set(id, edit);
|
|
333
|
-
|
|
742
|
+
pushEdit(id, edit);
|
|
334
743
|
}
|
|
335
744
|
break;
|
|
336
745
|
}
|
|
@@ -356,6 +765,11 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
356
765
|
if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
357
766
|
if (b.type !== "tool_result") continue;
|
|
358
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
|
+
}
|
|
359
773
|
const w = pendingWrite.get(id);
|
|
360
774
|
if (w) {
|
|
361
775
|
pendingWrite.delete(id);
|
|
@@ -365,6 +779,10 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
365
779
|
if (cmd === void 0) continue;
|
|
366
780
|
pendingBash.delete(id);
|
|
367
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
|
+
}
|
|
368
786
|
const code = /^Exit code (\d+)/.exec(content);
|
|
369
787
|
const exitCode = code ? Number(code[1]) : b.is_error === true ? 1 : 0;
|
|
370
788
|
const r = rec.toolUseResult;
|
|
@@ -377,14 +795,14 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
377
795
|
}
|
|
378
796
|
}
|
|
379
797
|
}
|
|
380
|
-
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 };
|
|
381
799
|
}
|
|
382
800
|
async function loadDesktopTitles(dir) {
|
|
383
801
|
const out = /* @__PURE__ */ new Map();
|
|
384
|
-
const files = await listSessionFiles(dir, (f) =>
|
|
802
|
+
const files = await listSessionFiles(dir, (f) => basename3(f).startsWith("local_") && f.endsWith(".json"));
|
|
385
803
|
for (const f of files) {
|
|
386
804
|
try {
|
|
387
|
-
const d = JSON.parse(await
|
|
805
|
+
const d = JSON.parse(await readFile3(f.path, "utf8"));
|
|
388
806
|
if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
|
|
389
807
|
out.set(d.cliSessionId, d.title);
|
|
390
808
|
}
|
|
@@ -394,14 +812,14 @@ async function loadDesktopTitles(dir) {
|
|
|
394
812
|
return out;
|
|
395
813
|
}
|
|
396
814
|
function createClaudeReader(o) {
|
|
397
|
-
const root =
|
|
398
|
-
const indexDir =
|
|
815
|
+
const root = join4(o.home, ".claude", "projects");
|
|
816
|
+
const indexDir = join4(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
399
817
|
const isSessionFile = (file) => file.startsWith(root + sep) && file.endsWith(".jsonl") && relative(root, file).split(sep).length === 2;
|
|
400
818
|
const cache = /* @__PURE__ */ new Map();
|
|
401
819
|
async function refForFile(file, titles) {
|
|
402
820
|
let st;
|
|
403
821
|
try {
|
|
404
|
-
st = await
|
|
822
|
+
st = await stat4(file);
|
|
405
823
|
} catch {
|
|
406
824
|
return null;
|
|
407
825
|
}
|
|
@@ -409,17 +827,20 @@ function createClaudeReader(o) {
|
|
|
409
827
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
410
828
|
let text;
|
|
411
829
|
try {
|
|
412
|
-
text = await
|
|
830
|
+
text = await readFile3(file, "utf8");
|
|
413
831
|
} catch {
|
|
414
832
|
return null;
|
|
415
833
|
}
|
|
416
834
|
let cwd = null;
|
|
417
835
|
let custom = null;
|
|
418
836
|
let ai = null;
|
|
419
|
-
|
|
837
|
+
let first;
|
|
838
|
+
const lines2 = text.split("\n");
|
|
839
|
+
for (const line of lines2) {
|
|
420
840
|
const wantCwd = cwd === null && line.includes('"cwd"');
|
|
421
841
|
const wantTitle = line.includes('-title"');
|
|
422
|
-
|
|
842
|
+
const wantPrompt = first === void 0 && line.includes('"user"');
|
|
843
|
+
if (!wantCwd && !wantTitle && !wantPrompt) continue;
|
|
423
844
|
let r;
|
|
424
845
|
try {
|
|
425
846
|
r = JSON.parse(line);
|
|
@@ -430,11 +851,26 @@ function createClaudeReader(o) {
|
|
|
430
851
|
if (wantCwd && typeof r.cwd === "string") cwd = r.cwd;
|
|
431
852
|
if (r.type === "custom-title" && typeof r.customTitle === "string") custom = r.customTitle;
|
|
432
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
|
+
}
|
|
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
|
+
}
|
|
433
868
|
}
|
|
869
|
+
cwd = await sessionFolder(cwd, first ?? null);
|
|
434
870
|
entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
|
|
435
871
|
cache.set(file, entry);
|
|
436
872
|
}
|
|
437
|
-
const id =
|
|
873
|
+
const id = basename3(file, ".jsonl");
|
|
438
874
|
const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
|
|
439
875
|
if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
|
|
440
876
|
return entry.ref;
|
|
@@ -443,7 +879,7 @@ function createClaudeReader(o) {
|
|
|
443
879
|
tool: "claude-code",
|
|
444
880
|
root,
|
|
445
881
|
isSessionFile,
|
|
446
|
-
idFromPath: (file) => isSessionFile(file) ?
|
|
882
|
+
idFromPath: (file) => isSessionFile(file) ? basename3(file, ".jsonl") : null,
|
|
447
883
|
refForFile: (file) => refForFile(file),
|
|
448
884
|
async listSessions(limit = 20) {
|
|
449
885
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -457,7 +893,7 @@ function createClaudeReader(o) {
|
|
|
457
893
|
return refs;
|
|
458
894
|
},
|
|
459
895
|
async readSession(ref) {
|
|
460
|
-
const { records, invalid } = parseJsonLines(await
|
|
896
|
+
const { records, invalid } = parseJsonLines(await readFile3(ref.file, "utf8"));
|
|
461
897
|
return normalizeClaude(records, ref, invalid);
|
|
462
898
|
},
|
|
463
899
|
detect(record) {
|
|
@@ -470,11 +906,21 @@ function createClaudeReader(o) {
|
|
|
470
906
|
}
|
|
471
907
|
|
|
472
908
|
// ../core/src/sources/codex.ts
|
|
473
|
-
import { readFile as
|
|
474
|
-
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";
|
|
475
911
|
var OUTPUT_TAIL2 = 2e4;
|
|
476
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;
|
|
477
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
|
+
}
|
|
478
924
|
function codexCommand(command) {
|
|
479
925
|
if (typeof command === "string") return command;
|
|
480
926
|
if (!Array.isArray(command)) return "";
|
|
@@ -542,7 +988,8 @@ function normalizeCodex(records, ref, invalid = 0) {
|
|
|
542
988
|
} else if (it.type === "FileChange" && isObj(it.changes)) {
|
|
543
989
|
for (const [path, change] of Object.entries(it.changes)) {
|
|
544
990
|
const t = isObj(change) ? str(change.type) : null;
|
|
545
|
-
|
|
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) : {} } });
|
|
546
993
|
}
|
|
547
994
|
}
|
|
548
995
|
} else if (p.type === "user_message") {
|
|
@@ -573,11 +1020,25 @@ function parseSessionMeta(line) {
|
|
|
573
1020
|
const id = str(p.id);
|
|
574
1021
|
return id ? { id, cwd: str(p.cwd) } : null;
|
|
575
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
|
+
}
|
|
576
1037
|
async function loadTitles(file) {
|
|
577
1038
|
const out = /* @__PURE__ */ new Map();
|
|
578
1039
|
let text;
|
|
579
1040
|
try {
|
|
580
|
-
text = await
|
|
1041
|
+
text = await readFile4(file, "utf8");
|
|
581
1042
|
} catch {
|
|
582
1043
|
return out;
|
|
583
1044
|
}
|
|
@@ -587,28 +1048,34 @@ async function loadTitles(file) {
|
|
|
587
1048
|
return out;
|
|
588
1049
|
}
|
|
589
1050
|
function createCodexReader(o) {
|
|
590
|
-
const root =
|
|
591
|
-
const indexFile =
|
|
592
|
-
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");
|
|
593
1054
|
const cache = /* @__PURE__ */ new Map();
|
|
594
1055
|
async function refForFile(file, titles) {
|
|
595
1056
|
let st;
|
|
596
1057
|
try {
|
|
597
|
-
st = await
|
|
1058
|
+
st = await stat5(file);
|
|
598
1059
|
} catch {
|
|
599
1060
|
return null;
|
|
600
1061
|
}
|
|
601
1062
|
let entry = cache.get(file);
|
|
602
1063
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
603
|
-
let
|
|
1064
|
+
let meta = null;
|
|
1065
|
+
let first = null;
|
|
1066
|
+
let lines2 = 0;
|
|
604
1067
|
try {
|
|
605
|
-
|
|
1068
|
+
await eachLine(file, (line) => {
|
|
1069
|
+
if (lines2++ === 0) return (meta = parseSessionMeta(line)) === null;
|
|
1070
|
+
first = typedPrompt(line);
|
|
1071
|
+
return first !== null;
|
|
1072
|
+
});
|
|
606
1073
|
} catch {
|
|
607
1074
|
return null;
|
|
608
1075
|
}
|
|
609
|
-
const meta = first ? parseSessionMeta(first) : null;
|
|
610
1076
|
if (!meta) return null;
|
|
611
|
-
|
|
1077
|
+
const { id, cwd } = meta;
|
|
1078
|
+
entry = { size: st.size, mtimeMs: st.mtimeMs, id, cwd: await sessionFolder(cwd, first), ref: null };
|
|
612
1079
|
cache.set(file, entry);
|
|
613
1080
|
}
|
|
614
1081
|
const title = (titles ?? await loadTitles(indexFile)).get(entry.id) ?? null;
|
|
@@ -619,7 +1086,7 @@ function createCodexReader(o) {
|
|
|
619
1086
|
tool: "codex",
|
|
620
1087
|
root,
|
|
621
1088
|
isSessionFile,
|
|
622
|
-
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(
|
|
1089
|
+
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec(basename4(file))?.[1] ?? null : null,
|
|
623
1090
|
refForFile: (file) => refForFile(file),
|
|
624
1091
|
async listSessions(limit = 20) {
|
|
625
1092
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -633,7 +1100,7 @@ function createCodexReader(o) {
|
|
|
633
1100
|
return refs;
|
|
634
1101
|
},
|
|
635
1102
|
async readSession(ref) {
|
|
636
|
-
const { records, invalid } = parseJsonLines(await
|
|
1103
|
+
const { records, invalid } = parseJsonLines(await readFile4(ref.file, "utf8"));
|
|
637
1104
|
return normalizeCodex(records, ref, invalid);
|
|
638
1105
|
},
|
|
639
1106
|
detect(record) {
|
|
@@ -658,21 +1125,21 @@ function createCodexReader(o) {
|
|
|
658
1125
|
}
|
|
659
1126
|
|
|
660
1127
|
// ../core/src/sources/cursor.ts
|
|
661
|
-
import { readdir as
|
|
662
|
-
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";
|
|
663
1130
|
var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
|
|
664
1131
|
async function resolveCursorSlug(slug, rootDir = "/") {
|
|
665
1132
|
if (!slug || /^\d+$/.test(slug)) return null;
|
|
666
1133
|
async function walk(dir, rest, depth) {
|
|
667
1134
|
if (depth > 16) return null;
|
|
668
|
-
const entries = await
|
|
1135
|
+
const entries = await readdir3(dir, { withFileTypes: true }).catch(() => null);
|
|
669
1136
|
if (!entries) return null;
|
|
670
1137
|
for (const e of entries) {
|
|
671
1138
|
if (!e.isDirectory()) continue;
|
|
672
1139
|
const n = norm(e.name);
|
|
673
|
-
if (rest === n) return
|
|
1140
|
+
if (rest === n) return join6(dir, e.name);
|
|
674
1141
|
if (rest.startsWith(n + "-")) {
|
|
675
|
-
const found = await walk(
|
|
1142
|
+
const found = await walk(join6(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
676
1143
|
if (found) return found;
|
|
677
1144
|
}
|
|
678
1145
|
}
|
|
@@ -720,7 +1187,13 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
720
1187
|
const input = isObj(b.input) ? b.input : {};
|
|
721
1188
|
if (b.name === "StrReplace" || b.name === "Write") {
|
|
722
1189
|
const paths = [str(input.path), ...Array.isArray(input.paths) ? input.paths.map(str) : []].filter((x) => !!x);
|
|
723
|
-
|
|
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 });
|
|
724
1197
|
} else if (b.name === "Shell") {
|
|
725
1198
|
const cmd = str(input.command);
|
|
726
1199
|
if (cmd) timeline.push({ kind: "command", run: { cmd, exitCode: null, output: "" } });
|
|
@@ -732,7 +1205,7 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
732
1205
|
return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
733
1206
|
}
|
|
734
1207
|
function createCursorReader(o) {
|
|
735
|
-
const root =
|
|
1208
|
+
const root = join6(o.home, ".cursor", "projects");
|
|
736
1209
|
const slugCache = /* @__PURE__ */ new Map();
|
|
737
1210
|
const cwdFor = (slug) => {
|
|
738
1211
|
let p = slugCache.get(slug);
|
|
@@ -753,7 +1226,7 @@ function createCursorReader(o) {
|
|
|
753
1226
|
const parts = relative2(root, file).split(sep3);
|
|
754
1227
|
let st;
|
|
755
1228
|
try {
|
|
756
|
-
st = await
|
|
1229
|
+
st = await stat6(file);
|
|
757
1230
|
} catch {
|
|
758
1231
|
return null;
|
|
759
1232
|
}
|
|
@@ -761,14 +1234,16 @@ function createCursorReader(o) {
|
|
|
761
1234
|
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
|
|
762
1235
|
let text;
|
|
763
1236
|
try {
|
|
764
|
-
text = await
|
|
1237
|
+
text = await readFile5(file, "utf8");
|
|
765
1238
|
} catch {
|
|
766
1239
|
return null;
|
|
767
1240
|
}
|
|
768
1241
|
let title = null;
|
|
1242
|
+
let first = null;
|
|
769
1243
|
for (const r of parseJsonLines(text).records) {
|
|
770
1244
|
if (isObj(r) && r.role === "user" && isObj(r.message)) {
|
|
771
1245
|
const p = cleanPrompt(blockText(r.message.content));
|
|
1246
|
+
first ??= p;
|
|
772
1247
|
const rest = p ? splitHandoffPrompt(p).rest : "";
|
|
773
1248
|
if (rest) {
|
|
774
1249
|
title = oneLine(rest, 60);
|
|
@@ -776,7 +1251,8 @@ function createCursorReader(o) {
|
|
|
776
1251
|
}
|
|
777
1252
|
}
|
|
778
1253
|
}
|
|
779
|
-
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 };
|
|
780
1256
|
cache.set(file, { size: st.size, mtimeMs: st.mtimeMs, ref });
|
|
781
1257
|
return ref;
|
|
782
1258
|
}
|
|
@@ -797,7 +1273,7 @@ function createCursorReader(o) {
|
|
|
797
1273
|
return refs;
|
|
798
1274
|
},
|
|
799
1275
|
async readSession(ref) {
|
|
800
|
-
const { records, invalid } = parseJsonLines(await
|
|
1276
|
+
const { records, invalid } = parseJsonLines(await readFile5(ref.file, "utf8"));
|
|
801
1277
|
return normalizeCursor(records, ref, invalid);
|
|
802
1278
|
},
|
|
803
1279
|
detect(record) {
|
|
@@ -811,7 +1287,7 @@ function createCursorReader(o) {
|
|
|
811
1287
|
|
|
812
1288
|
// ../core/src/git/snapshot.ts
|
|
813
1289
|
import { execFile as execFile2 } from "node:child_process";
|
|
814
|
-
import { createHash } from "node:crypto";
|
|
1290
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
815
1291
|
import { promisify as promisify2 } from "node:util";
|
|
816
1292
|
var pexec2 = promisify2(execFile2);
|
|
817
1293
|
var MAX_DIFF_CHARS = 2e6;
|
|
@@ -886,150 +1362,31 @@ async function takeGitSnapshot(cwd) {
|
|
|
886
1362
|
}
|
|
887
1363
|
}
|
|
888
1364
|
let diff = await git(root, ["diff", base, "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/"]);
|
|
889
|
-
const diffSha =
|
|
1365
|
+
const diffSha = createHash2("sha256").update(diff).digest("hex");
|
|
890
1366
|
if (diff.length > MAX_DIFF_CHARS) diff = diff.slice(0, MAX_DIFF_CHARS) + "\n[diff truncated by Harlay]\n";
|
|
891
1367
|
return { root, branch, head, files, diff, diffSha };
|
|
892
1368
|
}
|
|
893
1369
|
|
|
894
|
-
// ../core/src/redact/redact.ts
|
|
895
|
-
var mark = (kind) => `[REDACTED:${kind}]`;
|
|
896
|
-
var whole = (kind) => () => mark(kind);
|
|
897
|
-
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*)`;
|
|
898
|
-
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]*)`;
|
|
899
|
-
var NOT_A_VALUE = String.raw`(?:true|false|null|none|undefined|await|new|this|self|string|number|boolean|object|unknown|bool)\b`;
|
|
900
|
-
var RULES = [
|
|
901
|
-
{ kind: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replace: whole("private_key") },
|
|
902
|
-
{ kind: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, replace: whole("anthropic_key") },
|
|
903
|
-
{ kind: "openai_key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, replace: whole("openai_key") },
|
|
904
|
-
{ kind: "github_token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, replace: whole("github_token") },
|
|
905
|
-
{ kind: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: whole("aws_access_key") },
|
|
906
|
-
{ kind: "stripe_key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, replace: whole("stripe_key") },
|
|
907
|
-
{ kind: "slack_token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: whole("slack_token") },
|
|
908
|
-
{ kind: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: whole("google_api_key") },
|
|
909
|
-
{ kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: whole("jwt") },
|
|
910
|
-
{ kind: "bearer", re: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, replace: (_m, g) => `${g[0]}${mark("bearer")}` },
|
|
911
|
-
// Env rules run before config rules so an uppercase env line is counted once, as env_secret.
|
|
912
|
-
{
|
|
913
|
-
kind: "env_secret",
|
|
914
|
-
re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
|
|
915
|
-
replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
|
|
916
|
-
},
|
|
917
|
-
{
|
|
918
|
-
kind: "env_secret",
|
|
919
|
-
re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
|
|
920
|
-
replace: (_m, g) => `${g[0]}${mark("env_secret")}`
|
|
921
|
-
},
|
|
922
|
-
{
|
|
923
|
-
kind: "config_secret",
|
|
924
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
|
|
925
|
-
replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
|
|
926
|
-
},
|
|
927
|
-
{
|
|
928
|
-
// The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
|
|
929
|
-
// backtrack into a shorter match.
|
|
930
|
-
kind: "config_secret",
|
|
931
|
-
re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
|
|
932
|
-
replace: (_m, g) => `${g[0]}${mark("config_secret")}`
|
|
933
|
-
},
|
|
934
|
-
{
|
|
935
|
-
kind: "url_credentials",
|
|
936
|
-
re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
|
|
937
|
-
replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
|
|
938
|
-
}
|
|
939
|
-
];
|
|
940
|
-
function shannon(s) {
|
|
941
|
-
const freq = /* @__PURE__ */ new Map();
|
|
942
|
-
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
943
|
-
let h = 0;
|
|
944
|
-
for (const n of freq.values()) {
|
|
945
|
-
const p = n / s.length;
|
|
946
|
-
h -= p * Math.log2(p);
|
|
947
|
-
}
|
|
948
|
-
return h;
|
|
949
|
-
}
|
|
950
|
-
var Redactor = class {
|
|
951
|
-
counts = {};
|
|
952
|
-
bump(kind) {
|
|
953
|
-
this.counts[kind] = (this.counts[kind] ?? 0) + 1;
|
|
954
|
-
}
|
|
955
|
-
apply(text) {
|
|
956
|
-
let out = text;
|
|
957
|
-
for (const rule of RULES) {
|
|
958
|
-
out = out.replace(rule.re, (match, ...rest) => {
|
|
959
|
-
this.bump(rule.kind);
|
|
960
|
-
const groups = rest.filter((x) => typeof x === "string");
|
|
961
|
-
return rule.replace(match, groups);
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
|
|
965
|
-
if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
|
|
966
|
-
this.bump("high_entropy");
|
|
967
|
-
return mark("high_entropy");
|
|
968
|
-
});
|
|
969
|
-
return out;
|
|
970
|
-
}
|
|
971
|
-
};
|
|
972
|
-
|
|
973
1370
|
// ../core/src/extract/extract.ts
|
|
974
|
-
import { basename as
|
|
1371
|
+
import { basename as basename5, isAbsolute, relative as relative3 } from "node:path";
|
|
975
1372
|
|
|
976
1373
|
// ../core/src/extract/recent.ts
|
|
977
|
-
var
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
|
|
994
|
-
var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
|
|
995
|
-
var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
996
|
-
function findPinnedRules(prompts, recent) {
|
|
997
|
-
const recentText = norm2(recent.join("\n"));
|
|
998
|
-
const found = [];
|
|
999
|
-
prompts.forEach((p, message) => {
|
|
1000
|
-
if (wordCount(p) > RULES2.pastedWords) return;
|
|
1001
|
-
for (const sentence of splitSentences(p)) {
|
|
1002
|
-
if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
|
|
1003
|
-
if (recentText.includes(norm2(sentence))) continue;
|
|
1004
|
-
found.push({ text: clipWords(sentence, RULES2.words), message });
|
|
1005
|
-
}
|
|
1006
|
-
});
|
|
1007
|
-
return capRules(latestCopies(found));
|
|
1008
|
-
}
|
|
1009
|
-
function mergePinnedRules(older, newer, recent) {
|
|
1010
|
-
const recentText = norm2(recent.join("\n"));
|
|
1011
|
-
const all = [...older.map((r) => ({ text: r.text, message: -1 })), ...newer].filter((r) => !recentText.includes(norm2(r.text)));
|
|
1012
|
-
return capRules(latestCopies(all));
|
|
1013
|
-
}
|
|
1014
|
-
function latestCopies(rules) {
|
|
1015
|
-
const byKey = /* @__PURE__ */ new Map();
|
|
1016
|
-
for (const r of rules) {
|
|
1017
|
-
const key = norm2(r.text);
|
|
1018
|
-
byKey.delete(key);
|
|
1019
|
-
byKey.set(key, r);
|
|
1020
|
-
}
|
|
1021
|
-
return [...byKey.values()];
|
|
1022
|
-
}
|
|
1023
|
-
function capRules(all) {
|
|
1024
|
-
const kept = [];
|
|
1025
|
-
let used = 0;
|
|
1026
|
-
for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
|
|
1027
|
-
const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
|
|
1028
|
-
if (used + cost > RULES2.budgetTokens) break;
|
|
1029
|
-
kept.unshift(all[i]);
|
|
1030
|
-
used += cost;
|
|
1031
|
-
}
|
|
1032
|
-
return { rules: kept, more: all.length - kept.length };
|
|
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 };
|
|
1033
1390
|
}
|
|
1034
1391
|
|
|
1035
1392
|
// ../core/src/extract/extract.ts
|
|
@@ -1070,28 +1427,90 @@ var PROBES = /* @__PURE__ */ new Set([
|
|
|
1070
1427
|
"pwd"
|
|
1071
1428
|
]);
|
|
1072
1429
|
var GIT_PROBES = /* @__PURE__ */ new Set(["diff", "grep", "show", "log", "status", "rev-parse", "ls-files", "branch"]);
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
let
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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] ?? "");
|
|
1079
1461
|
if (first !== "git") return PROBES.has(first);
|
|
1080
1462
|
let i = 1;
|
|
1081
1463
|
while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
|
|
1082
1464
|
return GIT_PROBES.has(words[i] ?? "");
|
|
1083
1465
|
}
|
|
1466
|
+
function isProbeCommand(cmd) {
|
|
1467
|
+
const stages = splitShell(cmd, true).filter((s) => !isCd(s));
|
|
1468
|
+
return stages.length > 0 && stages.every(isProbeStage);
|
|
1469
|
+
}
|
|
1084
1470
|
var FAILURE_WINDOW = 5;
|
|
1085
1471
|
function findLastFailure(cmds) {
|
|
1086
1472
|
const real = cmds.filter((c) => !isProbeCommand(c.cmd));
|
|
1087
1473
|
for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
|
|
1088
1474
|
const c = real[i];
|
|
1089
1475
|
if (c.exitCode === null || c.exitCode === 0) continue;
|
|
1090
|
-
|
|
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;
|
|
1091
1483
|
return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
|
|
1092
1484
|
}
|
|
1093
1485
|
return null;
|
|
1094
1486
|
}
|
|
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();
|
|
1496
|
+
});
|
|
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
|
+
}
|
|
1095
1514
|
function dropPartialLine(output) {
|
|
1096
1515
|
if (!output.startsWith("\u2026")) return output;
|
|
1097
1516
|
const nl = output.indexOf("\n");
|
|
@@ -1099,19 +1518,19 @@ function dropPartialLine(output) {
|
|
|
1099
1518
|
}
|
|
1100
1519
|
function buildTranscript(s, title, rel, redactor) {
|
|
1101
1520
|
const r = (x) => redactor.apply(x);
|
|
1102
|
-
const
|
|
1521
|
+
const lines2 = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
|
|
1103
1522
|
for (const t of s.timeline) {
|
|
1104
1523
|
if (t.kind === "user") {
|
|
1105
1524
|
const { handoff, rest } = splitHandoffPrompt(t.text);
|
|
1106
|
-
if (handoff)
|
|
1107
|
-
if (rest)
|
|
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), "");
|
|
1108
1527
|
} else if (t.kind === "assistant") {
|
|
1109
1528
|
const text = r(t.text);
|
|
1110
|
-
|
|
1111
|
-
} else if (t.kind === "command")
|
|
1112
|
-
else
|
|
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})`, "");
|
|
1113
1532
|
}
|
|
1114
|
-
const out =
|
|
1533
|
+
const out = lines2.join("\n");
|
|
1115
1534
|
if (out.length <= TRANSCRIPT_MAX) return out;
|
|
1116
1535
|
const cut = out.slice(out.length - TRANSCRIPT_MAX);
|
|
1117
1536
|
const nl = cut.indexOf("\n");
|
|
@@ -1134,23 +1553,30 @@ function extractHandoff(s, git2, o) {
|
|
|
1134
1553
|
const redactor = new Redactor();
|
|
1135
1554
|
const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
|
|
1136
1555
|
const prev = o.previous;
|
|
1137
|
-
const
|
|
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;
|
|
1138
1560
|
const carried = prev && prev.goal !== null && fromPrev ? prev : null;
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
const older = [...carried.pinnedRules.map((r) => ({ ...r, text: redactor.apply(r.text) })), ...findPinnedRules(carriedRecent, []).rules];
|
|
1144
|
-
const merged = mergePinnedRules(older, pinnedRules, recentUserMessages);
|
|
1145
|
-
moreRules += merged.more + carried.moreRules;
|
|
1146
|
-
pinnedRules = merged.rules;
|
|
1147
|
-
}
|
|
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);
|
|
1148
1565
|
const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
|
|
1149
1566
|
const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
|
|
1150
1567
|
const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
|
|
1151
1568
|
const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
|
|
1152
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);
|
|
1153
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);
|
|
1154
1580
|
const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
|
|
1155
1581
|
const filesTouched = [];
|
|
1156
1582
|
for (const e of [...edits].reverse()) {
|
|
@@ -1177,11 +1603,13 @@ function extractHandoff(s, git2, o) {
|
|
|
1177
1603
|
workdir,
|
|
1178
1604
|
git: git2,
|
|
1179
1605
|
originalGoal,
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
moreRules,
|
|
1606
|
+
userMessages,
|
|
1607
|
+
omittedMessages,
|
|
1183
1608
|
whereItStopped,
|
|
1609
|
+
lastRequest: userMessages.at(-1) ?? null,
|
|
1184
1610
|
todos: openTodos,
|
|
1611
|
+
results,
|
|
1612
|
+
references,
|
|
1185
1613
|
filesTouched,
|
|
1186
1614
|
lastFailure,
|
|
1187
1615
|
transcript: buildTranscript(s, title, rel, redactor),
|
|
@@ -1191,235 +1619,9 @@ function extractHandoff(s, git2, o) {
|
|
|
1191
1619
|
};
|
|
1192
1620
|
}
|
|
1193
1621
|
|
|
1194
|
-
// ../core/src/brief/write.ts
|
|
1195
|
-
import { access, appendFile, mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
|
|
1196
|
-
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join6 } from "node:path";
|
|
1197
|
-
|
|
1198
|
-
// ../core/src/brief/prompt.ts
|
|
1199
|
-
var TIER_MAX = { full: 6e3, compact: 3e3 };
|
|
1200
|
-
var REASON_TEXT = {
|
|
1201
|
-
usage_limit: "it hit a usage limit",
|
|
1202
|
-
crash: "it stopped with an error",
|
|
1203
|
-
manual: "the user handed it off"
|
|
1204
|
-
};
|
|
1205
|
-
var OUR_TAGS = /<(\/?)(handoff|original_goal|rules_from_user|recent_user_messages|where_it_stopped|files|last_failure)\b/gi;
|
|
1206
|
-
function safe(s) {
|
|
1207
|
-
return s.replace(OUR_TAGS, "\u2039$1$2");
|
|
1208
|
-
}
|
|
1209
|
-
var attr = (s) => safe(s).replace(/"/g, "'").replace(/\s+/g, " ").trim();
|
|
1210
|
-
var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1211
|
-
function goalIsRecent(goal, recent) {
|
|
1212
|
-
const g = norm3(goal.replace(/\s…$/, ""));
|
|
1213
|
-
return recent.some((m) => norm3(m).startsWith(g));
|
|
1214
|
-
}
|
|
1215
|
-
function renderPrompt(rec, tier) {
|
|
1216
|
-
const full = tier === "full";
|
|
1217
|
-
const from = TOOL_LABEL[rec.from.tool];
|
|
1218
|
-
const dir = `.harlay/handoffs/${rec.id}`;
|
|
1219
|
-
const out = [
|
|
1220
|
-
`<handoff from="${from}" reason="${rec.from.reason}" task="${attr(rec.task.title)}">`,
|
|
1221
|
-
`You are taking over a coding session that ${from} could not finish (${REASON_TEXT[rec.from.reason]}). What you need to start is below; don't re-read the repo to rebuild it.`
|
|
1222
|
-
];
|
|
1223
|
-
const goal = rec.task.originalGoal;
|
|
1224
|
-
const noRecent = rec.task.recentUserMessages.length === 0;
|
|
1225
|
-
if (goal && (full ? !goalIsRecent(goal, rec.task.recentUserMessages) : noRecent)) out.push("", "<original_goal>", safe(goal), "</original_goal>");
|
|
1226
|
-
if (rec.task.pinnedRules.length) {
|
|
1227
|
-
out.push("", "<rules_from_user>", ...rec.task.pinnedRules.map((r) => `- ${safe(r.text)}`));
|
|
1228
|
-
if (rec.task.moreRules) out.push(`(${rec.task.moreRules} more in the transcript)`);
|
|
1229
|
-
out.push("</rules_from_user>");
|
|
1230
|
-
}
|
|
1231
|
-
const recent = full ? rec.task.recentUserMessages : rec.task.recentUserMessages.slice(-2);
|
|
1232
|
-
if (recent.length) out.push("", '<recent_user_messages oldest_first="true">', ...recent.map((m) => `- ${safe(m)}`), "</recent_user_messages>");
|
|
1233
|
-
const where = rec.state.whereItStopped === null ? null : full ? rec.state.whereItStopped : clipWords(rec.state.whereItStopped, 60);
|
|
1234
|
-
const todos = rec.state.todos.map((t) => `[${t.status === "in_progress" ? "in progress" : "pending"}] ${safe(t.text)}`);
|
|
1235
|
-
if (where || todos.length) {
|
|
1236
|
-
out.push("", "<where_it_stopped>");
|
|
1237
|
-
if (where) out.push(safe(where));
|
|
1238
|
-
if (todos.length) out.push(`Open todos: ${todos.join("; ")}`);
|
|
1239
|
-
out.push("</where_it_stopped>");
|
|
1240
|
-
}
|
|
1241
|
-
if (full && (rec.files.touched.length || rec.files.uncommitted.length)) {
|
|
1242
|
-
out.push("", "<files>");
|
|
1243
|
-
if (rec.files.touched.length) out.push(`Touched: ${rec.files.touched.map(safe).join(", ")}`);
|
|
1244
|
-
if (rec.files.uncommitted.length) {
|
|
1245
|
-
const list = rec.files.uncommitted.map((f) => `${safe(f.path)} (${f.status}${f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : ""})`);
|
|
1246
|
-
out.push(`Uncommitted: ${list.join(", ")}${rec.files.moreUncommitted ? `, and ${rec.files.moreUncommitted} more` : ""}`);
|
|
1247
|
-
}
|
|
1248
|
-
out.push("</files>");
|
|
1249
|
-
}
|
|
1250
|
-
if (rec.lastFailure) {
|
|
1251
|
-
out.push("", "<last_failure>", `$ ${safe(rec.lastFailure.cmd)} (exit ${rec.lastFailure.exitCode})`);
|
|
1252
|
-
if (full) out.push(safe(rec.lastFailure.outputTail));
|
|
1253
|
-
out.push("</last_failure>");
|
|
1254
|
-
}
|
|
1255
|
-
const more = [`${dir}/transcript.md (search it, don't read it all)`];
|
|
1256
|
-
if (rec.details.diff) more.push(`${dir}/diff.patch`);
|
|
1257
|
-
const next = noRecent ? "Continue from where it stopped." : "Continue from the most recent user message.";
|
|
1258
|
-
out.push("", `Only if you need more: ${more.join(" and ")}.`, "</handoff>", "", `${next} Start with the next concrete step.`);
|
|
1259
|
-
return wellFormed(out.join("\n"));
|
|
1260
|
-
}
|
|
1261
|
-
function choosePrompt(rec, fits) {
|
|
1262
|
-
for (const tier of ["full", "compact"]) {
|
|
1263
|
-
const text = renderPrompt(rec, tier);
|
|
1264
|
-
if (text.length <= TIER_MAX[tier] && fits(text)) return { tier, text };
|
|
1265
|
-
}
|
|
1266
|
-
return { tier: "pointer", text: HANDOFF_PROMPT };
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// ../core/src/brief/record.ts
|
|
1270
|
-
var RECORD_SCHEMA = "harlay/handoff@2";
|
|
1271
|
-
var MAX_UNCOMMITTED = 15;
|
|
1272
|
-
function buildRecord(d, id, previousId, r) {
|
|
1273
|
-
const a = (s) => r.apply(stripControl(s));
|
|
1274
|
-
const files = d.git?.files ?? [];
|
|
1275
|
-
return {
|
|
1276
|
-
schema: RECORD_SCHEMA,
|
|
1277
|
-
id,
|
|
1278
|
-
createdAt: d.createdAt.toISOString(),
|
|
1279
|
-
from: {
|
|
1280
|
-
tool: d.from.tool,
|
|
1281
|
-
model: d.from.model,
|
|
1282
|
-
session: d.from.session,
|
|
1283
|
-
reason: d.from.reason,
|
|
1284
|
-
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
|
|
1285
|
-
message: d.from.message === null ? null : a(d.from.message)
|
|
1286
|
-
},
|
|
1287
|
-
repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
|
|
1288
|
-
task: {
|
|
1289
|
-
title: a(d.title),
|
|
1290
|
-
originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
|
|
1291
|
-
recentUserMessages: d.recentUserMessages.map(a),
|
|
1292
|
-
pinnedRules: d.pinnedRules.map((p) => ({ text: a(p.text), message: p.message })),
|
|
1293
|
-
moreRules: d.moreRules
|
|
1294
|
-
},
|
|
1295
|
-
state: {
|
|
1296
|
-
whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
|
|
1297
|
-
todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
|
|
1298
|
-
},
|
|
1299
|
-
files: {
|
|
1300
|
-
touched: d.filesTouched.map(a),
|
|
1301
|
-
uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
|
|
1302
|
-
moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
|
|
1303
|
-
},
|
|
1304
|
-
lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, outputTail: a(d.lastFailure.output).split("\n").slice(-8).join("\n") } : null,
|
|
1305
|
-
details: { transcript: "transcript.md", diff: d.git ? "diff.patch" : null, lastError: d.lastFailure ? "last-error.txt" : null },
|
|
1306
|
-
previous: previousId ? { id: previousId } : null,
|
|
1307
|
-
unknownRecords: d.unknownRecords
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
var isStr = (v) => typeof v === "string";
|
|
1311
|
-
var isObj2 = (v) => typeof v === "object" && v !== null;
|
|
1312
|
-
function validateRecord(raw) {
|
|
1313
|
-
if (!isObj2(raw) || raw.schema !== RECORD_SCHEMA || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
|
|
1314
|
-
const { task, from } = raw;
|
|
1315
|
-
if (!isObj2(from) || !isTool(from.tool)) return null;
|
|
1316
|
-
if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
|
|
1317
|
-
if (!Array.isArray(task.recentUserMessages) || !task.recentUserMessages.every(isStr)) return null;
|
|
1318
|
-
if (!Array.isArray(task.pinnedRules) || !task.pinnedRules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
|
|
1319
|
-
return raw;
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
// ../core/src/brief/write.ts
|
|
1323
|
-
async function allocateHandoffId(handoffsDir, baseId) {
|
|
1324
|
-
for (let i = 1; i < 1e3; i++) {
|
|
1325
|
-
const id = i === 1 ? baseId : `${baseId}-${i}`;
|
|
1326
|
-
try {
|
|
1327
|
-
await access(join6(handoffsDir, id));
|
|
1328
|
-
} catch {
|
|
1329
|
-
return id;
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
throw new HarlayError(`Could not allocate a handoff folder for ${baseId}`);
|
|
1333
|
-
}
|
|
1334
|
-
async function ensureGitExclude(workdir) {
|
|
1335
|
-
const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
1336
|
-
const file = isAbsolute2(p) ? p : join6(workdir, p);
|
|
1337
|
-
let current = "";
|
|
1338
|
-
try {
|
|
1339
|
-
current = await readFile4(file, "utf8");
|
|
1340
|
-
} catch {
|
|
1341
|
-
current = "";
|
|
1342
|
-
}
|
|
1343
|
-
if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
|
|
1344
|
-
await mkdir2(dirname2(file), { recursive: true });
|
|
1345
|
-
await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
|
|
1346
|
-
`);
|
|
1347
|
-
}
|
|
1348
|
-
async function readHandoffState(workdir) {
|
|
1349
|
-
let raw;
|
|
1350
|
-
try {
|
|
1351
|
-
raw = JSON.parse(await readFile4(join6(workdir, ".harlay", "state.json"), "utf8"));
|
|
1352
|
-
} catch {
|
|
1353
|
-
return null;
|
|
1354
|
-
}
|
|
1355
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
1356
|
-
const s = raw;
|
|
1357
|
-
if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
|
|
1358
|
-
if (s.goal !== null && typeof s.goal !== "string") return null;
|
|
1359
|
-
return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
|
|
1360
|
-
}
|
|
1361
|
-
async function readIfExists(file) {
|
|
1362
|
-
try {
|
|
1363
|
-
return await readFile4(file, "utf8");
|
|
1364
|
-
} catch {
|
|
1365
|
-
return null;
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
async function readPreviousHandoff(workdir) {
|
|
1369
|
-
const raw = await readIfExists(join6(workdir, ".harlay", "handoff.json"));
|
|
1370
|
-
if (raw !== null) {
|
|
1371
|
-
let rec;
|
|
1372
|
-
try {
|
|
1373
|
-
rec = validateRecord(JSON.parse(raw));
|
|
1374
|
-
} catch {
|
|
1375
|
-
return null;
|
|
1376
|
-
}
|
|
1377
|
-
if (!rec) return null;
|
|
1378
|
-
const { task } = rec;
|
|
1379
|
-
return {
|
|
1380
|
-
id: rec.id,
|
|
1381
|
-
goal: task.originalGoal,
|
|
1382
|
-
title: task.title,
|
|
1383
|
-
recentUserMessages: task.recentUserMessages,
|
|
1384
|
-
pinnedRules: task.pinnedRules,
|
|
1385
|
-
moreRules: typeof task.moreRules === "number" ? task.moreRules : 0
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
const state = await readHandoffState(workdir);
|
|
1389
|
-
return state ? { id: state.lastId, goal: state.goal, title: state.title, recentUserMessages: [], pinnedRules: [], moreRules: 0 } : null;
|
|
1390
|
-
}
|
|
1391
|
-
function addCounts(a, b) {
|
|
1392
|
-
const out = { ...a };
|
|
1393
|
-
for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
|
|
1394
|
-
return out;
|
|
1395
|
-
}
|
|
1396
|
-
async function writeHandoff(d, o) {
|
|
1397
|
-
const harlayDir = join6(d.workdir, ".harlay");
|
|
1398
|
-
const latestPath = join6(harlayDir, "HANDOFF.md");
|
|
1399
|
-
const id = await allocateHandoffId(join6(harlayDir, "handoffs"), d.baseId);
|
|
1400
|
-
const dir = join6(harlayDir, "handoffs", id);
|
|
1401
|
-
await mkdir2(dir, { recursive: true });
|
|
1402
|
-
const r = new Redactor();
|
|
1403
|
-
const body = buildRecord(d, id, o.previousId, r);
|
|
1404
|
-
const chosen = choosePrompt(body, (p) => promptFits(o.target, o.mode, p));
|
|
1405
|
-
const record = { ...body, prompt: { target: o.target, tier: chosen.tier, chars: chosen.text.length } };
|
|
1406
|
-
const json = JSON.stringify(record, null, 2) + "\n";
|
|
1407
|
-
const recordPath = join6(dir, "handoff.json");
|
|
1408
|
-
await writeFile2(recordPath, json);
|
|
1409
|
-
await writeFile2(join6(dir, "prompt.md"), chosen.text + "\n");
|
|
1410
|
-
await writeFile2(join6(dir, "transcript.md"), r.apply(stripControl(d.transcript)));
|
|
1411
|
-
if (d.git) await writeFile2(join6(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1412
|
-
if (d.lastFailure) await writeFile2(join6(dir, "last-error.txt"), r.apply(stripControl(d.lastFailure.output)));
|
|
1413
|
-
await writeFile2(latestPath, renderPrompt(body, "full") + "\n");
|
|
1414
|
-
await writeFile2(join6(harlayDir, "handoff.json"), json);
|
|
1415
|
-
if (d.git) await ensureGitExclude(d.workdir);
|
|
1416
|
-
const redactions = addCounts(d.redactions ?? {}, r.counts);
|
|
1417
|
-
return { id, dir, recordPath, latestPath, prompt: chosen.text, tier: chosen.tier, redactions };
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
1622
|
// ../core/src/launch/resolve.ts
|
|
1421
1623
|
import { constants } from "node:fs";
|
|
1422
|
-
import { access
|
|
1624
|
+
import { access, stat as stat7 } from "node:fs/promises";
|
|
1423
1625
|
import { homedir } from "node:os";
|
|
1424
1626
|
import { join as join7 } from "node:path";
|
|
1425
1627
|
function defaultResolveOptions(env = process.env) {
|
|
@@ -1446,8 +1648,8 @@ function knownPaths(tool, o) {
|
|
|
1446
1648
|
}
|
|
1447
1649
|
async function isExecutable(p) {
|
|
1448
1650
|
try {
|
|
1449
|
-
if (!(await
|
|
1450
|
-
await
|
|
1651
|
+
if (!(await stat7(p)).isFile()) return false;
|
|
1652
|
+
await access(p, constants.X_OK);
|
|
1451
1653
|
return true;
|
|
1452
1654
|
} catch {
|
|
1453
1655
|
return false;
|
|
@@ -1529,46 +1731,93 @@ function parseSessionSpec(spec) {
|
|
|
1529
1731
|
}
|
|
1530
1732
|
|
|
1531
1733
|
// ../core/src/handoff.ts
|
|
1532
|
-
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
|
+
}
|
|
1533
1751
|
async function runHandoff(o) {
|
|
1534
1752
|
const cwd = o.session.cwd;
|
|
1535
1753
|
if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
|
|
1536
1754
|
try {
|
|
1537
|
-
if (!(await
|
|
1755
|
+
if (!(await stat8(cwd)).isDirectory()) throw new Error("not a directory");
|
|
1538
1756
|
} catch {
|
|
1539
1757
|
throw new HarlayError(`The session's folder no longer exists: ${cwd}`);
|
|
1540
1758
|
}
|
|
1541
1759
|
const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
|
|
1542
1760
|
const git2 = await takeGitSnapshot(cwd);
|
|
1543
|
-
const previous = await readPreviousHandoff(git2?.root ?? cwd);
|
|
1761
|
+
const previous = await readPreviousHandoff(o.store, git2?.root ?? cwd);
|
|
1544
1762
|
const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
|
|
1545
|
-
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom });
|
|
1763
|
+
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
|
|
1546
1764
|
if (o.launch === false) return { written, plan: null, data };
|
|
1547
1765
|
let plan;
|
|
1548
1766
|
try {
|
|
1549
|
-
|
|
1550
|
-
plan = planLaunch({
|
|
1551
|
-
target: o.target,
|
|
1552
|
-
mode: o.mode,
|
|
1553
|
-
cwd: data.workdir,
|
|
1554
|
-
prompt: written.prompt,
|
|
1555
|
-
scriptDir: written.dir,
|
|
1556
|
-
binaries,
|
|
1557
|
-
terminalApp: o.terminalApp ?? "Terminal"
|
|
1558
|
-
});
|
|
1559
|
-
await executeLaunch(plan, o.runner);
|
|
1767
|
+
plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
|
|
1560
1768
|
} catch (e) {
|
|
1561
1769
|
const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
|
|
1562
|
-
throw new LaunchError(`${reason}. The
|
|
1770
|
+
throw new LaunchError(`${reason}. The handoff is ready at ${written.path}`, written);
|
|
1563
1771
|
}
|
|
1564
1772
|
return { written, plan, data };
|
|
1565
1773
|
}
|
|
1566
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
|
+
|
|
1567
1816
|
// ../core/src/watch/watcher.ts
|
|
1568
1817
|
import { EventEmitter } from "node:events";
|
|
1569
1818
|
import { watch } from "node:fs";
|
|
1570
|
-
import { open as open2, stat as
|
|
1571
|
-
import { join as
|
|
1819
|
+
import { open as open2, stat as stat9 } from "node:fs/promises";
|
|
1820
|
+
import { join as join9 } from "node:path";
|
|
1572
1821
|
import { StringDecoder } from "node:string_decoder";
|
|
1573
1822
|
var Watcher = class extends EventEmitter {
|
|
1574
1823
|
constructor(readers, opts = {}) {
|
|
@@ -1592,14 +1841,14 @@ var Watcher = class extends EventEmitter {
|
|
|
1592
1841
|
for (const r of this.readers) {
|
|
1593
1842
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1594
1843
|
try {
|
|
1595
|
-
this.offsets.set(f.path, (await
|
|
1844
|
+
this.offsets.set(f.path, (await stat9(f.path)).size);
|
|
1596
1845
|
} catch {
|
|
1597
1846
|
}
|
|
1598
1847
|
}
|
|
1599
1848
|
if (this.opts.fsWatch === false) continue;
|
|
1600
1849
|
try {
|
|
1601
1850
|
const w = watch(r.root, { recursive: true }, (_event, name) => {
|
|
1602
|
-
if (name) this.schedule(
|
|
1851
|
+
if (name) this.schedule(join9(r.root, name.toString()));
|
|
1603
1852
|
});
|
|
1604
1853
|
w.on("error", (e) => this.emit("error", e));
|
|
1605
1854
|
this.watchers.push(w);
|
|
@@ -1620,7 +1869,7 @@ var Watcher = class extends EventEmitter {
|
|
|
1620
1869
|
for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
|
|
1621
1870
|
let size;
|
|
1622
1871
|
try {
|
|
1623
|
-
size = (await
|
|
1872
|
+
size = (await stat9(f.path)).size;
|
|
1624
1873
|
} catch {
|
|
1625
1874
|
continue;
|
|
1626
1875
|
}
|
|
@@ -1677,7 +1926,7 @@ var Watcher = class extends EventEmitter {
|
|
|
1677
1926
|
if (!reader) return [];
|
|
1678
1927
|
let size;
|
|
1679
1928
|
try {
|
|
1680
|
-
size = (await
|
|
1929
|
+
size = (await stat9(file)).size;
|
|
1681
1930
|
} catch {
|
|
1682
1931
|
return [];
|
|
1683
1932
|
}
|
|
@@ -1701,10 +1950,10 @@ var Watcher = class extends EventEmitter {
|
|
|
1701
1950
|
decoder = new StringDecoder("utf8");
|
|
1702
1951
|
this.decoders.set(file, decoder);
|
|
1703
1952
|
}
|
|
1704
|
-
const
|
|
1705
|
-
this.partial.set(file,
|
|
1953
|
+
const lines2 = ((this.partial.get(file) ?? "") + decoder.write(buf)).split("\n");
|
|
1954
|
+
this.partial.set(file, lines2.pop() ?? "");
|
|
1706
1955
|
const detected = [];
|
|
1707
|
-
for (const line of
|
|
1956
|
+
for (const line of lines2) {
|
|
1708
1957
|
if (!line.trim()) continue;
|
|
1709
1958
|
let rec;
|
|
1710
1959
|
try {
|
|
@@ -1755,14 +2004,14 @@ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
|
|
|
1755
2004
|
var PACE_MS2 = 30 * 6e4;
|
|
1756
2005
|
|
|
1757
2006
|
// ../core/src/usage/bridge.ts
|
|
1758
|
-
import { chmod as chmod2, mkdir as mkdir3, readFile as
|
|
1759
|
-
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";
|
|
1760
2009
|
var BRIDGE_MARKER = "# harlay-usage";
|
|
1761
2010
|
function claudeSettingsPath(home) {
|
|
1762
|
-
return
|
|
2011
|
+
return join10(home, ".claude", "settings.json");
|
|
1763
2012
|
}
|
|
1764
|
-
var previousJson = (dataDir) =>
|
|
1765
|
-
var previousCommand = (dataDir) =>
|
|
2013
|
+
var previousJson = (dataDir) => join10(dataDir, "claude-statusline.previous.json");
|
|
2014
|
+
var previousCommand = (dataDir) => join10(dataDir, "claude-statusline.previous");
|
|
1766
2015
|
function isObj3(x) {
|
|
1767
2016
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1768
2017
|
}
|
|
@@ -1772,7 +2021,7 @@ function isOurs(statusLine) {
|
|
|
1772
2021
|
async function readSettings(path) {
|
|
1773
2022
|
let text;
|
|
1774
2023
|
try {
|
|
1775
|
-
text = await
|
|
2024
|
+
text = await readFile6(path, "utf8");
|
|
1776
2025
|
} catch (e) {
|
|
1777
2026
|
if (e.code === "ENOENT") return {};
|
|
1778
2027
|
throw e;
|
|
@@ -1792,23 +2041,50 @@ async function removeBridge(home, dataDir) {
|
|
|
1792
2041
|
if (!isOurs(s.statusLine)) return;
|
|
1793
2042
|
let prev;
|
|
1794
2043
|
try {
|
|
1795
|
-
prev = JSON.parse(await
|
|
2044
|
+
prev = JSON.parse(await readFile6(previousJson(dataDir), "utf8"));
|
|
1796
2045
|
} catch {
|
|
1797
2046
|
prev = void 0;
|
|
1798
2047
|
}
|
|
1799
2048
|
if (prev === void 0) delete s.statusLine;
|
|
1800
2049
|
else s.statusLine = prev;
|
|
1801
2050
|
await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
|
|
1802
|
-
await
|
|
1803
|
-
await
|
|
2051
|
+
await rm2(previousJson(dataDir), { force: true });
|
|
2052
|
+
await rm2(previousCommand(dataDir), { force: true });
|
|
1804
2053
|
}
|
|
1805
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
|
+
|
|
1806
2082
|
// src/app.ts
|
|
1807
2083
|
import { execFile as execFile3 } from "node:child_process";
|
|
1808
2084
|
import { existsSync } from "node:fs";
|
|
1809
|
-
import { cp, mkdir as mkdir4, mkdtemp, rm as
|
|
1810
|
-
import { homedir as homedir3, tmpdir } from "node:os";
|
|
1811
|
-
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";
|
|
1812
2088
|
import { fileURLToPath } from "node:url";
|
|
1813
2089
|
import { promisify as promisify3 } from "node:util";
|
|
1814
2090
|
var run = promisify3(execFile3);
|
|
@@ -1817,19 +2093,19 @@ var APP_BUNDLE_ID = "com.corelabs.harlay";
|
|
|
1817
2093
|
var APP_FILES = ["dist", "renderer", "assets"];
|
|
1818
2094
|
function findAppSource(cliFile = fileURLToPath(import.meta.url)) {
|
|
1819
2095
|
const pkg = resolve(dirname3(cliFile), "..");
|
|
1820
|
-
for (const dir of [
|
|
1821
|
-
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;
|
|
1822
2098
|
}
|
|
1823
2099
|
return null;
|
|
1824
2100
|
}
|
|
1825
2101
|
async function makeIcns(png, work) {
|
|
1826
|
-
const set =
|
|
2102
|
+
const set = join11(work, "icon.iconset");
|
|
1827
2103
|
await mkdir4(set);
|
|
1828
2104
|
for (const size of [16, 32, 128, 256, 512]) {
|
|
1829
|
-
await run("sips", ["-z", String(size), String(size), png, "--out",
|
|
1830
|
-
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`)]);
|
|
1831
2107
|
}
|
|
1832
|
-
const icns =
|
|
2108
|
+
const icns = join11(work, "icon.icns");
|
|
1833
2109
|
await run("iconutil", ["-c", "icns", set, "-o", icns]);
|
|
1834
2110
|
return icns;
|
|
1835
2111
|
}
|
|
@@ -1848,7 +2124,7 @@ async function quitRunningApp() {
|
|
|
1848
2124
|
}
|
|
1849
2125
|
}
|
|
1850
2126
|
function harlayDataDir(home = homedir3()) {
|
|
1851
|
-
return
|
|
2127
|
+
return join11(home, "Library", "Application Support", "Harlay");
|
|
1852
2128
|
}
|
|
1853
2129
|
async function uninstallApp(opts) {
|
|
1854
2130
|
await quitRunningApp();
|
|
@@ -1859,7 +2135,7 @@ async function uninstallApp(opts) {
|
|
|
1859
2135
|
opts.log(`Left ~/.claude/settings.json unchanged: ${e instanceof Error ? e.message : String(e)}
|
|
1860
2136
|
`);
|
|
1861
2137
|
}
|
|
1862
|
-
const app =
|
|
2138
|
+
const app = join11(opts.dir, "Harlay.app");
|
|
1863
2139
|
if (!existsSync(app)) {
|
|
1864
2140
|
opts.log(`No Harlay.app in ${opts.dir}.
|
|
1865
2141
|
`);
|
|
@@ -1873,21 +2149,21 @@ async function installApp(opts) {
|
|
|
1873
2149
|
if (process.platform !== "darwin") throw new Error("The Harlay app is macOS only; the CLI works without it.");
|
|
1874
2150
|
const source = findAppSource();
|
|
1875
2151
|
if (!source) throw new Error("App files are missing from this install. Reinstall with `npm install -g harlay`.");
|
|
1876
|
-
const work = await mkdtemp(
|
|
2152
|
+
const work = await mkdtemp(join11(tmpdir2(), "harlay-app-"));
|
|
1877
2153
|
try {
|
|
1878
|
-
const appSrc =
|
|
1879
|
-
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 });
|
|
1880
2156
|
await writeFile4(
|
|
1881
|
-
|
|
2157
|
+
join11(appSrc, "package.json"),
|
|
1882
2158
|
JSON.stringify({ name: "harlay-desktop", productName: "Harlay", version: opts.version, main: "dist/main.cjs" }, null, 2)
|
|
1883
2159
|
);
|
|
1884
|
-
const icon = await makeIcns(
|
|
2160
|
+
const icon = await makeIcns(join11(source, "assets", "icon.png"), work);
|
|
1885
2161
|
opts.log(`Building Harlay.app (Electron ${ELECTRON_VERSION}; the first install downloads it, about 100 MB)\u2026
|
|
1886
2162
|
`);
|
|
1887
2163
|
const { packager } = await import("@electron/packager");
|
|
1888
2164
|
const [outDir] = await packager({
|
|
1889
2165
|
dir: appSrc,
|
|
1890
|
-
out:
|
|
2166
|
+
out: join11(work, "out"),
|
|
1891
2167
|
name: "Harlay",
|
|
1892
2168
|
platform: "darwin",
|
|
1893
2169
|
arch: process.arch === "arm64" ? "arm64" : "x64",
|
|
@@ -1902,29 +2178,29 @@ async function installApp(opts) {
|
|
|
1902
2178
|
quiet: true,
|
|
1903
2179
|
extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: "alert" }
|
|
1904
2180
|
});
|
|
1905
|
-
const built =
|
|
2181
|
+
const built = join11(outDir, "Harlay.app");
|
|
1906
2182
|
await run("codesign", ["--force", "--deep", "--sign", "-", built]);
|
|
1907
|
-
const dest =
|
|
2183
|
+
const dest = join11(opts.dir, "Harlay.app");
|
|
1908
2184
|
await quitRunningApp();
|
|
1909
2185
|
await mkdir4(opts.dir, { recursive: true });
|
|
1910
|
-
await
|
|
2186
|
+
await rm3(dest, { recursive: true, force: true });
|
|
1911
2187
|
await run("ditto", [built, dest]);
|
|
1912
2188
|
opts.log(`Installed ${dest}
|
|
1913
2189
|
`);
|
|
1914
2190
|
if (opts.open) await run("open", [dest]);
|
|
1915
2191
|
return dest;
|
|
1916
2192
|
} finally {
|
|
1917
|
-
await
|
|
2193
|
+
await rm3(work, { recursive: true, force: true });
|
|
1918
2194
|
}
|
|
1919
2195
|
}
|
|
1920
2196
|
|
|
1921
2197
|
// src/main.ts
|
|
1922
|
-
var VERSION = "0.1
|
|
2198
|
+
var VERSION = "0.2.1";
|
|
1923
2199
|
var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
|
|
1924
2200
|
|
|
1925
2201
|
Usage:
|
|
1926
2202
|
harlay sessions [--limit N] [--json]
|
|
1927
|
-
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]
|
|
1928
2204
|
harlay watch [--auto <claude|codex|cursor>] [--mode app|terminal] [--threshold N]
|
|
1929
2205
|
harlay app install [--dir <folder>] [--no-open] install or update the macOS menu bar app (default folder: /Applications)
|
|
1930
2206
|
harlay app uninstall [--dir <folder>] quit it, restore Claude Code's status line, move it to the Trash
|
|
@@ -1984,7 +2260,8 @@ async function cmdHandoff(args, readers, ctx) {
|
|
|
1984
2260
|
session: { type: "string" },
|
|
1985
2261
|
mode: { type: "string" },
|
|
1986
2262
|
"no-launch": { type: "boolean" },
|
|
1987
|
-
terminal: { type: "string" }
|
|
2263
|
+
terminal: { type: "string" },
|
|
2264
|
+
wait: { type: "boolean" }
|
|
1988
2265
|
},
|
|
1989
2266
|
strict: true
|
|
1990
2267
|
});
|
|
@@ -1992,23 +2269,21 @@ async function cmdHandoff(args, readers, ctx) {
|
|
|
1992
2269
|
const target = parseTool(values.to);
|
|
1993
2270
|
const mode = parseMode(values.mode, target);
|
|
1994
2271
|
const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir4());
|
|
2272
|
+
const since = /* @__PURE__ */ new Date();
|
|
1995
2273
|
const res = await runHandoff({
|
|
1996
2274
|
session,
|
|
1997
2275
|
target,
|
|
1998
2276
|
mode,
|
|
1999
2277
|
readers,
|
|
2278
|
+
store: handoffStore(ctx.env.HARLAY_HOME ?? homedir4()),
|
|
2000
2279
|
launch: !values["no-launch"],
|
|
2001
2280
|
terminalApp: values.terminal ?? "Terminal",
|
|
2002
2281
|
runner: ctx.runner,
|
|
2003
2282
|
resolve: defaultResolveOptions(ctx.env)
|
|
2004
2283
|
});
|
|
2005
|
-
ctx.out(`
|
|
2006
|
-
|
|
2284
|
+
ctx.out(`Handoff: ${res.written.path}
|
|
2285
|
+
Prompt: ${res.written.prompt}
|
|
2007
2286
|
`);
|
|
2008
|
-
ctx.out(
|
|
2009
|
-
res.written.tier === "pointer" ? "Prompt: pointer to the brief (too long to send inline)\n" : `Prompt: ${res.written.tier} brief inline (${res.written.prompt.length} chars)
|
|
2010
|
-
`
|
|
2011
|
-
);
|
|
2012
2287
|
const kinds = Object.keys(res.written.redactions);
|
|
2013
2288
|
if (kinds.length) {
|
|
2014
2289
|
const total = Object.values(res.written.redactions).reduce((a, b) => a + b, 0);
|
|
@@ -2017,8 +2292,29 @@ Archived: ${res.written.dir}
|
|
|
2017
2292
|
}
|
|
2018
2293
|
ctx.out(res.plan ? `Continuing in ${TOOL_LABEL[target]} (${mode}).
|
|
2019
2294
|
` : "Not launched (--no-launch).\n");
|
|
2020
|
-
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;
|
|
2021
2316
|
}
|
|
2317
|
+
var WAIT_SECONDS = 180;
|
|
2022
2318
|
async function cmdWatch(args, readers, ctx) {
|
|
2023
2319
|
const { values } = parseArgs({
|
|
2024
2320
|
args,
|
|
@@ -2029,6 +2325,7 @@ async function cmdWatch(args, readers, ctx) {
|
|
|
2029
2325
|
const mode = auto ? parseMode(values.mode, auto) : "app";
|
|
2030
2326
|
const threshold = values.threshold ? Number(values.threshold) : 90;
|
|
2031
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());
|
|
2032
2329
|
const watcher = new Watcher(readers, { usageThreshold: threshold });
|
|
2033
2330
|
watcher.on("error", (e) => ctx.err(`harlay: watcher error: ${e instanceof Error ? e.message : String(e)}
|
|
2034
2331
|
`));
|
|
@@ -2043,7 +2340,7 @@ async function cmdWatch(args, readers, ctx) {
|
|
|
2043
2340
|
ctx.out(`[${when}] ${name}: stopped \u2014 ${ev.event.halt.message}
|
|
2044
2341
|
`);
|
|
2045
2342
|
if (auto && auto !== ev.ref.tool) {
|
|
2046
|
-
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}
|
|
2047
2344
|
`)).catch((e) => ctx.err(` \u2192 handoff failed: ${e instanceof Error ? e.message : String(e)}
|
|
2048
2345
|
`));
|
|
2049
2346
|
}
|