harlay 0.1.3 → 0.2.1

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