harlay 0.1.2 → 0.1.3

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 {
@@ -24,20 +27,112 @@ var LaunchError = class extends HarlayError {
24
27
  written;
25
28
  };
26
29
 
30
+ // ../core/src/launch/launchers.ts
31
+ import { execFile } from "node:child_process";
32
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
33
+ import { dirname, join } from "node:path";
34
+ import { promisify } from "node:util";
35
+ var pexec = promisify(execFile);
36
+ var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
37
+ var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
38
+ var PROMPT_LIMIT = {
39
+ "claude-code": { app: 6e3, terminal: 1e5 },
40
+ codex: { app: 6e3, terminal: 1e5 },
41
+ cursor: { app: 9900, terminal: 1e5 }
42
+ };
43
+ function promptFits(target, mode, prompt) {
44
+ const length = mode === "app" ? encodeURIComponent(prompt).length : prompt.length;
45
+ return length <= PROMPT_LIMIT[target][mode];
46
+ }
47
+ var defaultRunner = {
48
+ async open(args) {
49
+ await pexec("/usr/bin/open", args);
50
+ },
51
+ async writeExecutable(path, content) {
52
+ await mkdir(dirname(path), { recursive: true });
53
+ await writeFile(path, content, { mode: 493 });
54
+ await chmod(path, 493);
55
+ },
56
+ sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
57
+ };
58
+ function shq(s) {
59
+ return `'${s.replace(/'/g, `'\\''`)}'`;
60
+ }
61
+ var enc = encodeURIComponent;
62
+ function planLaunch(o) {
63
+ if (o.mode === "app") {
64
+ switch (o.target) {
65
+ case "claude-code":
66
+ return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
67
+ case "codex":
68
+ return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
69
+ case "cursor":
70
+ return {
71
+ target: o.target,
72
+ mode: o.mode,
73
+ steps: [
74
+ { kind: "open", args: ["-a", "Cursor", o.cwd] },
75
+ { kind: "wait", ms: 1500 },
76
+ { kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
77
+ ]
78
+ };
79
+ }
80
+ }
81
+ const bin = o.binaries[o.target];
82
+ if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
83
+ const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
84
+ const path = join(o.scriptDir, `continue-${o.target}.command`);
85
+ const content = `#!/bin/zsh -l
86
+ cd -- ${shq(o.cwd)} || exit 1
87
+ exec ${argv.map(shq).join(" ")}
88
+ `;
89
+ return {
90
+ target: o.target,
91
+ mode: o.mode,
92
+ steps: [
93
+ { kind: "script", path, content },
94
+ { kind: "open", args: ["-a", o.terminalApp, path] }
95
+ ]
96
+ };
97
+ }
98
+ async function executeLaunch(plan, runner = defaultRunner) {
99
+ for (const step of plan.steps) {
100
+ if (step.kind === "open") await runner.open(step.args);
101
+ else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
102
+ else await runner.sleep(step.ms);
103
+ }
104
+ }
105
+
27
106
  // ../core/src/util/text.ts
28
107
  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
- }
108
+ var isHigh = (c) => c >= 55296 && c <= 56319;
109
+ var isLow = (c) => c >= 56320 && c <= 57343;
35
110
  function tail(s, maxChars) {
36
- return s.length <= maxChars ? s : "\u2026" + s.slice(s.length - maxChars + 1);
111
+ if (s.length <= maxChars) return s;
112
+ let start = s.length - maxChars + 1;
113
+ if (start < s.length && isLow(s.charCodeAt(start))) start++;
114
+ return "\u2026" + s.slice(start);
115
+ }
116
+ var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
117
+ function wellFormed(s) {
118
+ return s.replace(LONE_SURROGATE, "\uFFFD");
37
119
  }
38
120
  function oneLine(s, max = 120) {
39
121
  const t = s.replace(/\s+/g, " ").trim();
40
- return t.length <= max ? t : t.slice(0, max - 1) + "\u2026";
122
+ if (t.length <= max) return t;
123
+ let end = max - 1;
124
+ if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
125
+ return t.slice(0, end) + "\u2026";
126
+ }
127
+ function wordCount(s) {
128
+ return s.split(/\s+/).filter(Boolean).length;
129
+ }
130
+ function clipWords(s, max) {
131
+ const w = s.split(/\s+/).filter(Boolean);
132
+ return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
133
+ }
134
+ function splitSentences(s) {
135
+ return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
41
136
  }
42
137
  var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
43
138
  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/;
@@ -48,8 +143,27 @@ function cleanPrompt(raw) {
48
143
  if (MACHINE_TAG_RE.test(s)) return null;
49
144
  return s ? s : null;
50
145
  }
51
- function isHandoffPrompt(p) {
52
- return p.length <= 300 && p.includes(".harlay/HANDOFF.md");
146
+ var HANDOFF_TAG = '<handoff from="';
147
+ var HANDOFF_END = "</handoff>";
148
+ var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
149
+ var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
150
+ function splitHandoffPrompt(p) {
151
+ const start = p.indexOf(HANDOFF_TAG);
152
+ const end = p.lastIndexOf(HANDOFF_END);
153
+ if (start !== -1 && end > start) {
154
+ const before = p.slice(0, start).trim();
155
+ const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
156
+ const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
157
+ return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
158
+ }
159
+ if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
160
+ return { handoff: false, rest: p, id: null };
161
+ }
162
+ var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
163
+ var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
164
+ var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
165
+ function stripControl(s) {
166
+ return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
53
167
  }
54
168
  function slugify(s, max = 40) {
55
169
  const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
@@ -58,11 +172,11 @@ function slugify(s, max = 40) {
58
172
 
59
173
  // ../core/src/sources/claude.ts
60
174
  import { readFile, stat as stat2 } from "node:fs/promises";
61
- import { basename, join as join2, relative, sep } from "node:path";
175
+ import { basename, join as join3, relative, sep } from "node:path";
62
176
 
63
177
  // ../core/src/util/files.ts
64
178
  import { open, readdir, stat } from "node:fs/promises";
65
- import { join } from "node:path";
179
+ import { join as join2 } from "node:path";
66
180
  async function listSessionFiles(root, accept) {
67
181
  let names;
68
182
  try {
@@ -72,7 +186,7 @@ async function listSessionFiles(root, accept) {
72
186
  }
73
187
  const out = [];
74
188
  for (const name of names) {
75
- const path = join(root, name);
189
+ const path = join2(root, name);
76
190
  if (!accept(path)) continue;
77
191
  try {
78
192
  const s = await stat(path);
@@ -280,8 +394,8 @@ async function loadDesktopTitles(dir) {
280
394
  return out;
281
395
  }
282
396
  function createClaudeReader(o) {
283
- const root = join2(o.home, ".claude", "projects");
284
- const indexDir = join2(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
397
+ const root = join3(o.home, ".claude", "projects");
398
+ const indexDir = join3(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
285
399
  const isSessionFile = (file) => file.startsWith(root + sep) && file.endsWith(".jsonl") && relative(root, file).split(sep).length === 2;
286
400
  const cache = /* @__PURE__ */ new Map();
287
401
  async function refForFile(file, titles) {
@@ -357,7 +471,7 @@ function createClaudeReader(o) {
357
471
 
358
472
  // ../core/src/sources/codex.ts
359
473
  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";
474
+ import { basename as basename2, join as join4, sep as sep2 } from "node:path";
361
475
  var OUTPUT_TAIL2 = 2e4;
362
476
  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
477
  var KIND = { add: "create", update: "modify", delete: "delete" };
@@ -473,8 +587,8 @@ async function loadTitles(file) {
473
587
  return out;
474
588
  }
475
589
  function createCodexReader(o) {
476
- const root = join3(o.home, ".codex", "sessions");
477
- const indexFile = join3(o.home, ".codex", "session_index.jsonl");
590
+ const root = join4(o.home, ".codex", "sessions");
591
+ const indexFile = join4(o.home, ".codex", "session_index.jsonl");
478
592
  const isSessionFile = (file) => file.startsWith(root + sep2) && basename2(file).startsWith("rollout-") && file.endsWith(".jsonl");
479
593
  const cache = /* @__PURE__ */ new Map();
480
594
  async function refForFile(file, titles) {
@@ -545,7 +659,7 @@ function createCodexReader(o) {
545
659
 
546
660
  // ../core/src/sources/cursor.ts
547
661
  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";
662
+ import { join as join5, relative as relative2, sep as sep3 } from "node:path";
549
663
  var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
550
664
  async function resolveCursorSlug(slug, rootDir = "/") {
551
665
  if (!slug || /^\d+$/.test(slug)) return null;
@@ -556,9 +670,9 @@ async function resolveCursorSlug(slug, rootDir = "/") {
556
670
  for (const e of entries) {
557
671
  if (!e.isDirectory()) continue;
558
672
  const n = norm(e.name);
559
- if (rest === n) return join4(dir, e.name);
673
+ if (rest === n) return join5(dir, e.name);
560
674
  if (rest.startsWith(n + "-")) {
561
- const found = await walk(join4(dir, e.name), rest.slice(n.length + 1), depth + 1);
675
+ const found = await walk(join5(dir, e.name), rest.slice(n.length + 1), depth + 1);
562
676
  if (found) return found;
563
677
  }
564
678
  }
@@ -618,7 +732,7 @@ function normalizeCursor(records, ref, invalid = 0) {
618
732
  return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
619
733
  }
620
734
  function createCursorReader(o) {
621
- const root = join4(o.home, ".cursor", "projects");
735
+ const root = join5(o.home, ".cursor", "projects");
622
736
  const slugCache = /* @__PURE__ */ new Map();
623
737
  const cwdFor = (slug) => {
624
738
  let p = slugCache.get(slug);
@@ -655,8 +769,9 @@ function createCursorReader(o) {
655
769
  for (const r of parseJsonLines(text).records) {
656
770
  if (isObj(r) && r.role === "user" && isObj(r.message)) {
657
771
  const p = cleanPrompt(blockText(r.message.content));
658
- if (p && !isHandoffPrompt(p)) {
659
- title = oneLine(p, 60);
772
+ const rest = p ? splitHandoffPrompt(p).rest : "";
773
+ if (rest) {
774
+ title = oneLine(rest, 60);
660
775
  break;
661
776
  }
662
777
  }
@@ -695,14 +810,14 @@ function createCursorReader(o) {
695
810
  }
696
811
 
697
812
  // ../core/src/git/snapshot.ts
698
- import { execFile } from "node:child_process";
813
+ import { execFile as execFile2 } from "node:child_process";
699
814
  import { createHash } from "node:crypto";
700
- import { promisify } from "node:util";
701
- var pexec = promisify(execFile);
815
+ import { promisify as promisify2 } from "node:util";
816
+ var pexec2 = promisify2(execFile2);
702
817
  var MAX_DIFF_CHARS = 2e6;
703
818
  var MAX_FILES = 500;
704
819
  async function git(cwd, args) {
705
- const { stdout } = await pexec("git", args, {
820
+ const { stdout } = await pexec2("git", args, {
706
821
  cwd,
707
822
  maxBuffer: 64 * 1024 * 1024,
708
823
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
@@ -857,11 +972,75 @@ var Redactor = class {
857
972
 
858
973
  // ../core/src/extract/extract.ts
859
974
  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;
975
+
976
+ // ../core/src/extract/recent.ts
977
+ var RECENT = { budgetTokens: 450, min: 2, max: 6, words: 120 };
978
+ function pickRecentMessages(prompts) {
979
+ const out = [];
980
+ let used = 0;
981
+ for (let i = prompts.length - 1; i >= 0 && out.length < RECENT.max; i--) {
982
+ const m = clipWords(prompts[i], RECENT.words);
983
+ if (!m) continue;
984
+ const cost = Math.ceil(m.length / CHARS_PER_TOKEN);
985
+ if (out.length >= RECENT.min && used + cost > RECENT.budgetTokens) break;
986
+ out.unshift(m);
987
+ used += cost;
988
+ }
989
+ return out;
990
+ }
991
+
992
+ // ../core/src/extract/rules.ts
993
+ var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
994
+ var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
995
+ var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
996
+ function findPinnedRules(prompts, recent) {
997
+ const recentText = norm2(recent.join("\n"));
998
+ const found = [];
999
+ prompts.forEach((p, message) => {
1000
+ if (wordCount(p) > RULES2.pastedWords) return;
1001
+ for (const sentence of splitSentences(p)) {
1002
+ if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
1003
+ if (recentText.includes(norm2(sentence))) continue;
1004
+ found.push({ text: clipWords(sentence, RULES2.words), message });
1005
+ }
1006
+ });
1007
+ return capRules(latestCopies(found));
1008
+ }
1009
+ function mergePinnedRules(older, newer, recent) {
1010
+ const recentText = norm2(recent.join("\n"));
1011
+ const all = [...older.map((r) => ({ text: r.text, message: -1 })), ...newer].filter((r) => !recentText.includes(norm2(r.text)));
1012
+ return capRules(latestCopies(all));
1013
+ }
1014
+ function latestCopies(rules) {
1015
+ const byKey = /* @__PURE__ */ new Map();
1016
+ for (const r of rules) {
1017
+ const key = norm2(r.text);
1018
+ byKey.delete(key);
1019
+ byKey.set(key, r);
1020
+ }
1021
+ return [...byKey.values()];
1022
+ }
1023
+ function capRules(all) {
1024
+ const kept = [];
1025
+ let used = 0;
1026
+ for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
1027
+ const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
1028
+ if (used + cost > RULES2.budgetTokens) break;
1029
+ kept.unshift(all[i]);
1030
+ used += cost;
1031
+ }
1032
+ return { rules: kept, more: all.length - kept.length };
1033
+ }
1034
+
1035
+ // ../core/src/extract/extract.ts
861
1036
  var TRANSCRIPT_MAX = 2e5;
1037
+ var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
1038
+ function isSlashCommand(p) {
1039
+ return SLASH_COMMAND_RE.test(p.trim());
1040
+ }
862
1041
  function isSubstantive(p) {
863
1042
  const t = p.trim();
864
- return !t.startsWith("/") && t.split(/\s+/).length >= 3;
1043
+ return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
865
1044
  }
866
1045
  var PROBES = /* @__PURE__ */ new Set([
867
1046
  "rg",
@@ -902,46 +1081,41 @@ function isProbeCommand(cmd) {
902
1081
  while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
903
1082
  return GIT_PROBES.has(words[i] ?? "");
904
1083
  }
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;
1084
+ var FAILURE_WINDOW = 5;
1085
+ function findLastFailure(cmds) {
1086
+ const real = cmds.filter((c) => !isProbeCommand(c.cmd));
1087
+ for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
1088
+ const c = real[i];
1089
+ if (c.exitCode === null || c.exitCode === 0) continue;
1090
+ if (real.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) continue;
1091
+ return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
910
1092
  }
911
1093
  return null;
912
1094
  }
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 });
922
- });
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);
1095
+ function dropPartialLine(output) {
1096
+ if (!output.startsWith("\u2026")) return output;
1097
+ const nl = output.indexOf("\n");
1098
+ return nl === -1 ? "" : output.slice(nl + 1);
934
1099
  }
935
- function buildTranscript(s, title, rel) {
1100
+ function buildTranscript(s, title, rel, redactor) {
1101
+ const r = (x) => redactor.apply(x);
936
1102
  const lines = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
937
1103
  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})`, "");
1104
+ if (t.kind === "user") {
1105
+ const { handoff, rest } = splitHandoffPrompt(t.text);
1106
+ if (handoff) lines.push("**Harlay handoff prompt** (from the previous agent; context is in the brief)", "");
1107
+ if (rest) lines.push("**User:**", r(rest), "");
1108
+ } else if (t.kind === "assistant") {
1109
+ const text = r(t.text);
1110
+ lines.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
1111
+ } else if (t.kind === "command") lines.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
1112
+ else lines.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
942
1113
  }
943
1114
  const out = lines.join("\n");
944
- return out.length > TRANSCRIPT_MAX ? "\u2026(earlier transcript truncated)\n" + out.slice(out.length - TRANSCRIPT_MAX) : out;
1115
+ if (out.length <= TRANSCRIPT_MAX) return out;
1116
+ const cut = out.slice(out.length - TRANSCRIPT_MAX);
1117
+ const nl = cut.indexOf("\n");
1118
+ return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
945
1119
  }
946
1120
  function extractHandoff(s, git2, o) {
947
1121
  const cwd = s.ref.cwd;
@@ -952,39 +1126,45 @@ function extractHandoff(s, git2, o) {
952
1126
  const r = relative3(workdir, p);
953
1127
  return r.startsWith("..") || isAbsolute(r) ? p : r;
954
1128
  };
955
- const allPrompts = s.timeline.flatMap((t) => t.kind === "user" ? [t.text] : []);
956
- const prompts = allPrompts.filter((p) => !isHandoffPrompt(p));
1129
+ const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
1130
+ const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
957
1131
  const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
958
1132
  const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
959
1133
  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;
1134
+ const redactor = new Redactor();
1135
+ const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
1136
+ const prev = o.previous;
1137
+ const fromPrev = prev ? split.some((p) => p.handoff && (p.id === null || p.id === prev.id)) : false;
1138
+ const carried = prev && prev.goal !== null && fromPrev ? prev : null;
1139
+ const carriedRecent = carried ? carried.recentUserMessages.map((m) => redactor.apply(m)) : [];
1140
+ const recentUserMessages = pickRecentMessages([...carriedRecent, ...realPrompts]);
1141
+ let { rules: pinnedRules, more: moreRules } = findPinnedRules(realPrompts, recentUserMessages);
1142
+ if (carried) {
1143
+ const older = [...carried.pinnedRules.map((r) => ({ ...r, text: redactor.apply(r.text) })), ...findPinnedRules(carriedRecent, []).rules];
1144
+ const merged = mergePinnedRules(older, pinnedRules, recentUserMessages);
1145
+ moreRules += merged.more + carried.moreRules;
1146
+ pinnedRules = merged.rules;
1147
+ }
1148
+ const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
964
1149
  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
1150
  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) {
1151
+ const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
1152
+ const openTodos = todos.flatMap((t) => t.status === "in_progress" || t.status === "pending" ? [{ text: redactor.apply(t.text).replace(/\s+/g, " ").trim(), status: t.status }] : []).slice(0, 8);
1153
+ const failure = findLastFailure(cmds);
1154
+ const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
1155
+ const filesTouched = [];
1156
+ for (const e of [...edits].reverse()) {
972
1157
  const r = rel(e.path);
973
- if (!editedFiles.includes(r)) editedFiles.push(r);
974
- counts.set(r, (counts.get(r) ?? 0) + 1);
1158
+ if (!isAbsolute(r) && !filesTouched.includes(r)) filesTouched.push(r);
1159
+ if (filesTouched.length === 12) break;
975
1160
  }
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");
1161
+ const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
981
1162
  const pad = (n2) => String(n2).padStart(2, "0");
982
1163
  const n = o.now;
983
1164
  const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
984
1165
  return {
985
1166
  baseId,
986
1167
  title,
987
- status: "in_progress",
988
1168
  createdAt: o.now,
989
1169
  from: {
990
1170
  tool: s.ref.tool,
@@ -996,142 +1176,155 @@ function extractHandoff(s, git2, o) {
996
1176
  },
997
1177
  workdir,
998
1178
  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
1179
+ originalGoal,
1180
+ recentUserMessages,
1181
+ pinnedRules,
1182
+ moreRules,
1183
+ whereItStopped,
1184
+ todos: openTodos,
1185
+ filesTouched,
1186
+ lastFailure,
1187
+ transcript: buildTranscript(s, title, rel, redactor),
1188
+ unknownRecords: s.unknownRecords,
1189
+ redactions: { ...redactor.counts },
1190
+ carriedFrom: carried ? carried.id : null
1011
1191
  };
1012
1192
  }
1013
1193
 
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 };
1194
+ // ../core/src/brief/write.ts
1195
+ import { access, appendFile, mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
1196
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join6 } from "node:path";
1197
+
1198
+ // ../core/src/brief/prompt.ts
1199
+ var TIER_MAX = { full: 6e3, compact: 3e3 };
1016
1200
  var REASON_TEXT = {
1017
1201
  usage_limit: "it hit a usage limit",
1018
1202
  crash: "it stopped with an error",
1019
1203
  manual: "the user handed it off"
1020
1204
  };
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;
1205
+ var OUR_TAGS = /<(\/?)(handoff|original_goal|rules_from_user|recent_user_messages|where_it_stopped|files|last_failure)\b/gi;
1206
+ function safe(s) {
1207
+ return s.replace(OUR_TAGS, "\u2039$1$2");
1208
+ }
1209
+ var attr = (s) => safe(s).replace(/"/g, "'").replace(/\s+/g, " ").trim();
1210
+ var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
1211
+ function goalIsRecent(goal, recent) {
1212
+ const g = norm3(goal.replace(/\s…$/, ""));
1213
+ return recent.some((m) => norm3(m).startsWith(g));
1214
+ }
1215
+ function renderPrompt(rec, tier) {
1216
+ const full = tier === "full";
1217
+ const from = TOOL_LABEL[rec.from.tool];
1218
+ const dir = `.harlay/handoffs/${rec.id}`;
1219
+ const out = [
1220
+ `<handoff from="${from}" reason="${rec.from.reason}" task="${attr(rec.task.title)}">`,
1221
+ `You are taking over a coding session that ${from} could not finish (${REASON_TEXT[rec.from.reason]}). What you need to start is below; don't re-read the repo to rebuild it.`
1222
+ ];
1223
+ const goal = rec.task.originalGoal;
1224
+ const noRecent = rec.task.recentUserMessages.length === 0;
1225
+ if (goal && (full ? !goalIsRecent(goal, rec.task.recentUserMessages) : noRecent)) out.push("", "<original_goal>", safe(goal), "</original_goal>");
1226
+ if (rec.task.pinnedRules.length) {
1227
+ out.push("", "<rules_from_user>", ...rec.task.pinnedRules.map((r) => `- ${safe(r.text)}`));
1228
+ if (rec.task.moreRules) out.push(`(${rec.task.moreRules} more in the transcript)`);
1229
+ out.push("</rules_from_user>");
1230
+ }
1231
+ const recent = full ? rec.task.recentUserMessages : rec.task.recentUserMessages.slice(-2);
1232
+ if (recent.length) out.push("", '<recent_user_messages oldest_first="true">', ...recent.map((m) => `- ${safe(m)}`), "</recent_user_messages>");
1233
+ const where = rec.state.whereItStopped === null ? null : full ? rec.state.whereItStopped : clipWords(rec.state.whereItStopped, 60);
1234
+ const todos = rec.state.todos.map((t) => `[${t.status === "in_progress" ? "in progress" : "pending"}] ${safe(t.text)}`);
1235
+ if (where || todos.length) {
1236
+ out.push("", "<where_it_stopped>");
1237
+ if (where) out.push(safe(where));
1238
+ if (todos.length) out.push(`Open todos: ${todos.join("; ")}`);
1239
+ out.push("</where_it_stopped>");
1240
+ }
1241
+ if (full && (rec.files.touched.length || rec.files.uncommitted.length)) {
1242
+ out.push("", "<files>");
1243
+ if (rec.files.touched.length) out.push(`Touched: ${rec.files.touched.map(safe).join(", ")}`);
1244
+ if (rec.files.uncommitted.length) {
1245
+ const list = rec.files.uncommitted.map((f) => `${safe(f.path)} (${f.status}${f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : ""})`);
1246
+ out.push(`Uncommitted: ${list.join(", ")}${rec.files.moreUncommitted ? `, and ${rec.files.moreUncommitted} more` : ""}`);
1034
1247
  }
1035
- out.push(line);
1036
- used += line.length + 1;
1248
+ out.push("</files>");
1037
1249
  }
1038
- return out;
1250
+ if (rec.lastFailure) {
1251
+ out.push("", "<last_failure>", `$ ${safe(rec.lastFailure.cmd)} (exit ${rec.lastFailure.exitCode})`);
1252
+ if (full) out.push(safe(rec.lastFailure.outputTail));
1253
+ out.push("</last_failure>");
1254
+ }
1255
+ const more = [`${dir}/transcript.md (search it, don't read it all)`];
1256
+ if (rec.details.diff) more.push(`${dir}/diff.patch`);
1257
+ const next = noRecent ? "Continue from where it stopped." : "Continue from the most recent user message.";
1258
+ out.push("", `Only if you need more: ${more.join(" and ")}.`, "</handoff>", "", `${next} Start with the next concrete step.`);
1259
+ return wellFormed(out.join("\n"));
1039
1260
  }
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));
1261
+ function choosePrompt(rec, fits) {
1262
+ for (const tier of ["full", "compact"]) {
1263
+ const text = renderPrompt(rec, tier);
1264
+ if (text.length <= TIER_MAX[tier] && fits(text)) return { tier, text };
1102
1265
  }
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";
1266
+ return { tier: "pointer", text: HANDOFF_PROMPT };
1267
+ }
1268
+
1269
+ // ../core/src/brief/record.ts
1270
+ var RECORD_SCHEMA = "harlay/handoff@2";
1271
+ var MAX_UNCOMMITTED = 15;
1272
+ function buildRecord(d, id, previousId, r) {
1273
+ const a = (s) => r.apply(stripControl(s));
1274
+ const files = d.git?.files ?? [];
1275
+ return {
1276
+ schema: RECORD_SCHEMA,
1277
+ id,
1278
+ createdAt: d.createdAt.toISOString(),
1279
+ from: {
1280
+ tool: d.from.tool,
1281
+ model: d.from.model,
1282
+ session: d.from.session,
1283
+ reason: d.from.reason,
1284
+ resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
1285
+ message: d.from.message === null ? null : a(d.from.message)
1286
+ },
1287
+ repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
1288
+ task: {
1289
+ title: a(d.title),
1290
+ originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
1291
+ recentUserMessages: d.recentUserMessages.map(a),
1292
+ pinnedRules: d.pinnedRules.map((p) => ({ text: a(p.text), message: p.message })),
1293
+ moreRules: d.moreRules
1294
+ },
1295
+ state: {
1296
+ whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
1297
+ todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
1298
+ },
1299
+ files: {
1300
+ touched: d.filesTouched.map(a),
1301
+ uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
1302
+ moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
1303
+ },
1304
+ lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, outputTail: a(d.lastFailure.output).split("\n").slice(-8).join("\n") } : null,
1305
+ details: { transcript: "transcript.md", diff: d.git ? "diff.patch" : null, lastError: d.lastFailure ? "last-error.txt" : null },
1306
+ previous: previousId ? { id: previousId } : null,
1307
+ unknownRecords: d.unknownRecords
1308
+ };
1309
+ }
1310
+ var isStr = (v) => typeof v === "string";
1311
+ var isObj2 = (v) => typeof v === "object" && v !== null;
1312
+ function validateRecord(raw) {
1313
+ if (!isObj2(raw) || raw.schema !== RECORD_SCHEMA || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
1314
+ const { task, from } = raw;
1315
+ if (!isObj2(from) || !isTool(from.tool)) return null;
1316
+ if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
1317
+ if (!Array.isArray(task.recentUserMessages) || !task.recentUserMessages.every(isStr)) return null;
1318
+ if (!Array.isArray(task.pinnedRules) || !task.pinnedRules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
1319
+ return raw;
1125
1320
  }
1126
1321
 
1127
1322
  // ../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
1323
  async function allocateHandoffId(handoffsDir, baseId) {
1131
1324
  for (let i = 1; i < 1e3; i++) {
1132
1325
  const id = i === 1 ? baseId : `${baseId}-${i}`;
1133
1326
  try {
1134
- await access(join5(handoffsDir, id));
1327
+ await access(join6(handoffsDir, id));
1135
1328
  } catch {
1136
1329
  return id;
1137
1330
  }
@@ -1140,7 +1333,7 @@ async function allocateHandoffId(handoffsDir, baseId) {
1140
1333
  }
1141
1334
  async function ensureGitExclude(workdir) {
1142
1335
  const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
1143
- const file = isAbsolute2(p) ? p : join5(workdir, p);
1336
+ const file = isAbsolute2(p) ? p : join6(workdir, p);
1144
1337
  let current = "";
1145
1338
  try {
1146
1339
  current = await readFile4(file, "utf8");
@@ -1148,14 +1341,14 @@ async function ensureGitExclude(workdir) {
1148
1341
  current = "";
1149
1342
  }
1150
1343
  if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
1151
- await mkdir(dirname(file), { recursive: true });
1344
+ await mkdir2(dirname2(file), { recursive: true });
1152
1345
  await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
1153
1346
  `);
1154
1347
  }
1155
1348
  async function readHandoffState(workdir) {
1156
1349
  let raw;
1157
1350
  try {
1158
- raw = JSON.parse(await readFile4(join5(workdir, ".harlay", "state.json"), "utf8"));
1351
+ raw = JSON.parse(await readFile4(join6(workdir, ".harlay", "state.json"), "utf8"));
1159
1352
  } catch {
1160
1353
  return null;
1161
1354
  }
@@ -1172,113 +1365,56 @@ async function readIfExists(file) {
1172
1365
  return null;
1173
1366
  }
1174
1367
  }
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
- };
1368
+ async function readPreviousHandoff(workdir) {
1369
+ const raw = await readIfExists(join6(workdir, ".harlay", "handoff.json"));
1370
+ if (raw !== null) {
1371
+ let rec;
1372
+ try {
1373
+ rec = validateRecord(JSON.parse(raw));
1374
+ } catch {
1375
+ return null;
1257
1376
  }
1377
+ if (!rec) return null;
1378
+ const { task } = rec;
1379
+ return {
1380
+ id: rec.id,
1381
+ goal: task.originalGoal,
1382
+ title: task.title,
1383
+ recentUserMessages: task.recentUserMessages,
1384
+ pinnedRules: task.pinnedRules,
1385
+ moreRules: typeof task.moreRules === "number" ? task.moreRules : 0
1386
+ };
1258
1387
  }
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
- ]
1274
- };
1388
+ const state = await readHandoffState(workdir);
1389
+ return state ? { id: state.lastId, goal: state.goal, title: state.title, recentUserMessages: [], pinnedRules: [], moreRules: 0 } : null;
1275
1390
  }
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
- }
1391
+ function addCounts(a, b) {
1392
+ const out = { ...a };
1393
+ for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
1394
+ return out;
1395
+ }
1396
+ async function writeHandoff(d, o) {
1397
+ const harlayDir = join6(d.workdir, ".harlay");
1398
+ const latestPath = join6(harlayDir, "HANDOFF.md");
1399
+ const id = await allocateHandoffId(join6(harlayDir, "handoffs"), d.baseId);
1400
+ const dir = join6(harlayDir, "handoffs", id);
1401
+ await mkdir2(dir, { recursive: true });
1402
+ const r = new Redactor();
1403
+ const body = buildRecord(d, id, o.previousId, r);
1404
+ const chosen = choosePrompt(body, (p) => promptFits(o.target, o.mode, p));
1405
+ const record = { ...body, prompt: { target: o.target, tier: chosen.tier, chars: chosen.text.length } };
1406
+ const json = JSON.stringify(record, null, 2) + "\n";
1407
+ const recordPath = join6(dir, "handoff.json");
1408
+ await writeFile2(recordPath, json);
1409
+ await writeFile2(join6(dir, "prompt.md"), chosen.text + "\n");
1410
+ await writeFile2(join6(dir, "transcript.md"), r.apply(stripControl(d.transcript)));
1411
+ if (d.git) await writeFile2(join6(dir, "diff.patch"), r.apply(d.git.diff));
1412
+ if (d.lastFailure) await writeFile2(join6(dir, "last-error.txt"), r.apply(stripControl(d.lastFailure.output)));
1413
+ await writeFile2(latestPath, renderPrompt(body, "full") + "\n");
1414
+ await writeFile2(join6(harlayDir, "handoff.json"), json);
1415
+ if (d.git) await ensureGitExclude(d.workdir);
1416
+ const redactions = addCounts(d.redactions ?? {}, r.counts);
1417
+ return { id, dir, recordPath, latestPath, prompt: chosen.text, tier: chosen.tier, redactions };
1282
1418
  }
1283
1419
 
1284
1420
  // ../core/src/launch/resolve.ts
@@ -1404,10 +1540,9 @@ async function runHandoff(o) {
1404
1540
  }
1405
1541
  const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
1406
1542
  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;
1543
+ const previous = await readPreviousHandoff(git2?.root ?? cwd);
1409
1544
  const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
1410
- const written = await writeHandoff(data);
1545
+ const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom });
1411
1546
  if (o.launch === false) return { written, plan: null, data };
1412
1547
  let plan;
1413
1548
  try {
@@ -1416,7 +1551,7 @@ async function runHandoff(o) {
1416
1551
  target: o.target,
1417
1552
  mode: o.mode,
1418
1553
  cwd: data.workdir,
1419
- prompt: HANDOFF_PROMPT,
1554
+ prompt: written.prompt,
1420
1555
  scriptDir: written.dir,
1421
1556
  binaries,
1422
1557
  terminalApp: o.terminalApp ?? "Terminal"
@@ -1628,11 +1763,11 @@ function claudeSettingsPath(home) {
1628
1763
  }
1629
1764
  var previousJson = (dataDir) => join9(dataDir, "claude-statusline.previous.json");
1630
1765
  var previousCommand = (dataDir) => join9(dataDir, "claude-statusline.previous");
1631
- function isObj2(x) {
1766
+ function isObj3(x) {
1632
1767
  return typeof x === "object" && x !== null && !Array.isArray(x);
1633
1768
  }
1634
1769
  function isOurs(statusLine) {
1635
- return isObj2(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
1770
+ return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
1636
1771
  }
1637
1772
  async function readSettings(path) {
1638
1773
  let text;
@@ -1643,7 +1778,7 @@ async function readSettings(path) {
1643
1778
  throw e;
1644
1779
  }
1645
1780
  const parsed = JSON.parse(text);
1646
- if (!isObj2(parsed)) throw new Error(`${path} is not a JSON object`);
1781
+ if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
1647
1782
  return parsed;
1648
1783
  }
1649
1784
  async function writeAtomic(path, text) {
@@ -1784,7 +1919,7 @@ async function installApp(opts) {
1784
1919
  }
1785
1920
 
1786
1921
  // src/main.ts
1787
- var VERSION = "0.1.2";
1922
+ var VERSION = "0.1.3";
1788
1923
  var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
1789
1924
 
1790
1925
  Usage:
@@ -1870,6 +2005,10 @@ async function cmdHandoff(args, readers, ctx) {
1870
2005
  ctx.out(`Brief: ${res.written.latestPath}
1871
2006
  Archived: ${res.written.dir}
1872
2007
  `);
2008
+ ctx.out(
2009
+ res.written.tier === "pointer" ? "Prompt: pointer to the brief (too long to send inline)\n" : `Prompt: ${res.written.tier} brief inline (${res.written.prompt.length} chars)
2010
+ `
2011
+ );
1873
2012
  const kinds = Object.keys(res.written.redactions);
1874
2013
  if (kinds.length) {
1875
2014
  const total = Object.values(res.written.redactions).reduce((a, b) => a + b, 0);