harlay 0.1.2 → 0.2.0

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