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