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