harlay 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/harlay.mjs CHANGED
@@ -7,6 +7,9 @@ import { parseArgs } from "node:util";
7
7
  // ../core/src/types.ts
8
8
  var TOOLS = ["claude-code", "codex", "cursor"];
9
9
  var TOOL_LABEL = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
10
+ function isTool(v) {
11
+ return v === "claude-code" || v === "codex" || v === "cursor";
12
+ }
10
13
 
11
14
  // ../core/src/errors.ts
12
15
  var HarlayError = class extends Error {
@@ -25,31 +28,83 @@ var LaunchError = class extends HarlayError {
25
28
  };
26
29
 
27
30
  // ../core/src/util/text.ts
28
- var CHARS_PER_TOKEN = 4;
29
- function clip(s, maxTokens, pointer) {
30
- const max = maxTokens * CHARS_PER_TOKEN;
31
- if (s.length <= max) return s;
32
- const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
33
- return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
34
- }
31
+ var isHigh = (c) => c >= 55296 && c <= 56319;
32
+ var isLow = (c) => c >= 56320 && c <= 57343;
35
33
  function tail(s, maxChars) {
36
- return s.length <= maxChars ? s : "\u2026" + s.slice(s.length - maxChars + 1);
34
+ if (s.length <= maxChars) return s;
35
+ let start = s.length - maxChars + 1;
36
+ if (start < s.length && isLow(s.charCodeAt(start))) start++;
37
+ return "\u2026" + s.slice(start);
38
+ }
39
+ var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
40
+ function wellFormed(s) {
41
+ return s.replace(LONE_SURROGATE, "\uFFFD");
37
42
  }
38
43
  function oneLine(s, max = 120) {
39
44
  const t = s.replace(/\s+/g, " ").trim();
40
- return t.length <= max ? t : t.slice(0, max - 1) + "\u2026";
45
+ if (t.length <= max) return t;
46
+ let end = max - 1;
47
+ if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
48
+ return t.slice(0, end) + "\u2026";
49
+ }
50
+ function clipWords(s, max) {
51
+ const w = s.split(/\s+/).filter(Boolean);
52
+ return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
41
53
  }
42
54
  var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
43
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;
44
57
  function cleanPrompt(raw) {
45
58
  let s = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(CONTEXT_BLOCK_RE, "").trim();
46
59
  const query = /<user_query>([\s\S]*?)<\/user_query>/.exec(s);
47
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();
48
63
  if (MACHINE_TAG_RE.test(s)) return null;
49
64
  return s ? s : null;
50
65
  }
51
- function isHandoffPrompt(p) {
52
- return p.length <= 300 && p.includes(".harlay/HANDOFF.md");
66
+ var LEGACY_HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
67
+ var HANDOFF_TAG = '<handoff from="';
68
+ var HANDOFF_END = "</handoff>";
69
+ var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
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
+ }
89
+ function splitHandoffPrompt(p) {
90
+ const start = p.indexOf(HANDOFF_TAG);
91
+ const end = p.lastIndexOf(HANDOFF_END);
92
+ if (start !== -1 && end > start) {
93
+ const before = p.slice(0, start).trim();
94
+ const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
95
+ const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
96
+ return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
97
+ }
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 };
101
+ return { handoff: false, rest: p, id: null };
102
+ }
103
+ var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
104
+ var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
105
+ var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
106
+ function stripControl(s) {
107
+ return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
53
108
  }
54
109
  function slugify(s, max = 40) {
55
110
  const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
@@ -57,12 +112,14 @@ function slugify(s, max = 40) {
57
112
  }
58
113
 
59
114
  // ../core/src/sources/claude.ts
60
- import { readFile, stat as stat2 } from "node:fs/promises";
61
- import { basename, join as join2, 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";
62
117
 
63
118
  // ../core/src/util/files.ts
119
+ import { createReadStream } from "node:fs";
64
120
  import { open, readdir, stat } from "node:fs/promises";
65
121
  import { join } from "node:path";
122
+ import { createInterface } from "node:readline";
66
123
  async function listSessionFiles(root, accept) {
67
124
  let names;
68
125
  try {
@@ -82,27 +139,14 @@ async function listSessionFiles(root, accept) {
82
139
  }
83
140
  return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
84
141
  }
85
- async function readFirstLine(file, maxBytes = 8 * 1024 * 1024) {
86
- 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 });
87
145
  try {
88
- const chunks = [];
89
- const buf = Buffer.alloc(65536);
90
- let total = 0;
91
- while (total < maxBytes) {
92
- const { bytesRead } = await fh.read(buf, 0, buf.length, total);
93
- if (bytesRead === 0) break;
94
- const slice = buf.subarray(0, bytesRead);
95
- const nl = slice.indexOf(10);
96
- if (nl >= 0) {
97
- chunks.push(Buffer.from(slice.subarray(0, nl)));
98
- return Buffer.concat(chunks).toString("utf8");
99
- }
100
- chunks.push(Buffer.from(slice));
101
- total += bytesRead;
102
- }
103
- return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
146
+ for await (const line of lines2) if (each(line)) break;
104
147
  } finally {
105
- await fh.close();
148
+ lines2.close();
149
+ input.destroy();
106
150
  }
107
151
  }
108
152
 
@@ -135,6 +179,445 @@ function blockText(content) {
135
179
  return parts.join("\n");
136
180
  }
137
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
+
138
621
  // ../core/src/sources/todos.ts
139
622
  var STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed", "cancelled"]);
140
623
  function applyTodoWrite(current, input) {
@@ -155,6 +638,14 @@ function applyTodoWrite(current, input) {
155
638
 
156
639
  // ../core/src/sources/claude.ts
157
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
+ }
158
649
  function claudeHalt(record) {
159
650
  if (!isObj(record) || record.type !== "assistant" || record.isApiErrorMessage !== true) return null;
160
651
  if (record.error !== "rate_limit" && record.apiErrorStatus !== 429) return null;
@@ -167,16 +658,32 @@ function normalizeClaude(records, ref, invalid = 0) {
167
658
  const timeline = [];
168
659
  const pendingBash = /* @__PURE__ */ new Map();
169
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
+ };
170
667
  let todos = null;
171
668
  let halt = null;
172
669
  let model = null;
173
670
  let unknown = invalid;
671
+ const handoffIds = [];
174
672
  for (const rec of records) {
175
673
  if (!isObj(rec) || typeof rec.type !== "string") {
176
674
  unknown++;
177
675
  continue;
178
676
  }
179
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
+ }
180
687
  if (rec.type === "assistant") {
181
688
  const h = claudeHalt(rec);
182
689
  if (h) {
@@ -200,15 +707,31 @@ function normalizeClaude(records, ref, invalid = 0) {
200
707
  if (c) pendingBash.set(id, c);
201
708
  break;
202
709
  }
203
- 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
+ }
204
717
  case "MultiEdit": {
205
718
  const p = str(input.file_path);
206
- 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" });
207
730
  break;
208
731
  }
209
732
  case "NotebookEdit": {
210
733
  const p = str(input.notebook_path);
211
- if (p) timeline.push({ kind: "edit", edit: { path: p, kind: "modify" } });
734
+ if (p) pushEdit(id, { path: p, kind: "modify" });
212
735
  break;
213
736
  }
214
737
  case "Write": {
@@ -216,7 +739,7 @@ function normalizeClaude(records, ref, invalid = 0) {
216
739
  if (p) {
217
740
  const edit = { path: p, kind: "modify" };
218
741
  pendingWrite.set(id, edit);
219
- timeline.push({ kind: "edit", edit });
742
+ pushEdit(id, edit);
220
743
  }
221
744
  break;
222
745
  }
@@ -242,6 +765,11 @@ function normalizeClaude(records, ref, invalid = 0) {
242
765
  if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
243
766
  if (b.type !== "tool_result") continue;
244
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
+ }
245
773
  const w = pendingWrite.get(id);
246
774
  if (w) {
247
775
  pendingWrite.delete(id);
@@ -251,6 +779,10 @@ function normalizeClaude(records, ref, invalid = 0) {
251
779
  if (cmd === void 0) continue;
252
780
  pendingBash.delete(id);
253
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
+ }
254
786
  const code = /^Exit code (\d+)/.exec(content);
255
787
  const exitCode = code ? Number(code[1]) : b.is_error === true ? 1 : 0;
256
788
  const r = rec.toolUseResult;
@@ -263,14 +795,14 @@ function normalizeClaude(records, ref, invalid = 0) {
263
795
  }
264
796
  }
265
797
  }
266
- 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 };
267
799
  }
268
800
  async function loadDesktopTitles(dir) {
269
801
  const out = /* @__PURE__ */ new Map();
270
- 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"));
271
803
  for (const f of files) {
272
804
  try {
273
- const d = JSON.parse(await readFile(f.path, "utf8"));
805
+ const d = JSON.parse(await readFile3(f.path, "utf8"));
274
806
  if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
275
807
  out.set(d.cliSessionId, d.title);
276
808
  }
@@ -280,14 +812,14 @@ async function loadDesktopTitles(dir) {
280
812
  return out;
281
813
  }
282
814
  function createClaudeReader(o) {
283
- const root = join2(o.home, ".claude", "projects");
284
- const indexDir = join2(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");
285
817
  const isSessionFile = (file) => file.startsWith(root + sep) && file.endsWith(".jsonl") && relative(root, file).split(sep).length === 2;
286
818
  const cache = /* @__PURE__ */ new Map();
287
819
  async function refForFile(file, titles) {
288
820
  let st;
289
821
  try {
290
- st = await stat2(file);
822
+ st = await stat4(file);
291
823
  } catch {
292
824
  return null;
293
825
  }
@@ -295,17 +827,20 @@ function createClaudeReader(o) {
295
827
  if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
296
828
  let text;
297
829
  try {
298
- text = await readFile(file, "utf8");
830
+ text = await readFile3(file, "utf8");
299
831
  } catch {
300
832
  return null;
301
833
  }
302
834
  let cwd = null;
303
835
  let custom = null;
304
836
  let ai = null;
305
- for (const line of text.split("\n")) {
837
+ let first;
838
+ const lines2 = text.split("\n");
839
+ for (const line of lines2) {
306
840
  const wantCwd = cwd === null && line.includes('"cwd"');
307
841
  const wantTitle = line.includes('-title"');
308
- if (!wantCwd && !wantTitle) continue;
842
+ const wantPrompt = first === void 0 && line.includes('"user"');
843
+ if (!wantCwd && !wantTitle && !wantPrompt) continue;
309
844
  let r;
310
845
  try {
311
846
  r = JSON.parse(line);
@@ -316,11 +851,26 @@ function createClaudeReader(o) {
316
851
  if (wantCwd && typeof r.cwd === "string") cwd = r.cwd;
317
852
  if (r.type === "custom-title" && typeof r.customTitle === "string") custom = r.customTitle;
318
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
+ }
319
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
+ }
868
+ }
869
+ cwd = await sessionFolder(cwd, first ?? null);
320
870
  entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
321
871
  cache.set(file, entry);
322
872
  }
323
- const id = basename(file, ".jsonl");
873
+ const id = basename3(file, ".jsonl");
324
874
  const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
325
875
  if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
326
876
  return entry.ref;
@@ -329,7 +879,7 @@ function createClaudeReader(o) {
329
879
  tool: "claude-code",
330
880
  root,
331
881
  isSessionFile,
332
- idFromPath: (file) => isSessionFile(file) ? basename(file, ".jsonl") : null,
882
+ idFromPath: (file) => isSessionFile(file) ? basename3(file, ".jsonl") : null,
333
883
  refForFile: (file) => refForFile(file),
334
884
  async listSessions(limit = 20) {
335
885
  const files = await listSessionFiles(root, isSessionFile);
@@ -343,7 +893,7 @@ function createClaudeReader(o) {
343
893
  return refs;
344
894
  },
345
895
  async readSession(ref) {
346
- const { records, invalid } = parseJsonLines(await readFile(ref.file, "utf8"));
896
+ const { records, invalid } = parseJsonLines(await readFile3(ref.file, "utf8"));
347
897
  return normalizeClaude(records, ref, invalid);
348
898
  },
349
899
  detect(record) {
@@ -356,11 +906,21 @@ function createClaudeReader(o) {
356
906
  }
357
907
 
358
908
  // ../core/src/sources/codex.ts
359
- import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
360
- import { basename as basename2, join as join3, 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";
361
911
  var OUTPUT_TAIL2 = 2e4;
362
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;
363
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
+ }
364
924
  function codexCommand(command) {
365
925
  if (typeof command === "string") return command;
366
926
  if (!Array.isArray(command)) return "";
@@ -428,7 +988,8 @@ function normalizeCodex(records, ref, invalid = 0) {
428
988
  } else if (it.type === "FileChange" && isObj(it.changes)) {
429
989
  for (const [path, change] of Object.entries(it.changes)) {
430
990
  const t = isObj(change) ? str(change.type) : null;
431
- 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) : {} } });
432
993
  }
433
994
  }
434
995
  } else if (p.type === "user_message") {
@@ -459,11 +1020,25 @@ function parseSessionMeta(line) {
459
1020
  const id = str(p.id);
460
1021
  return id ? { id, cwd: str(p.cwd) } : null;
461
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
+ }
462
1037
  async function loadTitles(file) {
463
1038
  const out = /* @__PURE__ */ new Map();
464
1039
  let text;
465
1040
  try {
466
- text = await readFile2(file, "utf8");
1041
+ text = await readFile4(file, "utf8");
467
1042
  } catch {
468
1043
  return out;
469
1044
  }
@@ -473,28 +1048,34 @@ async function loadTitles(file) {
473
1048
  return out;
474
1049
  }
475
1050
  function createCodexReader(o) {
476
- const root = join3(o.home, ".codex", "sessions");
477
- const indexFile = join3(o.home, ".codex", "session_index.jsonl");
478
- 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");
479
1054
  const cache = /* @__PURE__ */ new Map();
480
1055
  async function refForFile(file, titles) {
481
1056
  let st;
482
1057
  try {
483
- st = await stat3(file);
1058
+ st = await stat5(file);
484
1059
  } catch {
485
1060
  return null;
486
1061
  }
487
1062
  let entry = cache.get(file);
488
1063
  if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
489
- let first;
1064
+ let meta = null;
1065
+ let first = null;
1066
+ let lines2 = 0;
490
1067
  try {
491
- 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
+ });
492
1073
  } catch {
493
1074
  return null;
494
1075
  }
495
- const meta = first ? parseSessionMeta(first) : null;
496
1076
  if (!meta) return null;
497
- 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 };
498
1079
  cache.set(file, entry);
499
1080
  }
500
1081
  const title = (titles ?? await loadTitles(indexFile)).get(entry.id) ?? null;
@@ -505,7 +1086,7 @@ function createCodexReader(o) {
505
1086
  tool: "codex",
506
1087
  root,
507
1088
  isSessionFile,
508
- 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,
509
1090
  refForFile: (file) => refForFile(file),
510
1091
  async listSessions(limit = 20) {
511
1092
  const files = await listSessionFiles(root, isSessionFile);
@@ -519,7 +1100,7 @@ function createCodexReader(o) {
519
1100
  return refs;
520
1101
  },
521
1102
  async readSession(ref) {
522
- const { records, invalid } = parseJsonLines(await readFile2(ref.file, "utf8"));
1103
+ const { records, invalid } = parseJsonLines(await readFile4(ref.file, "utf8"));
523
1104
  return normalizeCodex(records, ref, invalid);
524
1105
  },
525
1106
  detect(record) {
@@ -544,21 +1125,21 @@ function createCodexReader(o) {
544
1125
  }
545
1126
 
546
1127
  // ../core/src/sources/cursor.ts
547
- import { readdir as readdir2, readFile as readFile3, stat as stat4 } from "node:fs/promises";
548
- import { join as join4, 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";
549
1130
  var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
550
1131
  async function resolveCursorSlug(slug, rootDir = "/") {
551
1132
  if (!slug || /^\d+$/.test(slug)) return null;
552
1133
  async function walk(dir, rest, depth) {
553
1134
  if (depth > 16) return null;
554
- const entries = await readdir2(dir, { withFileTypes: true }).catch(() => null);
1135
+ const entries = await readdir3(dir, { withFileTypes: true }).catch(() => null);
555
1136
  if (!entries) return null;
556
1137
  for (const e of entries) {
557
1138
  if (!e.isDirectory()) continue;
558
1139
  const n = norm(e.name);
559
- if (rest === n) return join4(dir, e.name);
1140
+ if (rest === n) return join6(dir, e.name);
560
1141
  if (rest.startsWith(n + "-")) {
561
- const found = await walk(join4(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);
562
1143
  if (found) return found;
563
1144
  }
564
1145
  }
@@ -606,7 +1187,13 @@ function normalizeCursor(records, ref, invalid = 0) {
606
1187
  const input = isObj(b.input) ? b.input : {};
607
1188
  if (b.name === "StrReplace" || b.name === "Write") {
608
1189
  const paths = [str(input.path), ...Array.isArray(input.paths) ? input.paths.map(str) : []].filter((x) => !!x);
609
- 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 });
610
1197
  } else if (b.name === "Shell") {
611
1198
  const cmd = str(input.command);
612
1199
  if (cmd) timeline.push({ kind: "command", run: { cmd, exitCode: null, output: "" } });
@@ -618,7 +1205,7 @@ function normalizeCursor(records, ref, invalid = 0) {
618
1205
  return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
619
1206
  }
620
1207
  function createCursorReader(o) {
621
- const root = join4(o.home, ".cursor", "projects");
1208
+ const root = join6(o.home, ".cursor", "projects");
622
1209
  const slugCache = /* @__PURE__ */ new Map();
623
1210
  const cwdFor = (slug) => {
624
1211
  let p = slugCache.get(slug);
@@ -639,7 +1226,7 @@ function createCursorReader(o) {
639
1226
  const parts = relative2(root, file).split(sep3);
640
1227
  let st;
641
1228
  try {
642
- st = await stat4(file);
1229
+ st = await stat6(file);
643
1230
  } catch {
644
1231
  return null;
645
1232
  }
@@ -647,21 +1234,25 @@ function createCursorReader(o) {
647
1234
  if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
648
1235
  let text;
649
1236
  try {
650
- text = await readFile3(file, "utf8");
1237
+ text = await readFile5(file, "utf8");
651
1238
  } catch {
652
1239
  return null;
653
1240
  }
654
1241
  let title = null;
1242
+ let first = null;
655
1243
  for (const r of parseJsonLines(text).records) {
656
1244
  if (isObj(r) && r.role === "user" && isObj(r.message)) {
657
1245
  const p = cleanPrompt(blockText(r.message.content));
658
- if (p && !isHandoffPrompt(p)) {
659
- title = oneLine(p, 60);
1246
+ first ??= p;
1247
+ const rest = p ? splitHandoffPrompt(p).rest : "";
1248
+ if (rest) {
1249
+ title = oneLine(rest, 60);
660
1250
  break;
661
1251
  }
662
1252
  }
663
1253
  }
664
- 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 };
665
1256
  cache.set(file, { size: st.size, mtimeMs: st.mtimeMs, ref });
666
1257
  return ref;
667
1258
  }
@@ -682,7 +1273,7 @@ function createCursorReader(o) {
682
1273
  return refs;
683
1274
  },
684
1275
  async readSession(ref) {
685
- const { records, invalid } = parseJsonLines(await readFile3(ref.file, "utf8"));
1276
+ const { records, invalid } = parseJsonLines(await readFile5(ref.file, "utf8"));
686
1277
  return normalizeCursor(records, ref, invalid);
687
1278
  },
688
1279
  detect(record) {
@@ -695,14 +1286,14 @@ function createCursorReader(o) {
695
1286
  }
696
1287
 
697
1288
  // ../core/src/git/snapshot.ts
698
- import { execFile } from "node:child_process";
699
- import { createHash } from "node:crypto";
700
- import { promisify } from "node:util";
701
- var pexec = promisify(execFile);
1289
+ import { execFile as execFile2 } from "node:child_process";
1290
+ import { createHash as createHash2 } from "node:crypto";
1291
+ import { promisify as promisify2 } from "node:util";
1292
+ var pexec2 = promisify2(execFile2);
702
1293
  var MAX_DIFF_CHARS = 2e6;
703
1294
  var MAX_FILES = 500;
704
1295
  async function git(cwd, args) {
705
- const { stdout } = await pexec("git", args, {
1296
+ const { stdout } = await pexec2("git", args, {
706
1297
  cwd,
707
1298
  maxBuffer: 64 * 1024 * 1024,
708
1299
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
@@ -771,97 +1362,42 @@ async function takeGitSnapshot(cwd) {
771
1362
  }
772
1363
  }
773
1364
  let diff = await git(root, ["diff", base, "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/"]);
774
- const diffSha = createHash("sha256").update(diff).digest("hex");
1365
+ const diffSha = createHash2("sha256").update(diff).digest("hex");
775
1366
  if (diff.length > MAX_DIFF_CHARS) diff = diff.slice(0, MAX_DIFF_CHARS) + "\n[diff truncated by Harlay]\n";
776
1367
  return { root, branch, head, files, diff, diffSha };
777
1368
  }
778
1369
 
779
- // ../core/src/redact/redact.ts
780
- var mark = (kind) => `[REDACTED:${kind}]`;
781
- var whole = (kind) => () => mark(kind);
782
- 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*)`;
783
- 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]*)`;
784
- var NOT_A_VALUE = String.raw`(?:true|false|null|none|undefined|await|new|this|self|string|number|boolean|object|unknown|bool)\b`;
785
- var RULES = [
786
- { kind: "private_key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replace: whole("private_key") },
787
- { kind: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, replace: whole("anthropic_key") },
788
- { kind: "openai_key", re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, replace: whole("openai_key") },
789
- { kind: "github_token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, replace: whole("github_token") },
790
- { kind: "aws_access_key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replace: whole("aws_access_key") },
791
- { kind: "stripe_key", re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, replace: whole("stripe_key") },
792
- { kind: "slack_token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: whole("slack_token") },
793
- { kind: "google_api_key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, replace: whole("google_api_key") },
794
- { kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: whole("jwt") },
795
- { kind: "bearer", re: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{16,}=*/g, replace: (_m, g) => `${g[0]}${mark("bearer")}` },
796
- // Env rules run before config rules so an uppercase env line is counted once, as env_secret.
797
- {
798
- kind: "env_secret",
799
- re: new RegExp(String.raw`${ENV_KEY}(["'])(?!\[REDACTED:)[^\n"']{4,}\2`, "g"),
800
- replace: (_m, g) => `${g[0]}${g[1]}${mark("env_secret")}${g[1]}`
801
- },
802
- {
803
- kind: "env_secret",
804
- re: new RegExp(String.raw`${ENV_KEY}(?!["']|\[REDACTED:)[^\s"']{4,}`, "g"),
805
- replace: (_m, g) => `${g[0]}${mark("env_secret")}`
806
- },
807
- {
808
- kind: "config_secret",
809
- re: new RegExp(String.raw`${CONFIG_KEY}(["'${"`"}])(?!\[REDACTED:)(?:\\.|(?!\3)[^\n\\]){4,}\3`, "gi"),
810
- replace: (_m, g) => `${g[0]}${g[2]}${mark("config_secret")}${g[2]}`
811
- },
812
- {
813
- // The value is matched atomically (lookahead + backreference) so a call like `token = sign(x)` cannot
814
- // backtrack into a shorter match.
815
- kind: "config_secret",
816
- re: new RegExp(String.raw`${CONFIG_KEY}(?!${NOT_A_VALUE})(?=([^\s"'${"`"},;(){}\[\]<>]{4,}))\3(?![(\[<])`, "gi"),
817
- replace: (_m, g) => `${g[0]}${mark("config_secret")}`
818
- },
819
- {
820
- kind: "url_credentials",
821
- re: /\b([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:/@]+:)(?!\[REDACTED:)[^\s/@]+(?=@)/gi,
822
- replace: (_m, g) => `${g[0]}${mark("url_credentials")}`
823
- }
824
- ];
825
- function shannon(s) {
826
- const freq = /* @__PURE__ */ new Map();
827
- for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
828
- let h = 0;
829
- for (const n of freq.values()) {
830
- const p = n / s.length;
831
- h -= p * Math.log2(p);
832
- }
833
- return h;
1370
+ // ../core/src/extract/extract.ts
1371
+ import { basename as basename5, isAbsolute, relative as relative3 } from "node:path";
1372
+
1373
+ // ../core/src/extract/recent.ts
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 };
834
1390
  }
835
- var Redactor = class {
836
- counts = {};
837
- bump(kind) {
838
- this.counts[kind] = (this.counts[kind] ?? 0) + 1;
839
- }
840
- apply(text) {
841
- let out = text;
842
- for (const rule of RULES) {
843
- out = out.replace(rule.re, (match, ...rest) => {
844
- this.bump(rule.kind);
845
- const groups = rest.filter((x) => typeof x === "string");
846
- return rule.replace(match, groups);
847
- });
848
- }
849
- out = out.replace(/[A-Za-z0-9_\-+=]{32,}/g, (m) => {
850
- if (!/\d/.test(m) || !/[a-z]/.test(m) || !/[A-Z]/.test(m) || shannon(m) < 4.3) return m;
851
- this.bump("high_entropy");
852
- return mark("high_entropy");
853
- });
854
- return out;
855
- }
856
- };
857
1391
 
858
1392
  // ../core/src/extract/extract.ts
859
- import { basename as basename3, isAbsolute, relative as relative3 } from "node:path";
860
- var DECISION_RE = /\b(don['’]t|do not|never|must|always|keep|avoid|make sure|without|instead of)\b/i;
861
1393
  var TRANSCRIPT_MAX = 2e5;
1394
+ var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
1395
+ function isSlashCommand(p) {
1396
+ return SLASH_COMMAND_RE.test(p.trim());
1397
+ }
862
1398
  function isSubstantive(p) {
863
1399
  const t = p.trim();
864
- return !t.startsWith("/") && t.split(/\s+/).length >= 3;
1400
+ return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
865
1401
  }
866
1402
  var PROBES = /* @__PURE__ */ new Set([
867
1403
  "rg",
@@ -891,57 +1427,114 @@ var PROBES = /* @__PURE__ */ new Set([
891
1427
  "pwd"
892
1428
  ]);
893
1429
  var GIT_PROBES = /* @__PURE__ */ new Set(["diff", "grep", "show", "log", "status", "rev-parse", "ls-files", "branch"]);
894
- var LEADING_CD_RE = /^\s*cd\s+(?:"[^"]*"|'[^']*'|[^\s;&]+)\s*(?:&&|;)\s*/;
895
- function isProbeCommand(cmd) {
896
- let s = cmd;
897
- for (let m = LEADING_CD_RE.exec(s); m; m = LEADING_CD_RE.exec(s)) s = s.slice(m[0].length);
898
- const words = s.trim().split(/\s+/);
899
- 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] ?? "");
900
1461
  if (first !== "git") return PROBES.has(first);
901
1462
  let i = 1;
902
1463
  while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
903
1464
  return GIT_PROBES.has(words[i] ?? "");
904
1465
  }
905
- function findBlocker(cmds) {
906
- for (let i = cmds.length - 1; i >= 0; i--) {
907
- const c = cmds[i];
908
- if (c.exitCode === null || c.exitCode === 0 || isProbeCommand(c.cmd)) continue;
909
- if (!cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return c;
1466
+ function isProbeCommand(cmd) {
1467
+ const stages = splitShell(cmd, true).filter((s) => !isCd(s));
1468
+ return stages.length > 0 && stages.every(isProbeStage);
1469
+ }
1470
+ var FAILURE_WINDOW = 5;
1471
+ function findLastFailure(cmds) {
1472
+ const real = cmds.filter((c) => !isProbeCommand(c.cmd));
1473
+ for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
1474
+ const c = real[i];
1475
+ if (c.exitCode === null || c.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;
1483
+ return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
910
1484
  }
911
1485
  return null;
912
1486
  }
913
- function findDeadEnds(cmds, blocker) {
914
- const seen = /* @__PURE__ */ new Set();
915
- const out = [];
916
- cmds.forEach((c, i) => {
917
- if (c.exitCode === null || c.exitCode === 0 || seen.has(c.cmd) || isProbeCommand(c.cmd)) return;
918
- if (blocker && c.cmd === blocker.cmd) return;
919
- if (cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return;
920
- seen.add(c.cmd);
921
- out.push({ cmd: c.cmd, exitCode: c.exitCode });
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();
922
1496
  });
923
- return out.slice(-5);
924
- }
925
- function findDecisions(prompts) {
926
- const out = [];
927
- for (const p of prompts) {
928
- for (const s of p.split(/(?<=[.!?])\s+|\n+/)) {
929
- const t = s.trim();
930
- if (t.length >= 8 && t.length <= 300 && DECISION_RE.test(t) && !out.includes(t)) out.push(t);
931
- }
932
- }
933
- return out.slice(-8);
934
- }
935
- function buildTranscript(s, title, rel) {
936
- const lines = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
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
+ }
1514
+ function dropPartialLine(output) {
1515
+ if (!output.startsWith("\u2026")) return output;
1516
+ const nl = output.indexOf("\n");
1517
+ return nl === -1 ? "" : output.slice(nl + 1);
1518
+ }
1519
+ function buildTranscript(s, title, rel, redactor) {
1520
+ const r = (x) => redactor.apply(x);
1521
+ const lines2 = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
937
1522
  for (const t of s.timeline) {
938
- if (t.kind === "user") lines.push("**User:**", t.text, "");
939
- else if (t.kind === "assistant") lines.push("**Agent:**", t.text.length > 600 ? t.text.slice(0, 599) + "\u2026" : t.text, "");
940
- else if (t.kind === "command") lines.push(`\`$ ${oneLine(t.run.cmd, 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
941
- else lines.push(`Edited \`${rel(t.edit.path)}\` (${t.edit.kind})`, "");
942
- }
943
- const out = lines.join("\n");
944
- return out.length > TRANSCRIPT_MAX ? "\u2026(earlier transcript truncated)\n" + out.slice(out.length - TRANSCRIPT_MAX) : out;
1523
+ if (t.kind === "user") {
1524
+ const { handoff, rest } = splitHandoffPrompt(t.text);
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), "");
1527
+ } else if (t.kind === "assistant") {
1528
+ const text = r(t.text);
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})`, "");
1532
+ }
1533
+ const out = lines2.join("\n");
1534
+ if (out.length <= TRANSCRIPT_MAX) return out;
1535
+ const cut = out.slice(out.length - TRANSCRIPT_MAX);
1536
+ const nl = cut.indexOf("\n");
1537
+ return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
945
1538
  }
946
1539
  function extractHandoff(s, git2, o) {
947
1540
  const cwd = s.ref.cwd;
@@ -952,39 +1545,52 @@ function extractHandoff(s, git2, o) {
952
1545
  const r = relative3(workdir, p);
953
1546
  return r.startsWith("..") || isAbsolute(r) ? p : r;
954
1547
  };
955
- const allPrompts = s.timeline.flatMap((t) => t.kind === "user" ? [t.text] : []);
956
- const prompts = allPrompts.filter((p) => !isHandoffPrompt(p));
1548
+ const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
1549
+ const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
957
1550
  const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
958
1551
  const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
959
1552
  const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
960
- const carried = o.previous && o.previous.goal !== null && prompts.length < allPrompts.length ? o.previous : null;
961
- const goal = carried ? carried.goal : prompts.find(isSubstantive) ?? prompts[0] ?? null;
962
- const last = carried ? [...prompts].reverse().find(isSubstantive) ?? null : prompts.length ? prompts[prompts.length - 1] : null;
963
- const latestInstruction = last !== null && last !== goal && isSubstantive(last) ? last : null;
1553
+ const redactor = new Redactor();
1554
+ const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
1555
+ const prev = o.previous;
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;
1560
+ const carried = prev && prev.goal !== null && fromPrev ? prev : null;
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);
1565
+ const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
964
1566
  const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
965
- const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
966
1567
  const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
967
- const nextStep = nextTodo ? { text: nextTodo.text, source: "todo" } : lastAgent ? { text: lastAgent, source: "agent" } : null;
968
- const blocker = findBlocker(cmds);
969
- const editedFiles = [];
970
- const counts = /* @__PURE__ */ new Map();
971
- for (const e of edits) {
1568
+ const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
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);
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);
1580
+ const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
1581
+ const filesTouched = [];
1582
+ for (const e of [...edits].reverse()) {
972
1583
  const r = rel(e.path);
973
- if (!editedFiles.includes(r)) editedFiles.push(r);
974
- counts.set(r, (counts.get(r) ?? 0) + 1);
1584
+ if (!isAbsolute(r) && !filesTouched.includes(r)) filesTouched.push(r);
1585
+ if (filesTouched.length === 12) break;
975
1586
  }
976
- const readFirst = [...editedFiles].sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0)).slice(0, 3);
977
- const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
978
- const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
979
- const redactor = new Redactor();
980
- const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
1587
+ const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
981
1588
  const pad = (n2) => String(n2).padStart(2, "0");
982
1589
  const n = o.now;
983
1590
  const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
984
1591
  return {
985
1592
  baseId,
986
1593
  title,
987
- status: "in_progress",
988
1594
  createdAt: o.now,
989
1595
  from: {
990
1596
  tool: s.ref.tool,
@@ -996,294 +1602,26 @@ function extractHandoff(s, git2, o) {
996
1602
  },
997
1603
  workdir,
998
1604
  git: git2,
999
- goal,
1000
- latestInstruction,
1001
- nextStep,
1002
- blocker: blocker && blocker.exitCode !== null ? { cmd: blocker.cmd, exitCode: blocker.exitCode, output: blocker.output } : null,
1003
- progress,
1004
- progressSource,
1005
- editedFiles,
1006
- decisions: findDecisions(prompts),
1007
- deadEnds: findDeadEnds(cmds, blocker),
1008
- readFirst,
1009
- transcript: buildTranscript(s, title, rel),
1010
- unknownRecords: s.unknownRecords
1011
- };
1012
- }
1013
-
1014
- // ../core/src/brief/render.ts
1015
- var BUDGET = { goal: 150, latest: 100, next: 200, blocker: 250, progress: 300, files: 300, decisions: 250, deadEnds: 200 };
1016
- var REASON_TEXT = {
1017
- usage_limit: "it hit a usage limit",
1018
- crash: "it stopped with an error",
1019
- manual: "the user handed it off"
1020
- };
1021
- var j = (v) => JSON.stringify(v ?? null);
1022
- function quote(s) {
1023
- return s.split("\n").map((l) => l.trim() ? `> ${l}` : ">").join("\n");
1024
- }
1025
- function listWithin(lines, maxTokens, pointer) {
1026
- const max = maxTokens * CHARS_PER_TOKEN;
1027
- const out = [];
1028
- let used = 0;
1029
- for (let i = 0; i < lines.length; i++) {
1030
- const line = lines[i];
1031
- if (out.length > 0 && used + line.length + 1 > max) {
1032
- out.push(`- \u2026 ${lines.length - i} more (see \`${pointer}\`)`);
1033
- break;
1034
- }
1035
- out.push(line);
1036
- used += line.length + 1;
1037
- }
1038
- return out;
1039
- }
1040
- function tailWithin(s, maxTokens) {
1041
- const max = maxTokens * CHARS_PER_TOKEN;
1042
- const t = s.trimEnd();
1043
- if (t.length <= max) return t;
1044
- const cut = t.slice(t.length - max);
1045
- const nl = cut.indexOf("\n");
1046
- return "\u2026\n" + (nl >= 0 ? cut.slice(nl + 1) : cut);
1047
- }
1048
- function fenceFor(body) {
1049
- const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((r) => r.length));
1050
- return "`".repeat(Math.max(3, longest + 1));
1051
- }
1052
- function fileLine(f) {
1053
- const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
1054
- return `- \`${f.path}\` (${f.status}${counts})`;
1055
- }
1056
- function renderBrief(d, id, opts = {}) {
1057
- const dir = `.harlay/handoffs/${id}`;
1058
- const transcript = `${dir}/transcript.md`;
1059
- const front = [
1060
- "---",
1061
- "harlay: 1",
1062
- `id: ${j(id)}`,
1063
- `status: ${j(d.status)}`,
1064
- `created: ${j(d.createdAt.toISOString())}`,
1065
- "from:",
1066
- ` tool: ${j(d.from.tool)}`,
1067
- ` model: ${j(d.from.model)}`,
1068
- ` session: ${j(d.from.session)}`,
1069
- ` reason: ${j(d.from.reason)}`,
1070
- ` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
1071
- "repo:",
1072
- ` path: ${j(d.workdir)}`,
1073
- ...d.git ? [` branch: ${j(d.git.branch)}`, ` base_commit: ${j(d.git.head)}`, ` dirty_files: ${d.git.files.length}`, ` diff_sha: ${j(d.git.diffSha.slice(0, 12))}`] : [" git: false"],
1074
- "---"
1075
- ];
1076
- const header = [
1077
- `# Continue: ${d.title}`,
1078
- "",
1079
- `> You are continuing a coding task that another AI agent (${TOOL_LABEL[d.from.tool]}) started but could not finish (${REASON_TEXT[d.from.reason]}).`,
1080
- "> This brief was extracted from its session log and the repo.",
1081
- d.git ? '> First run `git status` and check it matches "Changed files". Do not redo finished work.' : "> Check the files listed below before editing. Do not redo finished work."
1082
- ];
1083
- const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
1084
- if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
1085
- const next = ["## 2. Next step"];
1086
- if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
1087
- else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
1088
- else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
1089
- const blocker = ["## 3. Blocker"];
1090
- if (d.blocker) {
1091
- const body = tailWithin(d.blocker.output, BUDGET.blocker);
1092
- const fence = fenceFor(body);
1093
- blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
1094
- } else {
1095
- blocker.push("No failing command recorded.");
1096
- }
1097
- const progress = ["## 4. Progress"];
1098
- if (d.progressSource === "none") progress.push("Nothing recorded yet.");
1099
- else {
1100
- if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
1101
- progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
1102
- }
1103
- const files = [];
1104
- if (d.git) {
1105
- files.push("## 5. Changed files (uncommitted)");
1106
- if (d.git.files.length === 0) files.push("Working tree is clean.");
1107
- else files.push(...listWithin(d.git.files.map(fileLine), BUDGET.files, `${dir}/diff.patch`), `Full diff: \`${dir}/diff.patch\``);
1108
- } else {
1109
- files.push("## 5. Changed files", "Not a git repository." + (d.editedFiles.length ? " Files the agent edited:" : ""));
1110
- files.push(...listWithin(d.editedFiles.map((f) => `- \`${f}\``), BUDGET.files, transcript));
1111
- }
1112
- const decisions = [
1113
- "## 6. Decisions and constraints",
1114
- ...d.decisions.length ? listWithin(d.decisions.map((s) => `- "${oneLine(s, 300)}"`), BUDGET.decisions, transcript) : ["None recorded."]
1115
- ];
1116
- const deadEnds = [
1117
- "## 7. Dead ends (don't retry)",
1118
- ...d.deadEnds.length ? listWithin(d.deadEnds.map((e) => `- \`${oneLine(e.cmd, 160)}\` failed (exit ${e.exitCode})`), BUDGET.deadEnds, transcript) : ["None recorded."]
1119
- ];
1120
- const more = ["## 8. Read more", `- Condensed transcript: \`${transcript}\``];
1121
- if (opts.previousBrief) more.push(`- Previous brief (as last updated by the previous agent): \`${dir}/previous-HANDOFF.md\``);
1122
- if (d.readFirst.length) more.push(`- Read these first: ${d.readFirst.map((f) => `\`${f}\``).join(", ")}`);
1123
- const stop = ["## 9. When you stop", "Before ending your turn, update sections 2\u20134 and `status:` in this file so the next agent can continue."];
1124
- return [front, header, goal, next, blocker, progress, files, decisions, deadEnds, more, stop].map((s) => s.join("\n")).join("\n\n") + "\n";
1125
- }
1126
-
1127
- // ../core/src/brief/write.ts
1128
- import { access, appendFile, mkdir, readFile as readFile4, writeFile } from "node:fs/promises";
1129
- import { dirname, isAbsolute as isAbsolute2, join as join5 } from "node:path";
1130
- async function allocateHandoffId(handoffsDir, baseId) {
1131
- for (let i = 1; i < 1e3; i++) {
1132
- const id = i === 1 ? baseId : `${baseId}-${i}`;
1133
- try {
1134
- await access(join5(handoffsDir, id));
1135
- } catch {
1136
- return id;
1137
- }
1138
- }
1139
- throw new HarlayError(`Could not allocate a handoff folder for ${baseId}`);
1140
- }
1141
- async function ensureGitExclude(workdir) {
1142
- const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
1143
- const file = isAbsolute2(p) ? p : join5(workdir, p);
1144
- let current = "";
1145
- try {
1146
- current = await readFile4(file, "utf8");
1147
- } catch {
1148
- current = "";
1149
- }
1150
- if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1151
- await mkdir(dirname(file), { recursive: true });
1152
- await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
1153
- `);
1154
- }
1155
- async function readHandoffState(workdir) {
1156
- let raw;
1157
- try {
1158
- raw = JSON.parse(await readFile4(join5(workdir, ".harlay", "state.json"), "utf8"));
1159
- } catch {
1160
- return null;
1161
- }
1162
- if (typeof raw !== "object" || raw === null) return null;
1163
- const s = raw;
1164
- if (s.harlay !== 1 || typeof s.lastId !== "string" || typeof s.title !== "string") return null;
1165
- if (s.goal !== null && typeof s.goal !== "string") return null;
1166
- return { harlay: 1, lastId: s.lastId, goal: s.goal, title: s.title };
1167
- }
1168
- async function readIfExists(file) {
1169
- try {
1170
- return await readFile4(file, "utf8");
1171
- } catch {
1172
- return null;
1173
- }
1174
- }
1175
- async function writeHandoff(d) {
1176
- const harlayDir = join5(d.workdir, ".harlay");
1177
- const latestPath = join5(harlayDir, "HANDOFF.md");
1178
- const id = await allocateHandoffId(join5(harlayDir, "handoffs"), d.baseId);
1179
- const dir = join5(harlayDir, "handoffs", id);
1180
- await mkdir(dir, { recursive: true });
1181
- const r = new Redactor();
1182
- const previous = await readIfExists(latestPath);
1183
- if (previous !== null) await writeFile(join5(dir, "previous-HANDOFF.md"), r.apply(previous));
1184
- const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
1185
- const briefPath = join5(dir, "HANDOFF.md");
1186
- await writeFile(briefPath, brief);
1187
- await writeFile(join5(dir, "transcript.md"), r.apply(d.transcript));
1188
- if (d.git) await writeFile(join5(dir, "diff.patch"), r.apply(d.git.diff));
1189
- if (d.blocker) await writeFile(join5(dir, "last-error.txt"), r.apply(d.blocker.output));
1190
- const from = {
1191
- ...d.from,
1192
- message: d.from.message === null ? null : r.apply(d.from.message),
1193
- resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
1194
- };
1195
- const goal = d.goal === null ? null : r.apply(d.goal);
1196
- const title = r.apply(d.title);
1197
- const meta = {
1198
- harlay: 1,
1199
- id,
1200
- createdAt: d.createdAt.toISOString(),
1201
- title,
1202
- goal,
1203
- from,
1204
- workdir: d.workdir,
1205
- unknownRecords: d.unknownRecords,
1206
- git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
1207
- redactions: { ...r.counts }
1208
- };
1209
- await writeFile(join5(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
1210
- await writeFile(latestPath, brief);
1211
- const state = { harlay: 1, lastId: id, goal, title };
1212
- await writeFile(join5(harlayDir, "state.json"), JSON.stringify(state, null, 2) + "\n");
1213
- if (d.git) await ensureGitExclude(d.workdir);
1214
- return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
1215
- }
1216
-
1217
- // ../core/src/launch/launchers.ts
1218
- import { execFile as execFile2 } from "node:child_process";
1219
- import { chmod, mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
1220
- import { dirname as dirname2, join as join6 } from "node:path";
1221
- import { promisify as promisify2 } from "node:util";
1222
- var pexec2 = promisify2(execFile2);
1223
- var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
1224
- var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
1225
- var defaultRunner = {
1226
- async open(args) {
1227
- await pexec2("/usr/bin/open", args);
1228
- },
1229
- async writeExecutable(path, content) {
1230
- await mkdir2(dirname2(path), { recursive: true });
1231
- await writeFile2(path, content, { mode: 493 });
1232
- await chmod(path, 493);
1233
- },
1234
- sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
1235
- };
1236
- function shq(s) {
1237
- return `'${s.replace(/'/g, `'\\''`)}'`;
1238
- }
1239
- var enc = encodeURIComponent;
1240
- function planLaunch(o) {
1241
- if (o.mode === "app") {
1242
- switch (o.target) {
1243
- case "claude-code":
1244
- return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
1245
- case "codex":
1246
- return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
1247
- case "cursor":
1248
- return {
1249
- target: o.target,
1250
- mode: o.mode,
1251
- steps: [
1252
- { kind: "open", args: ["-a", "Cursor", o.cwd] },
1253
- { kind: "wait", ms: 1500 },
1254
- { kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
1255
- ]
1256
- };
1257
- }
1258
- }
1259
- const bin = o.binaries[o.target];
1260
- if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
1261
- const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
1262
- const path = join6(o.scriptDir, `continue-${o.target}.command`);
1263
- const content = `#!/bin/zsh -l
1264
- cd -- ${shq(o.cwd)} || exit 1
1265
- exec ${argv.map(shq).join(" ")}
1266
- `;
1267
- return {
1268
- target: o.target,
1269
- mode: o.mode,
1270
- steps: [
1271
- { kind: "script", path, content },
1272
- { kind: "open", args: ["-a", o.terminalApp, path] }
1273
- ]
1605
+ originalGoal,
1606
+ userMessages,
1607
+ omittedMessages,
1608
+ whereItStopped,
1609
+ lastRequest: userMessages.at(-1) ?? null,
1610
+ todos: openTodos,
1611
+ results,
1612
+ references,
1613
+ filesTouched,
1614
+ lastFailure,
1615
+ transcript: buildTranscript(s, title, rel, redactor),
1616
+ unknownRecords: s.unknownRecords,
1617
+ redactions: { ...redactor.counts },
1618
+ carriedFrom: carried ? carried.id : null
1274
1619
  };
1275
1620
  }
1276
- async function executeLaunch(plan, runner = defaultRunner) {
1277
- for (const step of plan.steps) {
1278
- if (step.kind === "open") await runner.open(step.args);
1279
- else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
1280
- else await runner.sleep(step.ms);
1281
- }
1282
- }
1283
1621
 
1284
1622
  // ../core/src/launch/resolve.ts
1285
1623
  import { constants } from "node:fs";
1286
- import { access as access2, stat as stat5 } from "node:fs/promises";
1624
+ import { access, stat as stat7 } from "node:fs/promises";
1287
1625
  import { homedir } from "node:os";
1288
1626
  import { join as join7 } from "node:path";
1289
1627
  function defaultResolveOptions(env = process.env) {
@@ -1310,8 +1648,8 @@ function knownPaths(tool, o) {
1310
1648
  }
1311
1649
  async function isExecutable(p) {
1312
1650
  try {
1313
- if (!(await stat5(p)).isFile()) return false;
1314
- await access2(p, constants.X_OK);
1651
+ if (!(await stat7(p)).isFile()) return false;
1652
+ await access(p, constants.X_OK);
1315
1653
  return true;
1316
1654
  } catch {
1317
1655
  return false;
@@ -1393,47 +1731,93 @@ function parseSessionSpec(spec) {
1393
1731
  }
1394
1732
 
1395
1733
  // ../core/src/handoff.ts
1396
- 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
+ }
1397
1751
  async function runHandoff(o) {
1398
1752
  const cwd = o.session.cwd;
1399
1753
  if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
1400
1754
  try {
1401
- if (!(await stat6(cwd)).isDirectory()) throw new Error("not a directory");
1755
+ if (!(await stat8(cwd)).isDirectory()) throw new Error("not a directory");
1402
1756
  } catch {
1403
1757
  throw new HarlayError(`The session's folder no longer exists: ${cwd}`);
1404
1758
  }
1405
1759
  const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
1406
1760
  const git2 = await takeGitSnapshot(cwd);
1407
- const state = await readHandoffState(git2?.root ?? cwd);
1408
- const previous = state ? { id: state.lastId, goal: state.goal, title: state.title } : null;
1761
+ const previous = await readPreviousHandoff(o.store, git2?.root ?? cwd);
1409
1762
  const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
1410
- const written = await writeHandoff(data);
1763
+ const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom, store: o.store });
1411
1764
  if (o.launch === false) return { written, plan: null, data };
1412
1765
  let plan;
1413
1766
  try {
1414
- const binaries = await resolveBinaries(o.resolve ?? defaultResolveOptions());
1415
- plan = planLaunch({
1416
- target: o.target,
1417
- mode: o.mode,
1418
- cwd: data.workdir,
1419
- prompt: HANDOFF_PROMPT,
1420
- scriptDir: written.dir,
1421
- binaries,
1422
- terminalApp: o.terminalApp ?? "Terminal"
1423
- });
1424
- await executeLaunch(plan, o.runner);
1767
+ plan = await launchHandoff({ ...o, cwd: data.workdir, id: written.id, prompt: written.prompt });
1425
1768
  } catch (e) {
1426
1769
  const reason = (e instanceof Error ? e.message : String(e)).trim().replace(/\.$/, "");
1427
- 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);
1428
1771
  }
1429
1772
  return { written, plan, data };
1430
1773
  }
1431
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
+
1432
1816
  // ../core/src/watch/watcher.ts
1433
1817
  import { EventEmitter } from "node:events";
1434
1818
  import { watch } from "node:fs";
1435
- import { open as open2, stat as stat7 } from "node:fs/promises";
1436
- 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";
1437
1821
  import { StringDecoder } from "node:string_decoder";
1438
1822
  var Watcher = class extends EventEmitter {
1439
1823
  constructor(readers, opts = {}) {
@@ -1457,14 +1841,14 @@ var Watcher = class extends EventEmitter {
1457
1841
  for (const r of this.readers) {
1458
1842
  for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
1459
1843
  try {
1460
- this.offsets.set(f.path, (await stat7(f.path)).size);
1844
+ this.offsets.set(f.path, (await stat9(f.path)).size);
1461
1845
  } catch {
1462
1846
  }
1463
1847
  }
1464
1848
  if (this.opts.fsWatch === false) continue;
1465
1849
  try {
1466
1850
  const w = watch(r.root, { recursive: true }, (_event, name) => {
1467
- if (name) this.schedule(join8(r.root, name.toString()));
1851
+ if (name) this.schedule(join9(r.root, name.toString()));
1468
1852
  });
1469
1853
  w.on("error", (e) => this.emit("error", e));
1470
1854
  this.watchers.push(w);
@@ -1485,7 +1869,7 @@ var Watcher = class extends EventEmitter {
1485
1869
  for (const f of await listSessionFiles(r.root, r.isSessionFile)) {
1486
1870
  let size;
1487
1871
  try {
1488
- size = (await stat7(f.path)).size;
1872
+ size = (await stat9(f.path)).size;
1489
1873
  } catch {
1490
1874
  continue;
1491
1875
  }
@@ -1542,7 +1926,7 @@ var Watcher = class extends EventEmitter {
1542
1926
  if (!reader) return [];
1543
1927
  let size;
1544
1928
  try {
1545
- size = (await stat7(file)).size;
1929
+ size = (await stat9(file)).size;
1546
1930
  } catch {
1547
1931
  return [];
1548
1932
  }
@@ -1566,10 +1950,10 @@ var Watcher = class extends EventEmitter {
1566
1950
  decoder = new StringDecoder("utf8");
1567
1951
  this.decoders.set(file, decoder);
1568
1952
  }
1569
- const lines = ((this.partial.get(file) ?? "") + decoder.write(buf)).split("\n");
1570
- 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() ?? "");
1571
1955
  const detected = [];
1572
- for (const line of lines) {
1956
+ for (const line of lines2) {
1573
1957
  if (!line.trim()) continue;
1574
1958
  let rec;
1575
1959
  try {
@@ -1620,30 +2004,30 @@ var SAMPLE_MAX_AGE_MS = 30 * 24 * HOUR;
1620
2004
  var PACE_MS2 = 30 * 6e4;
1621
2005
 
1622
2006
  // ../core/src/usage/bridge.ts
1623
- import { chmod as chmod2, mkdir as mkdir3, readFile as readFile5, rename, rm, writeFile as writeFile3 } from "node:fs/promises";
1624
- 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";
1625
2009
  var BRIDGE_MARKER = "# harlay-usage";
1626
2010
  function claudeSettingsPath(home) {
1627
- return join9(home, ".claude", "settings.json");
2011
+ return join10(home, ".claude", "settings.json");
1628
2012
  }
1629
- var previousJson = (dataDir) => join9(dataDir, "claude-statusline.previous.json");
1630
- var previousCommand = (dataDir) => join9(dataDir, "claude-statusline.previous");
1631
- function isObj2(x) {
2013
+ var previousJson = (dataDir) => join10(dataDir, "claude-statusline.previous.json");
2014
+ var previousCommand = (dataDir) => join10(dataDir, "claude-statusline.previous");
2015
+ function isObj3(x) {
1632
2016
  return typeof x === "object" && x !== null && !Array.isArray(x);
1633
2017
  }
1634
2018
  function isOurs(statusLine) {
1635
- return isObj2(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
2019
+ return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
1636
2020
  }
1637
2021
  async function readSettings(path) {
1638
2022
  let text;
1639
2023
  try {
1640
- text = await readFile5(path, "utf8");
2024
+ text = await readFile6(path, "utf8");
1641
2025
  } catch (e) {
1642
2026
  if (e.code === "ENOENT") return {};
1643
2027
  throw e;
1644
2028
  }
1645
2029
  const parsed = JSON.parse(text);
1646
- if (!isObj2(parsed)) throw new Error(`${path} is not a JSON object`);
2030
+ if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
1647
2031
  return parsed;
1648
2032
  }
1649
2033
  async function writeAtomic(path, text) {
@@ -1657,23 +2041,50 @@ async function removeBridge(home, dataDir) {
1657
2041
  if (!isOurs(s.statusLine)) return;
1658
2042
  let prev;
1659
2043
  try {
1660
- prev = JSON.parse(await readFile5(previousJson(dataDir), "utf8"));
2044
+ prev = JSON.parse(await readFile6(previousJson(dataDir), "utf8"));
1661
2045
  } catch {
1662
2046
  prev = void 0;
1663
2047
  }
1664
2048
  if (prev === void 0) delete s.statusLine;
1665
2049
  else s.statusLine = prev;
1666
2050
  await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
1667
- await rm(previousJson(dataDir), { force: true });
1668
- await rm(previousCommand(dataDir), { force: true });
2051
+ await rm2(previousJson(dataDir), { force: true });
2052
+ await rm2(previousCommand(dataDir), { force: true });
1669
2053
  }
1670
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
+
1671
2082
  // src/app.ts
1672
2083
  import { execFile as execFile3 } from "node:child_process";
1673
2084
  import { existsSync } from "node:fs";
1674
- import { cp, mkdir as mkdir4, mkdtemp, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1675
- import { homedir as homedir3, tmpdir } from "node:os";
1676
- 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";
1677
2088
  import { fileURLToPath } from "node:url";
1678
2089
  import { promisify as promisify3 } from "node:util";
1679
2090
  var run = promisify3(execFile3);
@@ -1682,19 +2093,19 @@ var APP_BUNDLE_ID = "com.corelabs.harlay";
1682
2093
  var APP_FILES = ["dist", "renderer", "assets"];
1683
2094
  function findAppSource(cliFile = fileURLToPath(import.meta.url)) {
1684
2095
  const pkg = resolve(dirname3(cliFile), "..");
1685
- for (const dir of [join10(pkg, "app"), resolve(pkg, "..", "..", "apps", "desktop")]) {
1686
- 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;
1687
2098
  }
1688
2099
  return null;
1689
2100
  }
1690
2101
  async function makeIcns(png, work) {
1691
- const set = join10(work, "icon.iconset");
2102
+ const set = join11(work, "icon.iconset");
1692
2103
  await mkdir4(set);
1693
2104
  for (const size of [16, 32, 128, 256, 512]) {
1694
- await run("sips", ["-z", String(size), String(size), png, "--out", join10(set, `icon_${size}x${size}.png`)]);
1695
- 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`)]);
1696
2107
  }
1697
- const icns = join10(work, "icon.icns");
2108
+ const icns = join11(work, "icon.icns");
1698
2109
  await run("iconutil", ["-c", "icns", set, "-o", icns]);
1699
2110
  return icns;
1700
2111
  }
@@ -1713,7 +2124,7 @@ async function quitRunningApp() {
1713
2124
  }
1714
2125
  }
1715
2126
  function harlayDataDir(home = homedir3()) {
1716
- return join10(home, "Library", "Application Support", "Harlay");
2127
+ return join11(home, "Library", "Application Support", "Harlay");
1717
2128
  }
1718
2129
  async function uninstallApp(opts) {
1719
2130
  await quitRunningApp();
@@ -1724,7 +2135,7 @@ async function uninstallApp(opts) {
1724
2135
  opts.log(`Left ~/.claude/settings.json unchanged: ${e instanceof Error ? e.message : String(e)}
1725
2136
  `);
1726
2137
  }
1727
- const app = join10(opts.dir, "Harlay.app");
2138
+ const app = join11(opts.dir, "Harlay.app");
1728
2139
  if (!existsSync(app)) {
1729
2140
  opts.log(`No Harlay.app in ${opts.dir}.
1730
2141
  `);
@@ -1738,21 +2149,21 @@ async function installApp(opts) {
1738
2149
  if (process.platform !== "darwin") throw new Error("The Harlay app is macOS only; the CLI works without it.");
1739
2150
  const source = findAppSource();
1740
2151
  if (!source) throw new Error("App files are missing from this install. Reinstall with `npm install -g harlay`.");
1741
- const work = await mkdtemp(join10(tmpdir(), "harlay-app-"));
2152
+ const work = await mkdtemp(join11(tmpdir2(), "harlay-app-"));
1742
2153
  try {
1743
- const appSrc = join10(work, "src");
1744
- 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 });
1745
2156
  await writeFile4(
1746
- join10(appSrc, "package.json"),
2157
+ join11(appSrc, "package.json"),
1747
2158
  JSON.stringify({ name: "harlay-desktop", productName: "Harlay", version: opts.version, main: "dist/main.cjs" }, null, 2)
1748
2159
  );
1749
- const icon = await makeIcns(join10(source, "assets", "icon.png"), work);
2160
+ const icon = await makeIcns(join11(source, "assets", "icon.png"), work);
1750
2161
  opts.log(`Building Harlay.app (Electron ${ELECTRON_VERSION}; the first install downloads it, about 100 MB)\u2026
1751
2162
  `);
1752
2163
  const { packager } = await import("@electron/packager");
1753
2164
  const [outDir] = await packager({
1754
2165
  dir: appSrc,
1755
- out: join10(work, "out"),
2166
+ out: join11(work, "out"),
1756
2167
  name: "Harlay",
1757
2168
  platform: "darwin",
1758
2169
  arch: process.arch === "arm64" ? "arm64" : "x64",
@@ -1767,29 +2178,29 @@ async function installApp(opts) {
1767
2178
  quiet: true,
1768
2179
  extendInfo: { LSUIElement: true, NSUserNotificationAlertStyle: "alert" }
1769
2180
  });
1770
- const built = join10(outDir, "Harlay.app");
2181
+ const built = join11(outDir, "Harlay.app");
1771
2182
  await run("codesign", ["--force", "--deep", "--sign", "-", built]);
1772
- const dest = join10(opts.dir, "Harlay.app");
2183
+ const dest = join11(opts.dir, "Harlay.app");
1773
2184
  await quitRunningApp();
1774
2185
  await mkdir4(opts.dir, { recursive: true });
1775
- await rm2(dest, { recursive: true, force: true });
2186
+ await rm3(dest, { recursive: true, force: true });
1776
2187
  await run("ditto", [built, dest]);
1777
2188
  opts.log(`Installed ${dest}
1778
2189
  `);
1779
2190
  if (opts.open) await run("open", [dest]);
1780
2191
  return dest;
1781
2192
  } finally {
1782
- await rm2(work, { recursive: true, force: true });
2193
+ await rm3(work, { recursive: true, force: true });
1783
2194
  }
1784
2195
  }
1785
2196
 
1786
2197
  // src/main.ts
1787
- var VERSION = "0.1.2";
2198
+ var VERSION = "0.2.0";
1788
2199
  var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
1789
2200
 
1790
2201
  Usage:
1791
2202
  harlay sessions [--limit N] [--json]
1792
- 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]
1793
2204
  harlay watch [--auto <claude|codex|cursor>] [--mode app|terminal] [--threshold N]
1794
2205
  harlay app install [--dir <folder>] [--no-open] install or update the macOS menu bar app (default folder: /Applications)
1795
2206
  harlay app uninstall [--dir <folder>] quit it, restore Claude Code's status line, move it to the Trash
@@ -1849,7 +2260,8 @@ async function cmdHandoff(args, readers, ctx) {
1849
2260
  session: { type: "string" },
1850
2261
  mode: { type: "string" },
1851
2262
  "no-launch": { type: "boolean" },
1852
- terminal: { type: "string" }
2263
+ terminal: { type: "string" },
2264
+ wait: { type: "boolean" }
1853
2265
  },
1854
2266
  strict: true
1855
2267
  });
@@ -1857,18 +2269,20 @@ async function cmdHandoff(args, readers, ctx) {
1857
2269
  const target = parseTool(values.to);
1858
2270
  const mode = parseMode(values.mode, target);
1859
2271
  const session = await pickSession(readers, values.session, ctx.cwd, ctx.env.HARLAY_HOME ?? homedir4());
2272
+ const since = /* @__PURE__ */ new Date();
1860
2273
  const res = await runHandoff({
1861
2274
  session,
1862
2275
  target,
1863
2276
  mode,
1864
2277
  readers,
2278
+ store: handoffStore(ctx.env.HARLAY_HOME ?? homedir4()),
1865
2279
  launch: !values["no-launch"],
1866
2280
  terminalApp: values.terminal ?? "Terminal",
1867
2281
  runner: ctx.runner,
1868
2282
  resolve: defaultResolveOptions(ctx.env)
1869
2283
  });
1870
- ctx.out(`Brief: ${res.written.latestPath}
1871
- Archived: ${res.written.dir}
2284
+ ctx.out(`Handoff: ${res.written.path}
2285
+ Prompt: ${res.written.prompt}
1872
2286
  `);
1873
2287
  const kinds = Object.keys(res.written.redactions);
1874
2288
  if (kinds.length) {
@@ -1878,8 +2292,29 @@ Archived: ${res.written.dir}
1878
2292
  }
1879
2293
  ctx.out(res.plan ? `Continuing in ${TOOL_LABEL[target]} (${mode}).
1880
2294
  ` : "Not launched (--no-launch).\n");
1881
- 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;
1882
2316
  }
2317
+ var WAIT_SECONDS = 180;
1883
2318
  async function cmdWatch(args, readers, ctx) {
1884
2319
  const { values } = parseArgs({
1885
2320
  args,
@@ -1890,6 +2325,7 @@ async function cmdWatch(args, readers, ctx) {
1890
2325
  const mode = auto ? parseMode(values.mode, auto) : "app";
1891
2326
  const threshold = values.threshold ? Number(values.threshold) : 90;
1892
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());
1893
2329
  const watcher = new Watcher(readers, { usageThreshold: threshold });
1894
2330
  watcher.on("error", (e) => ctx.err(`harlay: watcher error: ${e instanceof Error ? e.message : String(e)}
1895
2331
  `));
@@ -1904,7 +2340,7 @@ async function cmdWatch(args, readers, ctx) {
1904
2340
  ctx.out(`[${when}] ${name}: stopped \u2014 ${ev.event.halt.message}
1905
2341
  `);
1906
2342
  if (auto && auto !== ev.ref.tool) {
1907
- 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}
1908
2344
  `)).catch((e) => ctx.err(` \u2192 handoff failed: ${e instanceof Error ? e.message : String(e)}
1909
2345
  `));
1910
2346
  }