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/app/dist/main.cjs +485 -353
- package/app/renderer/popover.css +1 -0
- package/app/renderer/popover.js +98 -2
- package/dist/harlay.mjs +453 -314
- package/package.json +1 -1
package/app/dist/main.cjs
CHANGED
|
@@ -29,20 +29,112 @@ var LaunchError = class extends HarlayError {
|
|
|
29
29
|
written;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
// ../../packages/core/src/launch/launchers.ts
|
|
33
|
+
var import_node_child_process = require("node:child_process");
|
|
34
|
+
var import_promises = require("node:fs/promises");
|
|
35
|
+
var import_node_path = require("node:path");
|
|
36
|
+
var import_node_util = require("node:util");
|
|
37
|
+
var pexec = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
38
|
+
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
39
|
+
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
40
|
+
var PROMPT_LIMIT = {
|
|
41
|
+
"claude-code": { app: 6e3, terminal: 1e5 },
|
|
42
|
+
codex: { app: 6e3, terminal: 1e5 },
|
|
43
|
+
cursor: { app: 9900, terminal: 1e5 }
|
|
44
|
+
};
|
|
45
|
+
function promptFits(target, mode, prompt) {
|
|
46
|
+
const length = mode === "app" ? encodeURIComponent(prompt).length : prompt.length;
|
|
47
|
+
return length <= PROMPT_LIMIT[target][mode];
|
|
48
|
+
}
|
|
49
|
+
var defaultRunner = {
|
|
50
|
+
async open(args) {
|
|
51
|
+
await pexec("/usr/bin/open", args);
|
|
52
|
+
},
|
|
53
|
+
async writeExecutable(path, content) {
|
|
54
|
+
await (0, import_promises.mkdir)((0, import_node_path.dirname)(path), { recursive: true });
|
|
55
|
+
await (0, import_promises.writeFile)(path, content, { mode: 493 });
|
|
56
|
+
await (0, import_promises.chmod)(path, 493);
|
|
57
|
+
},
|
|
58
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
59
|
+
};
|
|
60
|
+
function shq(s) {
|
|
61
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
62
|
+
}
|
|
63
|
+
var enc = encodeURIComponent;
|
|
64
|
+
function planLaunch(o) {
|
|
65
|
+
if (o.mode === "app") {
|
|
66
|
+
switch (o.target) {
|
|
67
|
+
case "claude-code":
|
|
68
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
69
|
+
case "codex":
|
|
70
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
71
|
+
case "cursor":
|
|
72
|
+
return {
|
|
73
|
+
target: o.target,
|
|
74
|
+
mode: o.mode,
|
|
75
|
+
steps: [
|
|
76
|
+
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
77
|
+
{ kind: "wait", ms: 1500 },
|
|
78
|
+
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
79
|
+
]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const bin = o.binaries[o.target];
|
|
84
|
+
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
85
|
+
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
86
|
+
const path = (0, import_node_path.join)(o.scriptDir, `continue-${o.target}.command`);
|
|
87
|
+
const content = `#!/bin/zsh -l
|
|
88
|
+
cd -- ${shq(o.cwd)} || exit 1
|
|
89
|
+
exec ${argv.map(shq).join(" ")}
|
|
90
|
+
`;
|
|
91
|
+
return {
|
|
92
|
+
target: o.target,
|
|
93
|
+
mode: o.mode,
|
|
94
|
+
steps: [
|
|
95
|
+
{ kind: "script", path, content },
|
|
96
|
+
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
97
|
+
]
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function executeLaunch(plan, runner = defaultRunner) {
|
|
101
|
+
for (const step of plan.steps) {
|
|
102
|
+
if (step.kind === "open") await runner.open(step.args);
|
|
103
|
+
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
104
|
+
else await runner.sleep(step.ms);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
32
108
|
// ../../packages/core/src/util/text.ts
|
|
33
109
|
var CHARS_PER_TOKEN = 4;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (s.length <= max) return s;
|
|
37
|
-
const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
|
|
38
|
-
return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
|
|
39
|
-
}
|
|
110
|
+
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
111
|
+
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
40
112
|
function tail(s, maxChars) {
|
|
41
|
-
|
|
113
|
+
if (s.length <= maxChars) return s;
|
|
114
|
+
let start = s.length - maxChars + 1;
|
|
115
|
+
if (start < s.length && isLow(s.charCodeAt(start))) start++;
|
|
116
|
+
return "\u2026" + s.slice(start);
|
|
117
|
+
}
|
|
118
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
119
|
+
function wellFormed(s) {
|
|
120
|
+
return s.replace(LONE_SURROGATE, "\uFFFD");
|
|
42
121
|
}
|
|
43
122
|
function oneLine(s, max = 120) {
|
|
44
123
|
const t = s.replace(/\s+/g, " ").trim();
|
|
45
|
-
|
|
124
|
+
if (t.length <= max) return t;
|
|
125
|
+
let end = max - 1;
|
|
126
|
+
if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
|
|
127
|
+
return t.slice(0, end) + "\u2026";
|
|
128
|
+
}
|
|
129
|
+
function wordCount(s) {
|
|
130
|
+
return s.split(/\s+/).filter(Boolean).length;
|
|
131
|
+
}
|
|
132
|
+
function clipWords(s, max) {
|
|
133
|
+
const w = s.split(/\s+/).filter(Boolean);
|
|
134
|
+
return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
|
|
135
|
+
}
|
|
136
|
+
function splitSentences(s) {
|
|
137
|
+
return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
|
|
46
138
|
}
|
|
47
139
|
var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
|
|
48
140
|
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/;
|
|
@@ -53,8 +145,27 @@ function cleanPrompt(raw) {
|
|
|
53
145
|
if (MACHINE_TAG_RE.test(s)) return null;
|
|
54
146
|
return s ? s : null;
|
|
55
147
|
}
|
|
56
|
-
|
|
57
|
-
|
|
148
|
+
var HANDOFF_TAG = '<handoff from="';
|
|
149
|
+
var HANDOFF_END = "</handoff>";
|
|
150
|
+
var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
|
|
151
|
+
var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
|
|
152
|
+
function splitHandoffPrompt(p) {
|
|
153
|
+
const start = p.indexOf(HANDOFF_TAG);
|
|
154
|
+
const end = p.lastIndexOf(HANDOFF_END);
|
|
155
|
+
if (start !== -1 && end > start) {
|
|
156
|
+
const before = p.slice(0, start).trim();
|
|
157
|
+
const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
|
|
158
|
+
const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
159
|
+
return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
|
|
160
|
+
}
|
|
161
|
+
if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
|
|
162
|
+
return { handoff: false, rest: p, id: null };
|
|
163
|
+
}
|
|
164
|
+
var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
165
|
+
var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
166
|
+
var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
167
|
+
function stripControl(s) {
|
|
168
|
+
return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
|
|
58
169
|
}
|
|
59
170
|
function slugify(s, max = 40) {
|
|
60
171
|
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
|
|
@@ -62,25 +173,25 @@ function slugify(s, max = 40) {
|
|
|
62
173
|
}
|
|
63
174
|
|
|
64
175
|
// ../../packages/core/src/sources/claude.ts
|
|
65
|
-
var
|
|
66
|
-
var
|
|
176
|
+
var import_promises3 = require("node:fs/promises");
|
|
177
|
+
var import_node_path3 = require("node:path");
|
|
67
178
|
|
|
68
179
|
// ../../packages/core/src/util/files.ts
|
|
69
|
-
var
|
|
70
|
-
var
|
|
180
|
+
var import_promises2 = require("node:fs/promises");
|
|
181
|
+
var import_node_path2 = require("node:path");
|
|
71
182
|
async function listSessionFiles(root, accept) {
|
|
72
183
|
let names;
|
|
73
184
|
try {
|
|
74
|
-
names = await (0,
|
|
185
|
+
names = await (0, import_promises2.readdir)(root, { recursive: true });
|
|
75
186
|
} catch {
|
|
76
187
|
return [];
|
|
77
188
|
}
|
|
78
189
|
const out = [];
|
|
79
190
|
for (const name of names) {
|
|
80
|
-
const path = (0,
|
|
191
|
+
const path = (0, import_node_path2.join)(root, name);
|
|
81
192
|
if (!accept(path)) continue;
|
|
82
193
|
try {
|
|
83
|
-
const s = await (0,
|
|
194
|
+
const s = await (0, import_promises2.stat)(path);
|
|
84
195
|
if (s.isFile()) out.push({ path, mtime: s.mtime });
|
|
85
196
|
} catch {
|
|
86
197
|
}
|
|
@@ -88,7 +199,7 @@ async function listSessionFiles(root, accept) {
|
|
|
88
199
|
return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
89
200
|
}
|
|
90
201
|
async function readFirstLine(file, maxBytes = 8 * 1024 * 1024) {
|
|
91
|
-
const fh = await (0,
|
|
202
|
+
const fh = await (0, import_promises2.open)(file, "r");
|
|
92
203
|
try {
|
|
93
204
|
const chunks = [];
|
|
94
205
|
const buf = Buffer.alloc(65536);
|
|
@@ -272,10 +383,10 @@ function normalizeClaude(records, ref, invalid = 0) {
|
|
|
272
383
|
}
|
|
273
384
|
async function loadDesktopTitles(dir) {
|
|
274
385
|
const out = /* @__PURE__ */ new Map();
|
|
275
|
-
const files = await listSessionFiles(dir, (f) => (0,
|
|
386
|
+
const files = await listSessionFiles(dir, (f) => (0, import_node_path3.basename)(f).startsWith("local_") && f.endsWith(".json"));
|
|
276
387
|
for (const f of files) {
|
|
277
388
|
try {
|
|
278
|
-
const d = JSON.parse(await (0,
|
|
389
|
+
const d = JSON.parse(await (0, import_promises3.readFile)(f.path, "utf8"));
|
|
279
390
|
if (isObj(d) && typeof d.cliSessionId === "string" && typeof d.title === "string" && d.title && !out.has(d.cliSessionId)) {
|
|
280
391
|
out.set(d.cliSessionId, d.title);
|
|
281
392
|
}
|
|
@@ -285,14 +396,14 @@ async function loadDesktopTitles(dir) {
|
|
|
285
396
|
return out;
|
|
286
397
|
}
|
|
287
398
|
function createClaudeReader(o) {
|
|
288
|
-
const root = (0,
|
|
289
|
-
const indexDir = (0,
|
|
290
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
399
|
+
const root = (0, import_node_path3.join)(o.home, ".claude", "projects");
|
|
400
|
+
const indexDir = (0, import_node_path3.join)(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
401
|
+
const isSessionFile = (file) => file.startsWith(root + import_node_path3.sep) && file.endsWith(".jsonl") && (0, import_node_path3.relative)(root, file).split(import_node_path3.sep).length === 2;
|
|
291
402
|
const cache = /* @__PURE__ */ new Map();
|
|
292
403
|
async function refForFile(file, titles) {
|
|
293
404
|
let st;
|
|
294
405
|
try {
|
|
295
|
-
st = await (0,
|
|
406
|
+
st = await (0, import_promises3.stat)(file);
|
|
296
407
|
} catch {
|
|
297
408
|
return null;
|
|
298
409
|
}
|
|
@@ -300,7 +411,7 @@ function createClaudeReader(o) {
|
|
|
300
411
|
if (!entry || entry.size !== st.size || entry.mtimeMs !== st.mtimeMs) {
|
|
301
412
|
let text;
|
|
302
413
|
try {
|
|
303
|
-
text = await (0,
|
|
414
|
+
text = await (0, import_promises3.readFile)(file, "utf8");
|
|
304
415
|
} catch {
|
|
305
416
|
return null;
|
|
306
417
|
}
|
|
@@ -325,7 +436,7 @@ function createClaudeReader(o) {
|
|
|
325
436
|
entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, fileTitle: custom ?? ai, ref: null };
|
|
326
437
|
cache.set(file, entry);
|
|
327
438
|
}
|
|
328
|
-
const id = (0,
|
|
439
|
+
const id = (0, import_node_path3.basename)(file, ".jsonl");
|
|
329
440
|
const title = entry.fileTitle ?? (titles ?? await loadDesktopTitles(indexDir)).get(id) ?? null;
|
|
330
441
|
if (!entry.ref || entry.ref.title !== title) entry.ref = { tool: "claude-code", id, file, cwd: entry.cwd, title, lastActivityAt: st.mtime };
|
|
331
442
|
return entry.ref;
|
|
@@ -334,7 +445,7 @@ function createClaudeReader(o) {
|
|
|
334
445
|
tool: "claude-code",
|
|
335
446
|
root,
|
|
336
447
|
isSessionFile,
|
|
337
|
-
idFromPath: (file) => isSessionFile(file) ? (0,
|
|
448
|
+
idFromPath: (file) => isSessionFile(file) ? (0, import_node_path3.basename)(file, ".jsonl") : null,
|
|
338
449
|
refForFile: (file) => refForFile(file),
|
|
339
450
|
async listSessions(limit = 20) {
|
|
340
451
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -348,7 +459,7 @@ function createClaudeReader(o) {
|
|
|
348
459
|
return refs;
|
|
349
460
|
},
|
|
350
461
|
async readSession(ref) {
|
|
351
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
462
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises3.readFile)(ref.file, "utf8"));
|
|
352
463
|
return normalizeClaude(records, ref, invalid);
|
|
353
464
|
},
|
|
354
465
|
detect(record) {
|
|
@@ -361,8 +472,8 @@ function createClaudeReader(o) {
|
|
|
361
472
|
}
|
|
362
473
|
|
|
363
474
|
// ../../packages/core/src/sources/codex.ts
|
|
364
|
-
var
|
|
365
|
-
var
|
|
475
|
+
var import_promises4 = require("node:fs/promises");
|
|
476
|
+
var import_node_path4 = require("node:path");
|
|
366
477
|
var OUTPUT_TAIL2 = 2e4;
|
|
367
478
|
var ROLLOUT_ID_RE = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
368
479
|
var KIND = { add: "create", update: "modify", delete: "delete" };
|
|
@@ -468,7 +579,7 @@ async function loadTitles(file) {
|
|
|
468
579
|
const out = /* @__PURE__ */ new Map();
|
|
469
580
|
let text;
|
|
470
581
|
try {
|
|
471
|
-
text = await (0,
|
|
582
|
+
text = await (0, import_promises4.readFile)(file, "utf8");
|
|
472
583
|
} catch {
|
|
473
584
|
return out;
|
|
474
585
|
}
|
|
@@ -478,14 +589,14 @@ async function loadTitles(file) {
|
|
|
478
589
|
return out;
|
|
479
590
|
}
|
|
480
591
|
function createCodexReader(o) {
|
|
481
|
-
const root = (0,
|
|
482
|
-
const indexFile = (0,
|
|
483
|
-
const isSessionFile = (file) => file.startsWith(root +
|
|
592
|
+
const root = (0, import_node_path4.join)(o.home, ".codex", "sessions");
|
|
593
|
+
const indexFile = (0, import_node_path4.join)(o.home, ".codex", "session_index.jsonl");
|
|
594
|
+
const isSessionFile = (file) => file.startsWith(root + import_node_path4.sep) && (0, import_node_path4.basename)(file).startsWith("rollout-") && file.endsWith(".jsonl");
|
|
484
595
|
const cache = /* @__PURE__ */ new Map();
|
|
485
596
|
async function refForFile(file, titles) {
|
|
486
597
|
let st;
|
|
487
598
|
try {
|
|
488
|
-
st = await (0,
|
|
599
|
+
st = await (0, import_promises4.stat)(file);
|
|
489
600
|
} catch {
|
|
490
601
|
return null;
|
|
491
602
|
}
|
|
@@ -510,7 +621,7 @@ function createCodexReader(o) {
|
|
|
510
621
|
tool: "codex",
|
|
511
622
|
root,
|
|
512
623
|
isSessionFile,
|
|
513
|
-
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0,
|
|
624
|
+
idFromPath: (file) => isSessionFile(file) ? ROLLOUT_ID_RE.exec((0, import_node_path4.basename)(file))?.[1] ?? null : null,
|
|
514
625
|
refForFile: (file) => refForFile(file),
|
|
515
626
|
async listSessions(limit = 20) {
|
|
516
627
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -524,7 +635,7 @@ function createCodexReader(o) {
|
|
|
524
635
|
return refs;
|
|
525
636
|
},
|
|
526
637
|
async readSession(ref) {
|
|
527
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
638
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises4.readFile)(ref.file, "utf8"));
|
|
528
639
|
return normalizeCodex(records, ref, invalid);
|
|
529
640
|
},
|
|
530
641
|
detect(record) {
|
|
@@ -549,21 +660,21 @@ function createCodexReader(o) {
|
|
|
549
660
|
}
|
|
550
661
|
|
|
551
662
|
// ../../packages/core/src/sources/cursor.ts
|
|
552
|
-
var
|
|
553
|
-
var
|
|
663
|
+
var import_promises5 = require("node:fs/promises");
|
|
664
|
+
var import_node_path5 = require("node:path");
|
|
554
665
|
var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
|
|
555
666
|
async function resolveCursorSlug(slug, rootDir = "/") {
|
|
556
667
|
if (!slug || /^\d+$/.test(slug)) return null;
|
|
557
668
|
async function walk(dir, rest, depth) {
|
|
558
669
|
if (depth > 16) return null;
|
|
559
|
-
const entries = await (0,
|
|
670
|
+
const entries = await (0, import_promises5.readdir)(dir, { withFileTypes: true }).catch(() => null);
|
|
560
671
|
if (!entries) return null;
|
|
561
672
|
for (const e of entries) {
|
|
562
673
|
if (!e.isDirectory()) continue;
|
|
563
674
|
const n = norm(e.name);
|
|
564
|
-
if (rest === n) return (0,
|
|
675
|
+
if (rest === n) return (0, import_node_path5.join)(dir, e.name);
|
|
565
676
|
if (rest.startsWith(n + "-")) {
|
|
566
|
-
const found = await walk((0,
|
|
677
|
+
const found = await walk((0, import_node_path5.join)(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
567
678
|
if (found) return found;
|
|
568
679
|
}
|
|
569
680
|
}
|
|
@@ -623,7 +734,7 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
623
734
|
return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
624
735
|
}
|
|
625
736
|
function createCursorReader(o) {
|
|
626
|
-
const root = (0,
|
|
737
|
+
const root = (0, import_node_path5.join)(o.home, ".cursor", "projects");
|
|
627
738
|
const slugCache = /* @__PURE__ */ new Map();
|
|
628
739
|
const cwdFor = (slug) => {
|
|
629
740
|
let p = slugCache.get(slug);
|
|
@@ -634,17 +745,17 @@ function createCursorReader(o) {
|
|
|
634
745
|
return p;
|
|
635
746
|
};
|
|
636
747
|
const isSessionFile = (file) => {
|
|
637
|
-
if (!file.startsWith(root +
|
|
638
|
-
const parts = (0,
|
|
748
|
+
if (!file.startsWith(root + import_node_path5.sep) || !file.endsWith(".jsonl")) return false;
|
|
749
|
+
const parts = (0, import_node_path5.relative)(root, file).split(import_node_path5.sep);
|
|
639
750
|
return parts.length === 4 && parts[1] === "agent-transcripts" && parts[3] === `${parts[2]}.jsonl`;
|
|
640
751
|
};
|
|
641
752
|
const cache = /* @__PURE__ */ new Map();
|
|
642
753
|
async function refForFile(file) {
|
|
643
754
|
if (!isSessionFile(file)) return null;
|
|
644
|
-
const parts = (0,
|
|
755
|
+
const parts = (0, import_node_path5.relative)(root, file).split(import_node_path5.sep);
|
|
645
756
|
let st;
|
|
646
757
|
try {
|
|
647
|
-
st = await (0,
|
|
758
|
+
st = await (0, import_promises5.stat)(file);
|
|
648
759
|
} catch {
|
|
649
760
|
return null;
|
|
650
761
|
}
|
|
@@ -652,7 +763,7 @@ function createCursorReader(o) {
|
|
|
652
763
|
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.ref;
|
|
653
764
|
let text;
|
|
654
765
|
try {
|
|
655
|
-
text = await (0,
|
|
766
|
+
text = await (0, import_promises5.readFile)(file, "utf8");
|
|
656
767
|
} catch {
|
|
657
768
|
return null;
|
|
658
769
|
}
|
|
@@ -660,8 +771,9 @@ function createCursorReader(o) {
|
|
|
660
771
|
for (const r of parseJsonLines(text).records) {
|
|
661
772
|
if (isObj(r) && r.role === "user" && isObj(r.message)) {
|
|
662
773
|
const p = cleanPrompt(blockText(r.message.content));
|
|
663
|
-
|
|
664
|
-
|
|
774
|
+
const rest = p ? splitHandoffPrompt(p).rest : "";
|
|
775
|
+
if (rest) {
|
|
776
|
+
title = oneLine(rest, 60);
|
|
665
777
|
break;
|
|
666
778
|
}
|
|
667
779
|
}
|
|
@@ -674,7 +786,7 @@ function createCursorReader(o) {
|
|
|
674
786
|
tool: "cursor",
|
|
675
787
|
root,
|
|
676
788
|
isSessionFile,
|
|
677
|
-
idFromPath: (file) => isSessionFile(file) ? (0,
|
|
789
|
+
idFromPath: (file) => isSessionFile(file) ? (0, import_node_path5.relative)(root, file).split(import_node_path5.sep)[2] : null,
|
|
678
790
|
refForFile,
|
|
679
791
|
async listSessions(limit = 20) {
|
|
680
792
|
const files = await listSessionFiles(root, isSessionFile);
|
|
@@ -687,7 +799,7 @@ function createCursorReader(o) {
|
|
|
687
799
|
return refs;
|
|
688
800
|
},
|
|
689
801
|
async readSession(ref) {
|
|
690
|
-
const { records, invalid } = parseJsonLines(await (0,
|
|
802
|
+
const { records, invalid } = parseJsonLines(await (0, import_promises5.readFile)(ref.file, "utf8"));
|
|
691
803
|
return normalizeCursor(records, ref, invalid);
|
|
692
804
|
},
|
|
693
805
|
detect(record) {
|
|
@@ -700,14 +812,14 @@ function createCursorReader(o) {
|
|
|
700
812
|
}
|
|
701
813
|
|
|
702
814
|
// ../../packages/core/src/git/snapshot.ts
|
|
703
|
-
var
|
|
815
|
+
var import_node_child_process2 = require("node:child_process");
|
|
704
816
|
var import_node_crypto = require("node:crypto");
|
|
705
|
-
var
|
|
706
|
-
var
|
|
817
|
+
var import_node_util2 = require("node:util");
|
|
818
|
+
var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
707
819
|
var MAX_DIFF_CHARS = 2e6;
|
|
708
820
|
var MAX_FILES = 500;
|
|
709
821
|
async function git(cwd, args) {
|
|
710
|
-
const { stdout } = await
|
|
822
|
+
const { stdout } = await pexec2("git", args, {
|
|
711
823
|
cwd,
|
|
712
824
|
maxBuffer: 64 * 1024 * 1024,
|
|
713
825
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
|
|
@@ -861,12 +973,76 @@ var Redactor = class {
|
|
|
861
973
|
};
|
|
862
974
|
|
|
863
975
|
// ../../packages/core/src/extract/extract.ts
|
|
864
|
-
var
|
|
865
|
-
|
|
976
|
+
var import_node_path6 = require("node:path");
|
|
977
|
+
|
|
978
|
+
// ../../packages/core/src/extract/recent.ts
|
|
979
|
+
var RECENT = { budgetTokens: 450, min: 2, max: 6, words: 120 };
|
|
980
|
+
function pickRecentMessages(prompts) {
|
|
981
|
+
const out = [];
|
|
982
|
+
let used = 0;
|
|
983
|
+
for (let i = prompts.length - 1; i >= 0 && out.length < RECENT.max; i--) {
|
|
984
|
+
const m = clipWords(prompts[i], RECENT.words);
|
|
985
|
+
if (!m) continue;
|
|
986
|
+
const cost = Math.ceil(m.length / CHARS_PER_TOKEN);
|
|
987
|
+
if (out.length >= RECENT.min && used + cost > RECENT.budgetTokens) break;
|
|
988
|
+
out.unshift(m);
|
|
989
|
+
used += cost;
|
|
990
|
+
}
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ../../packages/core/src/extract/rules.ts
|
|
995
|
+
var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
|
|
996
|
+
var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
|
|
997
|
+
var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
998
|
+
function findPinnedRules(prompts, recent) {
|
|
999
|
+
const recentText = norm2(recent.join("\n"));
|
|
1000
|
+
const found = [];
|
|
1001
|
+
prompts.forEach((p, message) => {
|
|
1002
|
+
if (wordCount(p) > RULES2.pastedWords) return;
|
|
1003
|
+
for (const sentence of splitSentences(p)) {
|
|
1004
|
+
if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
|
|
1005
|
+
if (recentText.includes(norm2(sentence))) continue;
|
|
1006
|
+
found.push({ text: clipWords(sentence, RULES2.words), message });
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
return capRules(latestCopies(found));
|
|
1010
|
+
}
|
|
1011
|
+
function mergePinnedRules(older, newer, recent) {
|
|
1012
|
+
const recentText = norm2(recent.join("\n"));
|
|
1013
|
+
const all = [...older.map((r) => ({ text: r.text, message: -1 })), ...newer].filter((r) => !recentText.includes(norm2(r.text)));
|
|
1014
|
+
return capRules(latestCopies(all));
|
|
1015
|
+
}
|
|
1016
|
+
function latestCopies(rules) {
|
|
1017
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1018
|
+
for (const r of rules) {
|
|
1019
|
+
const key = norm2(r.text);
|
|
1020
|
+
byKey.delete(key);
|
|
1021
|
+
byKey.set(key, r);
|
|
1022
|
+
}
|
|
1023
|
+
return [...byKey.values()];
|
|
1024
|
+
}
|
|
1025
|
+
function capRules(all) {
|
|
1026
|
+
const kept = [];
|
|
1027
|
+
let used = 0;
|
|
1028
|
+
for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
|
|
1029
|
+
const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
|
|
1030
|
+
if (used + cost > RULES2.budgetTokens) break;
|
|
1031
|
+
kept.unshift(all[i]);
|
|
1032
|
+
used += cost;
|
|
1033
|
+
}
|
|
1034
|
+
return { rules: kept, more: all.length - kept.length };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ../../packages/core/src/extract/extract.ts
|
|
866
1038
|
var TRANSCRIPT_MAX = 2e5;
|
|
1039
|
+
var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
|
|
1040
|
+
function isSlashCommand(p) {
|
|
1041
|
+
return SLASH_COMMAND_RE.test(p.trim());
|
|
1042
|
+
}
|
|
867
1043
|
function isSubstantive(p) {
|
|
868
1044
|
const t = p.trim();
|
|
869
|
-
return !t
|
|
1045
|
+
return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
|
|
870
1046
|
}
|
|
871
1047
|
var PROBES = /* @__PURE__ */ new Set([
|
|
872
1048
|
"rg",
|
|
@@ -901,95 +1077,96 @@ function isProbeCommand(cmd) {
|
|
|
901
1077
|
let s = cmd;
|
|
902
1078
|
for (let m = LEADING_CD_RE.exec(s); m; m = LEADING_CD_RE.exec(s)) s = s.slice(m[0].length);
|
|
903
1079
|
const words = s.trim().split(/\s+/);
|
|
904
|
-
const first = (0,
|
|
1080
|
+
const first = (0, import_node_path6.basename)(words[0] ?? "");
|
|
905
1081
|
if (first !== "git") return PROBES.has(first);
|
|
906
1082
|
let i = 1;
|
|
907
1083
|
while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
|
|
908
1084
|
return GIT_PROBES.has(words[i] ?? "");
|
|
909
1085
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1086
|
+
var FAILURE_WINDOW = 5;
|
|
1087
|
+
function findLastFailure(cmds) {
|
|
1088
|
+
const real = cmds.filter((c) => !isProbeCommand(c.cmd));
|
|
1089
|
+
for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
|
|
1090
|
+
const c = real[i];
|
|
1091
|
+
if (c.exitCode === null || c.exitCode === 0) continue;
|
|
1092
|
+
if (real.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) continue;
|
|
1093
|
+
return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
|
|
915
1094
|
}
|
|
916
1095
|
return null;
|
|
917
1096
|
}
|
|
918
|
-
function
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
if (c.exitCode === null || c.exitCode === 0 || seen.has(c.cmd) || isProbeCommand(c.cmd)) return;
|
|
923
|
-
if (blocker && c.cmd === blocker.cmd) return;
|
|
924
|
-
if (cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return;
|
|
925
|
-
seen.add(c.cmd);
|
|
926
|
-
out.push({ cmd: c.cmd, exitCode: c.exitCode });
|
|
927
|
-
});
|
|
928
|
-
return out.slice(-5);
|
|
1097
|
+
function dropPartialLine(output) {
|
|
1098
|
+
if (!output.startsWith("\u2026")) return output;
|
|
1099
|
+
const nl = output.indexOf("\n");
|
|
1100
|
+
return nl === -1 ? "" : output.slice(nl + 1);
|
|
929
1101
|
}
|
|
930
|
-
function
|
|
931
|
-
const
|
|
932
|
-
for (const p of prompts) {
|
|
933
|
-
for (const s of p.split(/(?<=[.!?])\s+|\n+/)) {
|
|
934
|
-
const t = s.trim();
|
|
935
|
-
if (t.length >= 8 && t.length <= 300 && DECISION_RE.test(t) && !out.includes(t)) out.push(t);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
return out.slice(-8);
|
|
939
|
-
}
|
|
940
|
-
function buildTranscript(s, title, rel) {
|
|
1102
|
+
function buildTranscript(s, title, rel, redactor) {
|
|
1103
|
+
const r = (x) => redactor.apply(x);
|
|
941
1104
|
const lines = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
|
|
942
1105
|
for (const t of s.timeline) {
|
|
943
|
-
if (t.kind === "user")
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1106
|
+
if (t.kind === "user") {
|
|
1107
|
+
const { handoff: handoff2, rest } = splitHandoffPrompt(t.text);
|
|
1108
|
+
if (handoff2) lines.push("**Harlay handoff prompt** (from the previous agent; context is in the brief)", "");
|
|
1109
|
+
if (rest) lines.push("**User:**", r(rest), "");
|
|
1110
|
+
} else if (t.kind === "assistant") {
|
|
1111
|
+
const text = r(t.text);
|
|
1112
|
+
lines.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
|
|
1113
|
+
} else if (t.kind === "command") lines.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
|
|
1114
|
+
else lines.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
|
|
947
1115
|
}
|
|
948
1116
|
const out = lines.join("\n");
|
|
949
|
-
|
|
1117
|
+
if (out.length <= TRANSCRIPT_MAX) return out;
|
|
1118
|
+
const cut = out.slice(out.length - TRANSCRIPT_MAX);
|
|
1119
|
+
const nl = cut.indexOf("\n");
|
|
1120
|
+
return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
|
|
950
1121
|
}
|
|
951
1122
|
function extractHandoff(s, git2, o) {
|
|
952
1123
|
const cwd = s.ref.cwd;
|
|
953
1124
|
if (!cwd) throw new HarlayError("This session has no known working directory, so Harlay cannot hand it off.");
|
|
954
1125
|
const workdir = git2?.root ?? cwd;
|
|
955
1126
|
const rel = (p) => {
|
|
956
|
-
if (!(0,
|
|
957
|
-
const r = (0,
|
|
958
|
-
return r.startsWith("..") || (0,
|
|
1127
|
+
if (!(0, import_node_path6.isAbsolute)(p)) return p;
|
|
1128
|
+
const r = (0, import_node_path6.relative)(workdir, p);
|
|
1129
|
+
return r.startsWith("..") || (0, import_node_path6.isAbsolute)(r) ? p : r;
|
|
959
1130
|
};
|
|
960
|
-
const
|
|
961
|
-
const prompts =
|
|
1131
|
+
const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
|
|
1132
|
+
const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
|
|
962
1133
|
const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
|
|
963
1134
|
const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
|
|
964
1135
|
const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
const
|
|
968
|
-
const
|
|
1136
|
+
const redactor = new Redactor();
|
|
1137
|
+
const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
|
|
1138
|
+
const prev = o.previous;
|
|
1139
|
+
const fromPrev = prev ? split.some((p) => p.handoff && (p.id === null || p.id === prev.id)) : false;
|
|
1140
|
+
const carried = prev && prev.goal !== null && fromPrev ? prev : null;
|
|
1141
|
+
const carriedRecent = carried ? carried.recentUserMessages.map((m) => redactor.apply(m)) : [];
|
|
1142
|
+
const recentUserMessages = pickRecentMessages([...carriedRecent, ...realPrompts]);
|
|
1143
|
+
let { rules: pinnedRules, more: moreRules } = findPinnedRules(realPrompts, recentUserMessages);
|
|
1144
|
+
if (carried) {
|
|
1145
|
+
const older = [...carried.pinnedRules.map((r) => ({ ...r, text: redactor.apply(r.text) })), ...findPinnedRules(carriedRecent, []).rules];
|
|
1146
|
+
const merged = mergePinnedRules(older, pinnedRules, recentUserMessages);
|
|
1147
|
+
moreRules += merged.more + carried.moreRules;
|
|
1148
|
+
pinnedRules = merged.rules;
|
|
1149
|
+
}
|
|
1150
|
+
const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
|
|
969
1151
|
const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
|
|
970
|
-
const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
|
|
971
1152
|
const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
|
|
972
|
-
const
|
|
973
|
-
const
|
|
974
|
-
const
|
|
975
|
-
const
|
|
976
|
-
|
|
1153
|
+
const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
|
|
1154
|
+
const openTodos = todos.flatMap((t) => t.status === "in_progress" || t.status === "pending" ? [{ text: redactor.apply(t.text).replace(/\s+/g, " ").trim(), status: t.status }] : []).slice(0, 8);
|
|
1155
|
+
const failure = findLastFailure(cmds);
|
|
1156
|
+
const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
|
|
1157
|
+
const filesTouched = [];
|
|
1158
|
+
for (const e of [...edits].reverse()) {
|
|
977
1159
|
const r = rel(e.path);
|
|
978
|
-
if (!
|
|
979
|
-
|
|
1160
|
+
if (!(0, import_node_path6.isAbsolute)(r) && !filesTouched.includes(r)) filesTouched.push(r);
|
|
1161
|
+
if (filesTouched.length === 12) break;
|
|
980
1162
|
}
|
|
981
|
-
const
|
|
982
|
-
const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
|
|
983
|
-
const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
|
|
984
|
-
const redactor = new Redactor();
|
|
985
|
-
const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
|
|
1163
|
+
const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
|
|
986
1164
|
const pad = (n2) => String(n2).padStart(2, "0");
|
|
987
1165
|
const n = o.now;
|
|
988
1166
|
const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
|
|
989
1167
|
return {
|
|
990
1168
|
baseId,
|
|
991
1169
|
title,
|
|
992
|
-
status: "in_progress",
|
|
993
1170
|
createdAt: o.now,
|
|
994
1171
|
from: {
|
|
995
1172
|
tool: s.ref.tool,
|
|
@@ -1001,142 +1178,155 @@ function extractHandoff(s, git2, o) {
|
|
|
1001
1178
|
},
|
|
1002
1179
|
workdir,
|
|
1003
1180
|
git: git2,
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1181
|
+
originalGoal,
|
|
1182
|
+
recentUserMessages,
|
|
1183
|
+
pinnedRules,
|
|
1184
|
+
moreRules,
|
|
1185
|
+
whereItStopped,
|
|
1186
|
+
todos: openTodos,
|
|
1187
|
+
filesTouched,
|
|
1188
|
+
lastFailure,
|
|
1189
|
+
transcript: buildTranscript(s, title, rel, redactor),
|
|
1190
|
+
unknownRecords: s.unknownRecords,
|
|
1191
|
+
redactions: { ...redactor.counts },
|
|
1192
|
+
carriedFrom: carried ? carried.id : null
|
|
1016
1193
|
};
|
|
1017
1194
|
}
|
|
1018
1195
|
|
|
1019
|
-
// ../../packages/core/src/brief/
|
|
1020
|
-
var
|
|
1196
|
+
// ../../packages/core/src/brief/write.ts
|
|
1197
|
+
var import_promises6 = require("node:fs/promises");
|
|
1198
|
+
var import_node_path7 = require("node:path");
|
|
1199
|
+
|
|
1200
|
+
// ../../packages/core/src/brief/prompt.ts
|
|
1201
|
+
var TIER_MAX = { full: 6e3, compact: 3e3 };
|
|
1021
1202
|
var REASON_TEXT = {
|
|
1022
1203
|
usage_limit: "it hit a usage limit",
|
|
1023
1204
|
crash: "it stopped with an error",
|
|
1024
1205
|
manual: "the user handed it off"
|
|
1025
1206
|
};
|
|
1026
|
-
var
|
|
1027
|
-
function
|
|
1028
|
-
return s.
|
|
1207
|
+
var OUR_TAGS = /<(\/?)(handoff|original_goal|rules_from_user|recent_user_messages|where_it_stopped|files|last_failure)\b/gi;
|
|
1208
|
+
function safe(s) {
|
|
1209
|
+
return s.replace(OUR_TAGS, "\u2039$1$2");
|
|
1210
|
+
}
|
|
1211
|
+
var attr = (s) => safe(s).replace(/"/g, "'").replace(/\s+/g, " ").trim();
|
|
1212
|
+
var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1213
|
+
function goalIsRecent(goal, recent) {
|
|
1214
|
+
const g = norm3(goal.replace(/\s…$/, ""));
|
|
1215
|
+
return recent.some((m) => norm3(m).startsWith(g));
|
|
1216
|
+
}
|
|
1217
|
+
function renderPrompt(rec, tier) {
|
|
1218
|
+
const full = tier === "full";
|
|
1219
|
+
const from = TOOL_LABEL[rec.from.tool];
|
|
1220
|
+
const dir = `.harlay/handoffs/${rec.id}`;
|
|
1221
|
+
const out = [
|
|
1222
|
+
`<handoff from="${from}" reason="${rec.from.reason}" task="${attr(rec.task.title)}">`,
|
|
1223
|
+
`You are taking over a coding session that ${from} could not finish (${REASON_TEXT[rec.from.reason]}). What you need to start is below; don't re-read the repo to rebuild it.`
|
|
1224
|
+
];
|
|
1225
|
+
const goal = rec.task.originalGoal;
|
|
1226
|
+
const noRecent = rec.task.recentUserMessages.length === 0;
|
|
1227
|
+
if (goal && (full ? !goalIsRecent(goal, rec.task.recentUserMessages) : noRecent)) out.push("", "<original_goal>", safe(goal), "</original_goal>");
|
|
1228
|
+
if (rec.task.pinnedRules.length) {
|
|
1229
|
+
out.push("", "<rules_from_user>", ...rec.task.pinnedRules.map((r) => `- ${safe(r.text)}`));
|
|
1230
|
+
if (rec.task.moreRules) out.push(`(${rec.task.moreRules} more in the transcript)`);
|
|
1231
|
+
out.push("</rules_from_user>");
|
|
1232
|
+
}
|
|
1233
|
+
const recent = full ? rec.task.recentUserMessages : rec.task.recentUserMessages.slice(-2);
|
|
1234
|
+
if (recent.length) out.push("", '<recent_user_messages oldest_first="true">', ...recent.map((m) => `- ${safe(m)}`), "</recent_user_messages>");
|
|
1235
|
+
const where = rec.state.whereItStopped === null ? null : full ? rec.state.whereItStopped : clipWords(rec.state.whereItStopped, 60);
|
|
1236
|
+
const todos = rec.state.todos.map((t) => `[${t.status === "in_progress" ? "in progress" : "pending"}] ${safe(t.text)}`);
|
|
1237
|
+
if (where || todos.length) {
|
|
1238
|
+
out.push("", "<where_it_stopped>");
|
|
1239
|
+
if (where) out.push(safe(where));
|
|
1240
|
+
if (todos.length) out.push(`Open todos: ${todos.join("; ")}`);
|
|
1241
|
+
out.push("</where_it_stopped>");
|
|
1242
|
+
}
|
|
1243
|
+
if (full && (rec.files.touched.length || rec.files.uncommitted.length)) {
|
|
1244
|
+
out.push("", "<files>");
|
|
1245
|
+
if (rec.files.touched.length) out.push(`Touched: ${rec.files.touched.map(safe).join(", ")}`);
|
|
1246
|
+
if (rec.files.uncommitted.length) {
|
|
1247
|
+
const list = rec.files.uncommitted.map((f) => `${safe(f.path)} (${f.status}${f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : ""})`);
|
|
1248
|
+
out.push(`Uncommitted: ${list.join(", ")}${rec.files.moreUncommitted ? `, and ${rec.files.moreUncommitted} more` : ""}`);
|
|
1249
|
+
}
|
|
1250
|
+
out.push("</files>");
|
|
1251
|
+
}
|
|
1252
|
+
if (rec.lastFailure) {
|
|
1253
|
+
out.push("", "<last_failure>", `$ ${safe(rec.lastFailure.cmd)} (exit ${rec.lastFailure.exitCode})`);
|
|
1254
|
+
if (full) out.push(safe(rec.lastFailure.outputTail));
|
|
1255
|
+
out.push("</last_failure>");
|
|
1256
|
+
}
|
|
1257
|
+
const more = [`${dir}/transcript.md (search it, don't read it all)`];
|
|
1258
|
+
if (rec.details.diff) more.push(`${dir}/diff.patch`);
|
|
1259
|
+
const next = noRecent ? "Continue from where it stopped." : "Continue from the most recent user message.";
|
|
1260
|
+
out.push("", `Only if you need more: ${more.join(" and ")}.`, "</handoff>", "", `${next} Start with the next concrete step.`);
|
|
1261
|
+
return wellFormed(out.join("\n"));
|
|
1262
|
+
}
|
|
1263
|
+
function choosePrompt(rec, fits) {
|
|
1264
|
+
for (const tier of ["full", "compact"]) {
|
|
1265
|
+
const text = renderPrompt(rec, tier);
|
|
1266
|
+
if (text.length <= TIER_MAX[tier] && fits(text)) return { tier, text };
|
|
1267
|
+
}
|
|
1268
|
+
return { tier: "pointer", text: HANDOFF_PROMPT };
|
|
1029
1269
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1270
|
+
|
|
1271
|
+
// ../../packages/core/src/brief/record.ts
|
|
1272
|
+
var RECORD_SCHEMA = "harlay/handoff@2";
|
|
1273
|
+
var MAX_UNCOMMITTED = 15;
|
|
1274
|
+
function buildRecord(d, id, previousId, r) {
|
|
1275
|
+
const a = (s) => r.apply(stripControl(s));
|
|
1276
|
+
const files = d.git?.files ?? [];
|
|
1277
|
+
return {
|
|
1278
|
+
schema: RECORD_SCHEMA,
|
|
1279
|
+
id,
|
|
1280
|
+
createdAt: d.createdAt.toISOString(),
|
|
1281
|
+
from: {
|
|
1282
|
+
tool: d.from.tool,
|
|
1283
|
+
model: d.from.model,
|
|
1284
|
+
session: d.from.session,
|
|
1285
|
+
reason: d.from.reason,
|
|
1286
|
+
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
|
|
1287
|
+
message: d.from.message === null ? null : a(d.from.message)
|
|
1288
|
+
},
|
|
1289
|
+
repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
|
|
1290
|
+
task: {
|
|
1291
|
+
title: a(d.title),
|
|
1292
|
+
originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
|
|
1293
|
+
recentUserMessages: d.recentUserMessages.map(a),
|
|
1294
|
+
pinnedRules: d.pinnedRules.map((p) => ({ text: a(p.text), message: p.message })),
|
|
1295
|
+
moreRules: d.moreRules
|
|
1296
|
+
},
|
|
1297
|
+
state: {
|
|
1298
|
+
whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
|
|
1299
|
+
todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
|
|
1300
|
+
},
|
|
1301
|
+
files: {
|
|
1302
|
+
touched: d.filesTouched.map(a),
|
|
1303
|
+
uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
|
|
1304
|
+
moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
|
|
1305
|
+
},
|
|
1306
|
+
lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, outputTail: a(d.lastFailure.output).split("\n").slice(-8).join("\n") } : null,
|
|
1307
|
+
details: { transcript: "transcript.md", diff: d.git ? "diff.patch" : null, lastError: d.lastFailure ? "last-error.txt" : null },
|
|
1308
|
+
previous: previousId ? { id: previousId } : null,
|
|
1309
|
+
unknownRecords: d.unknownRecords
|
|
1310
|
+
};
|
|
1044
1311
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
if (
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
1056
|
-
}
|
|
1057
|
-
function fileLine(f) {
|
|
1058
|
-
const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
|
|
1059
|
-
return `- \`${f.path}\` (${f.status}${counts})`;
|
|
1060
|
-
}
|
|
1061
|
-
function renderBrief(d, id, opts = {}) {
|
|
1062
|
-
const dir = `.harlay/handoffs/${id}`;
|
|
1063
|
-
const transcript = `${dir}/transcript.md`;
|
|
1064
|
-
const front = [
|
|
1065
|
-
"---",
|
|
1066
|
-
"harlay: 1",
|
|
1067
|
-
`id: ${j(id)}`,
|
|
1068
|
-
`status: ${j(d.status)}`,
|
|
1069
|
-
`created: ${j(d.createdAt.toISOString())}`,
|
|
1070
|
-
"from:",
|
|
1071
|
-
` tool: ${j(d.from.tool)}`,
|
|
1072
|
-
` model: ${j(d.from.model)}`,
|
|
1073
|
-
` session: ${j(d.from.session)}`,
|
|
1074
|
-
` reason: ${j(d.from.reason)}`,
|
|
1075
|
-
` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
|
|
1076
|
-
"repo:",
|
|
1077
|
-
` path: ${j(d.workdir)}`,
|
|
1078
|
-
...d.git ? [` branch: ${j(d.git.branch)}`, ` base_commit: ${j(d.git.head)}`, ` dirty_files: ${d.git.files.length}`, ` diff_sha: ${j(d.git.diffSha.slice(0, 12))}`] : [" git: false"],
|
|
1079
|
-
"---"
|
|
1080
|
-
];
|
|
1081
|
-
const header = [
|
|
1082
|
-
`# Continue: ${d.title}`,
|
|
1083
|
-
"",
|
|
1084
|
-
`> You are continuing a coding task that another AI agent (${TOOL_LABEL[d.from.tool]}) started but could not finish (${REASON_TEXT[d.from.reason]}).`,
|
|
1085
|
-
"> This brief was extracted from its session log and the repo.",
|
|
1086
|
-
d.git ? '> First run `git status` and check it matches "Changed files". Do not redo finished work.' : "> Check the files listed below before editing. Do not redo finished work."
|
|
1087
|
-
];
|
|
1088
|
-
const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
|
|
1089
|
-
if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
|
|
1090
|
-
const next = ["## 2. Next step"];
|
|
1091
|
-
if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
|
|
1092
|
-
else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
|
|
1093
|
-
else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
|
|
1094
|
-
const blocker = ["## 3. Blocker"];
|
|
1095
|
-
if (d.blocker) {
|
|
1096
|
-
const body = tailWithin(d.blocker.output, BUDGET.blocker);
|
|
1097
|
-
const fence = fenceFor(body);
|
|
1098
|
-
blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
|
|
1099
|
-
} else {
|
|
1100
|
-
blocker.push("No failing command recorded.");
|
|
1101
|
-
}
|
|
1102
|
-
const progress = ["## 4. Progress"];
|
|
1103
|
-
if (d.progressSource === "none") progress.push("Nothing recorded yet.");
|
|
1104
|
-
else {
|
|
1105
|
-
if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
|
|
1106
|
-
progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
|
|
1107
|
-
}
|
|
1108
|
-
const files = [];
|
|
1109
|
-
if (d.git) {
|
|
1110
|
-
files.push("## 5. Changed files (uncommitted)");
|
|
1111
|
-
if (d.git.files.length === 0) files.push("Working tree is clean.");
|
|
1112
|
-
else files.push(...listWithin(d.git.files.map(fileLine), BUDGET.files, `${dir}/diff.patch`), `Full diff: \`${dir}/diff.patch\``);
|
|
1113
|
-
} else {
|
|
1114
|
-
files.push("## 5. Changed files", "Not a git repository." + (d.editedFiles.length ? " Files the agent edited:" : ""));
|
|
1115
|
-
files.push(...listWithin(d.editedFiles.map((f) => `- \`${f}\``), BUDGET.files, transcript));
|
|
1116
|
-
}
|
|
1117
|
-
const decisions = [
|
|
1118
|
-
"## 6. Decisions and constraints",
|
|
1119
|
-
...d.decisions.length ? listWithin(d.decisions.map((s) => `- "${oneLine(s, 300)}"`), BUDGET.decisions, transcript) : ["None recorded."]
|
|
1120
|
-
];
|
|
1121
|
-
const deadEnds = [
|
|
1122
|
-
"## 7. Dead ends (don't retry)",
|
|
1123
|
-
...d.deadEnds.length ? listWithin(d.deadEnds.map((e) => `- \`${oneLine(e.cmd, 160)}\` failed (exit ${e.exitCode})`), BUDGET.deadEnds, transcript) : ["None recorded."]
|
|
1124
|
-
];
|
|
1125
|
-
const more = ["## 8. Read more", `- Condensed transcript: \`${transcript}\``];
|
|
1126
|
-
if (opts.previousBrief) more.push(`- Previous brief (as last updated by the previous agent): \`${dir}/previous-HANDOFF.md\``);
|
|
1127
|
-
if (d.readFirst.length) more.push(`- Read these first: ${d.readFirst.map((f) => `\`${f}\``).join(", ")}`);
|
|
1128
|
-
const stop = ["## 9. When you stop", "Before ending your turn, update sections 2\u20134 and `status:` in this file so the next agent can continue."];
|
|
1129
|
-
return [front, header, goal, next, blocker, progress, files, decisions, deadEnds, more, stop].map((s) => s.join("\n")).join("\n\n") + "\n";
|
|
1312
|
+
var isStr = (v) => typeof v === "string";
|
|
1313
|
+
var isObj2 = (v) => typeof v === "object" && v !== null;
|
|
1314
|
+
function validateRecord(raw) {
|
|
1315
|
+
if (!isObj2(raw) || raw.schema !== RECORD_SCHEMA || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
|
|
1316
|
+
const { task, from } = raw;
|
|
1317
|
+
if (!isObj2(from) || !isTool(from.tool)) return null;
|
|
1318
|
+
if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
|
|
1319
|
+
if (!Array.isArray(task.recentUserMessages) || !task.recentUserMessages.every(isStr)) return null;
|
|
1320
|
+
if (!Array.isArray(task.pinnedRules) || !task.pinnedRules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
|
|
1321
|
+
return raw;
|
|
1130
1322
|
}
|
|
1131
1323
|
|
|
1132
1324
|
// ../../packages/core/src/brief/write.ts
|
|
1133
|
-
var import_promises5 = require("node:fs/promises");
|
|
1134
|
-
var import_node_path6 = require("node:path");
|
|
1135
1325
|
async function allocateHandoffId(handoffsDir, baseId) {
|
|
1136
1326
|
for (let i = 1; i < 1e3; i++) {
|
|
1137
1327
|
const id = i === 1 ? baseId : `${baseId}-${i}`;
|
|
1138
1328
|
try {
|
|
1139
|
-
await (0,
|
|
1329
|
+
await (0, import_promises6.access)((0, import_node_path7.join)(handoffsDir, id));
|
|
1140
1330
|
} catch {
|
|
1141
1331
|
return id;
|
|
1142
1332
|
}
|
|
@@ -1145,22 +1335,22 @@ async function allocateHandoffId(handoffsDir, baseId) {
|
|
|
1145
1335
|
}
|
|
1146
1336
|
async function ensureGitExclude(workdir) {
|
|
1147
1337
|
const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
1148
|
-
const file = (0,
|
|
1338
|
+
const file = (0, import_node_path7.isAbsolute)(p) ? p : (0, import_node_path7.join)(workdir, p);
|
|
1149
1339
|
let current2 = "";
|
|
1150
1340
|
try {
|
|
1151
|
-
current2 = await (0,
|
|
1341
|
+
current2 = await (0, import_promises6.readFile)(file, "utf8");
|
|
1152
1342
|
} catch {
|
|
1153
1343
|
current2 = "";
|
|
1154
1344
|
}
|
|
1155
1345
|
if (current2.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
|
|
1156
|
-
await (0,
|
|
1157
|
-
await (0,
|
|
1346
|
+
await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(file), { recursive: true });
|
|
1347
|
+
await (0, import_promises6.appendFile)(file, `${current2 && !current2.endsWith("\n") ? "\n" : ""}.harlay/
|
|
1158
1348
|
`);
|
|
1159
1349
|
}
|
|
1160
1350
|
async function readHandoffState(workdir) {
|
|
1161
1351
|
let raw;
|
|
1162
1352
|
try {
|
|
1163
|
-
raw = JSON.parse(await (0,
|
|
1353
|
+
raw = JSON.parse(await (0, import_promises6.readFile)((0, import_node_path7.join)(workdir, ".harlay", "state.json"), "utf8"));
|
|
1164
1354
|
} catch {
|
|
1165
1355
|
return null;
|
|
1166
1356
|
}
|
|
@@ -1172,118 +1362,61 @@ async function readHandoffState(workdir) {
|
|
|
1172
1362
|
}
|
|
1173
1363
|
async function readIfExists(file) {
|
|
1174
1364
|
try {
|
|
1175
|
-
return await (0,
|
|
1365
|
+
return await (0, import_promises6.readFile)(file, "utf8");
|
|
1176
1366
|
} catch {
|
|
1177
1367
|
return null;
|
|
1178
1368
|
}
|
|
1179
1369
|
}
|
|
1180
|
-
async function
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if (previous !== null) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "previous-HANDOFF.md"), r.apply(previous));
|
|
1189
|
-
const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
|
|
1190
|
-
const briefPath = (0, import_node_path6.join)(dir, "HANDOFF.md");
|
|
1191
|
-
await (0, import_promises5.writeFile)(briefPath, brief);
|
|
1192
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "transcript.md"), r.apply(d.transcript));
|
|
1193
|
-
if (d.git) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1194
|
-
if (d.blocker) await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "last-error.txt"), r.apply(d.blocker.output));
|
|
1195
|
-
const from = {
|
|
1196
|
-
...d.from,
|
|
1197
|
-
message: d.from.message === null ? null : r.apply(d.from.message),
|
|
1198
|
-
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
|
|
1199
|
-
};
|
|
1200
|
-
const goal = d.goal === null ? null : r.apply(d.goal);
|
|
1201
|
-
const title = r.apply(d.title);
|
|
1202
|
-
const meta = {
|
|
1203
|
-
harlay: 1,
|
|
1204
|
-
id,
|
|
1205
|
-
createdAt: d.createdAt.toISOString(),
|
|
1206
|
-
title,
|
|
1207
|
-
goal,
|
|
1208
|
-
from,
|
|
1209
|
-
workdir: d.workdir,
|
|
1210
|
-
unknownRecords: d.unknownRecords,
|
|
1211
|
-
git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
|
|
1212
|
-
redactions: { ...r.counts }
|
|
1213
|
-
};
|
|
1214
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
1215
|
-
await (0, import_promises5.writeFile)(latestPath, brief);
|
|
1216
|
-
const state2 = { harlay: 1, lastId: id, goal, title };
|
|
1217
|
-
await (0, import_promises5.writeFile)((0, import_node_path6.join)(harlayDir, "state.json"), JSON.stringify(state2, null, 2) + "\n");
|
|
1218
|
-
if (d.git) await ensureGitExclude(d.workdir);
|
|
1219
|
-
return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// ../../packages/core/src/launch/launchers.ts
|
|
1223
|
-
var import_node_child_process2 = require("node:child_process");
|
|
1224
|
-
var import_promises6 = require("node:fs/promises");
|
|
1225
|
-
var import_node_path7 = require("node:path");
|
|
1226
|
-
var import_node_util2 = require("node:util");
|
|
1227
|
-
var pexec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
|
|
1228
|
-
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
1229
|
-
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
1230
|
-
var defaultRunner = {
|
|
1231
|
-
async open(args) {
|
|
1232
|
-
await pexec2("/usr/bin/open", args);
|
|
1233
|
-
},
|
|
1234
|
-
async writeExecutable(path, content) {
|
|
1235
|
-
await (0, import_promises6.mkdir)((0, import_node_path7.dirname)(path), { recursive: true });
|
|
1236
|
-
await (0, import_promises6.writeFile)(path, content, { mode: 493 });
|
|
1237
|
-
await (0, import_promises6.chmod)(path, 493);
|
|
1238
|
-
},
|
|
1239
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1240
|
-
};
|
|
1241
|
-
function shq(s) {
|
|
1242
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1243
|
-
}
|
|
1244
|
-
var enc = encodeURIComponent;
|
|
1245
|
-
function planLaunch(o) {
|
|
1246
|
-
if (o.mode === "app") {
|
|
1247
|
-
switch (o.target) {
|
|
1248
|
-
case "claude-code":
|
|
1249
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
1250
|
-
case "codex":
|
|
1251
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
1252
|
-
case "cursor":
|
|
1253
|
-
return {
|
|
1254
|
-
target: o.target,
|
|
1255
|
-
mode: o.mode,
|
|
1256
|
-
steps: [
|
|
1257
|
-
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
1258
|
-
{ kind: "wait", ms: 1500 },
|
|
1259
|
-
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
1260
|
-
]
|
|
1261
|
-
};
|
|
1370
|
+
async function readPreviousHandoff(workdir) {
|
|
1371
|
+
const raw = await readIfExists((0, import_node_path7.join)(workdir, ".harlay", "handoff.json"));
|
|
1372
|
+
if (raw !== null) {
|
|
1373
|
+
let rec;
|
|
1374
|
+
try {
|
|
1375
|
+
rec = validateRecord(JSON.parse(raw));
|
|
1376
|
+
} catch {
|
|
1377
|
+
return null;
|
|
1262
1378
|
}
|
|
1379
|
+
if (!rec) return null;
|
|
1380
|
+
const { task } = rec;
|
|
1381
|
+
return {
|
|
1382
|
+
id: rec.id,
|
|
1383
|
+
goal: task.originalGoal,
|
|
1384
|
+
title: task.title,
|
|
1385
|
+
recentUserMessages: task.recentUserMessages,
|
|
1386
|
+
pinnedRules: task.pinnedRules,
|
|
1387
|
+
moreRules: typeof task.moreRules === "number" ? task.moreRules : 0
|
|
1388
|
+
};
|
|
1263
1389
|
}
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
1267
|
-
const path = (0, import_node_path7.join)(o.scriptDir, `continue-${o.target}.command`);
|
|
1268
|
-
const content = `#!/bin/zsh -l
|
|
1269
|
-
cd -- ${shq(o.cwd)} || exit 1
|
|
1270
|
-
exec ${argv.map(shq).join(" ")}
|
|
1271
|
-
`;
|
|
1272
|
-
return {
|
|
1273
|
-
target: o.target,
|
|
1274
|
-
mode: o.mode,
|
|
1275
|
-
steps: [
|
|
1276
|
-
{ kind: "script", path, content },
|
|
1277
|
-
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
1278
|
-
]
|
|
1279
|
-
};
|
|
1390
|
+
const state2 = await readHandoffState(workdir);
|
|
1391
|
+
return state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title, recentUserMessages: [], pinnedRules: [], moreRules: 0 } : null;
|
|
1280
1392
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1393
|
+
function addCounts(a, b) {
|
|
1394
|
+
const out = { ...a };
|
|
1395
|
+
for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
|
|
1396
|
+
return out;
|
|
1397
|
+
}
|
|
1398
|
+
async function writeHandoff(d, o) {
|
|
1399
|
+
const harlayDir = (0, import_node_path7.join)(d.workdir, ".harlay");
|
|
1400
|
+
const latestPath = (0, import_node_path7.join)(harlayDir, "HANDOFF.md");
|
|
1401
|
+
const id = await allocateHandoffId((0, import_node_path7.join)(harlayDir, "handoffs"), d.baseId);
|
|
1402
|
+
const dir = (0, import_node_path7.join)(harlayDir, "handoffs", id);
|
|
1403
|
+
await (0, import_promises6.mkdir)(dir, { recursive: true });
|
|
1404
|
+
const r = new Redactor();
|
|
1405
|
+
const body = buildRecord(d, id, o.previousId, r);
|
|
1406
|
+
const chosen = choosePrompt(body, (p) => promptFits(o.target, o.mode, p));
|
|
1407
|
+
const record = { ...body, prompt: { target: o.target, tier: chosen.tier, chars: chosen.text.length } };
|
|
1408
|
+
const json = JSON.stringify(record, null, 2) + "\n";
|
|
1409
|
+
const recordPath = (0, import_node_path7.join)(dir, "handoff.json");
|
|
1410
|
+
await (0, import_promises6.writeFile)(recordPath, json);
|
|
1411
|
+
await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "prompt.md"), chosen.text + "\n");
|
|
1412
|
+
await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "transcript.md"), r.apply(stripControl(d.transcript)));
|
|
1413
|
+
if (d.git) await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1414
|
+
if (d.lastFailure) await (0, import_promises6.writeFile)((0, import_node_path7.join)(dir, "last-error.txt"), r.apply(stripControl(d.lastFailure.output)));
|
|
1415
|
+
await (0, import_promises6.writeFile)(latestPath, renderPrompt(body, "full") + "\n");
|
|
1416
|
+
await (0, import_promises6.writeFile)((0, import_node_path7.join)(harlayDir, "handoff.json"), json);
|
|
1417
|
+
if (d.git) await ensureGitExclude(d.workdir);
|
|
1418
|
+
const redactions = addCounts(d.redactions ?? {}, r.counts);
|
|
1419
|
+
return { id, dir, recordPath, latestPath, prompt: chosen.text, tier: chosen.tier, redactions };
|
|
1287
1420
|
}
|
|
1288
1421
|
|
|
1289
1422
|
// ../../packages/core/src/launch/resolve.ts
|
|
@@ -1385,10 +1518,9 @@ async function runHandoff(o) {
|
|
|
1385
1518
|
}
|
|
1386
1519
|
const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
|
|
1387
1520
|
const git2 = await takeGitSnapshot(cwd);
|
|
1388
|
-
const
|
|
1389
|
-
const previous = state2 ? { id: state2.lastId, goal: state2.goal, title: state2.title } : null;
|
|
1521
|
+
const previous = await readPreviousHandoff(git2?.root ?? cwd);
|
|
1390
1522
|
const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
|
|
1391
|
-
const written = await writeHandoff(data);
|
|
1523
|
+
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom });
|
|
1392
1524
|
if (o.launch === false) return { written, plan: null, data };
|
|
1393
1525
|
let plan;
|
|
1394
1526
|
try {
|
|
@@ -1397,7 +1529,7 @@ async function runHandoff(o) {
|
|
|
1397
1529
|
target: o.target,
|
|
1398
1530
|
mode: o.mode,
|
|
1399
1531
|
cwd: data.workdir,
|
|
1400
|
-
prompt:
|
|
1532
|
+
prompt: written.prompt,
|
|
1401
1533
|
scriptDir: written.dir,
|
|
1402
1534
|
binaries,
|
|
1403
1535
|
terminalApp: o.terminalApp ?? "Terminal"
|
|
@@ -2047,11 +2179,11 @@ function bridgeCommand(dataDir2) {
|
|
|
2047
2179
|
const q = `'${scriptPath(dataDir2).replace(/'/g, `'\\''`)}'`;
|
|
2048
2180
|
return `${q} ${BRIDGE_MARKER}`;
|
|
2049
2181
|
}
|
|
2050
|
-
function
|
|
2182
|
+
function isObj3(x) {
|
|
2051
2183
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
2052
2184
|
}
|
|
2053
2185
|
function isOurs(statusLine) {
|
|
2054
|
-
return
|
|
2186
|
+
return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
|
|
2055
2187
|
}
|
|
2056
2188
|
async function readSettings(path) {
|
|
2057
2189
|
let text;
|
|
@@ -2062,7 +2194,7 @@ async function readSettings(path) {
|
|
|
2062
2194
|
throw e;
|
|
2063
2195
|
}
|
|
2064
2196
|
const parsed = JSON.parse(text);
|
|
2065
|
-
if (!
|
|
2197
|
+
if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
|
|
2066
2198
|
return parsed;
|
|
2067
2199
|
}
|
|
2068
2200
|
async function writeAtomic(path, text) {
|
|
@@ -2087,13 +2219,13 @@ async function installBridge(home2, dataDir2) {
|
|
|
2087
2219
|
const prev = s.statusLine;
|
|
2088
2220
|
if (prev !== void 0) {
|
|
2089
2221
|
await writeAtomic(previousJson(dataDir2), JSON.stringify(prev, null, 2) + "\n");
|
|
2090
|
-
const cmd =
|
|
2222
|
+
const cmd = isObj3(prev) && typeof prev.command === "string" ? prev.command : "";
|
|
2091
2223
|
await writeAtomic(previousCommand(dataDir2), cmd);
|
|
2092
2224
|
} else {
|
|
2093
2225
|
await (0, import_promises12.rm)(previousJson(dataDir2), { force: true });
|
|
2094
2226
|
await (0, import_promises12.rm)(previousCommand(dataDir2), { force: true });
|
|
2095
2227
|
}
|
|
2096
|
-
s.statusLine = { ...
|
|
2228
|
+
s.statusLine = { ...isObj3(prev) ? prev : {}, type: "command", command: bridgeCommand(dataDir2) };
|
|
2097
2229
|
await (0, import_promises12.mkdir)((0, import_node_path12.join)(home2, ".claude"), { recursive: true });
|
|
2098
2230
|
await writeAtomic(path, JSON.stringify(s, null, 2) + "\n");
|
|
2099
2231
|
}
|