@neta-art/cohub-cli 10.7.0 → 10.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +4 -4
  2. package/dist/board-kernel.js +276 -214
  3. package/dist/commands/boards/examples.js +16 -16
  4. package/dist/commands/boards.js +1 -1
  5. package/dist/commands/runtime.js +3 -14
  6. package/dist/runtime/connection.js +161 -54
  7. package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
  8. package/dist/runtime/harnesses/chained-transcript.js +62 -0
  9. package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
  10. package/dist/runtime/harnesses/claude-transcript.js +138 -0
  11. package/dist/runtime/harnesses/claude.js +20 -2
  12. package/dist/runtime/harnesses/codex.js +1 -1
  13. package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
  14. package/dist/runtime/harnesses/cursor-transcript.js +183 -0
  15. package/dist/runtime/harnesses/cursor.d.ts +24 -0
  16. package/dist/runtime/harnesses/cursor.js +620 -0
  17. package/dist/runtime/harnesses/index.d.ts +1 -3
  18. package/dist/runtime/harnesses/index.js +4 -10
  19. package/dist/runtime/harnesses/pi.js +1 -1
  20. package/dist/runtime/harnesses/stream-json.d.ts +5 -1
  21. package/dist/runtime/harnesses/stream-json.js +31 -9
  22. package/dist/runtime/harnesses/types.d.ts +10 -5
  23. package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
  24. package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
  25. package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
  26. package/dist/runtime/harnesses/workbuddy.js +31 -7
  27. package/dist/runtime/json-rpc.d.ts +2 -0
  28. package/dist/runtime/json-rpc.js +9 -0
  29. package/dist/runtime/native/attach.d.ts +1 -1
  30. package/dist/runtime/native/attach.js +1 -4
  31. package/dist/runtime/native/execution.js +14 -3
  32. package/dist/runtime/native/ingest.d.ts +24 -2
  33. package/dist/runtime/native/ingest.js +76 -10
  34. package/dist/runtime/native/sessions.js +6 -1
  35. package/dist/runtime/native/transcript.d.ts +23 -0
  36. package/dist/runtime/native/transcript.js +40 -5
  37. package/package.json +3 -3
@@ -0,0 +1,138 @@
1
+ import { record } from "../json-rpc.js";
2
+ import { iso } from "../native/transcript.js";
3
+ import { chainBranch, extendTurn, readChainedLines, startTurn } from "./chained-transcript.js";
4
+ import { array, text } from "./process.js";
5
+ import { streamContent, streamUsage } from "./stream-json.js";
6
+ const COMMAND = /^<command-name>\s*([^<]*)<\/command-name>/;
7
+ const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
8
+ const BASH_INPUT = /^<bash-input>([\s\S]*?)<\/bash-input>/;
9
+ /** Output of a local command or `!` shell, recorded as a user message but never sent as a prompt. */
10
+ const LOCAL_OUTPUT = /^<(?:local-command-(?:stdout|stderr|caveat)|bash-(?:stdout|stderr))>/;
11
+ const INTERRUPTED = "[Request interrupted by user";
12
+ export const CLAUDE_REPLY_ENDED = ["end_turn", "stop_sequence", "max_tokens", "refusal", "aborted", "error"];
13
+ const nonEmpty = (block) => !(block.type === "text" && !block.text) && !(block.type === "thinking" && !block.thinking);
14
+ /** A local `/command` or `!shell` is shown as typed; one that never reached the model is no Turn. */
15
+ function prompt(value) {
16
+ const content = typeof value === "string" ? [{ type: "text", text: value }] : streamContent(value).filter((block) => block.type === "text" || block.type === "image");
17
+ const first = content.find((block) => block.type === "text")?.text.trimStart() ?? "";
18
+ if (LOCAL_OUTPUT.test(first) || first.startsWith(INTERRUPTED))
19
+ return null;
20
+ const command = COMMAND.exec(first);
21
+ if (command)
22
+ return { content: [{ type: "text", text: `${command[1]?.trim() ?? ""} ${COMMAND_ARGS.exec(first)?.[1]?.trim() ?? ""}`.trim() }], local: true };
23
+ const bash = BASH_INPUT.exec(first);
24
+ if (bash)
25
+ return { content: [{ type: "text", text: `!${bash[1] ?? ""}` }], local: true };
26
+ const visible = content.filter(nonEmpty);
27
+ return visible.length ? { content: visible, local: false } : null;
28
+ }
29
+ /**
30
+ * Claude Code appends one record per content block, linked by `parentUuid` (`logicalParentUuid`
31
+ * across a compaction). A Turn starts at a prompt; its tool results and an interruption share the
32
+ * prompt's `promptId`. Interactive sessions record each end (`turn_duration`), `-p` sessions do not.
33
+ */
34
+ export async function readClaudeTranscript(path, nativeSessionId, signal) {
35
+ const read = await readChainedLines(path, signal);
36
+ const branch = chainBranch(read.lines, (value) => value.isSidechain === true ? "" : text(value.uuid), (value) => text(value.parentUuid) || text(value.logicalParentUuid));
37
+ const turns = [];
38
+ const owners = new Map();
39
+ const messageIds = new Map();
40
+ let cwd = "", cloudSessionId = "";
41
+ let current = null;
42
+ let promptId = "", local = false, interrupted = false, completedAt = "";
43
+ const finish = () => {
44
+ if (!current)
45
+ return;
46
+ // A local command that never reached the model is not a conversation Turn.
47
+ if (local && !current.messages.length) {
48
+ current = null;
49
+ return;
50
+ }
51
+ const last = current.messages.at(-1);
52
+ const status = interrupted || !last || last.stopReason === "tool_use" ? "interrupted" : last.stopReason === "error" ? "failed" : "completed";
53
+ current.result = { messages: current.messages, completedAt: completedAt || current.startedAt, status };
54
+ turns.push(current);
55
+ current = null;
56
+ };
57
+ for (const line of branch) {
58
+ const value = line.value;
59
+ cwd ||= text(value.cwd);
60
+ if (value.type === "user") {
61
+ const message = record(value.message);
62
+ const results = array(message.content).map(record).filter((block) => block.type === "tool_result");
63
+ const opened = value.isMeta === true || value.isCompactSummary === true || results.length ? null : prompt(message.content);
64
+ if (opened && !(current && text(value.promptId) && text(value.promptId) === promptId)) {
65
+ finish();
66
+ const cohub = record(value.cohub);
67
+ cloudSessionId ||= text(cohub.sessionId);
68
+ // Cohub sends its Turn id as the prompt's record id; a projected record names its Turn.
69
+ current = startTurn(line, text(value.uuid), turns.at(-1)?.key ?? null, opened.content, text(cohub.turnId) || text(value.uuid));
70
+ promptId = text(value.promptId);
71
+ local = opened.local;
72
+ interrupted = false;
73
+ completedAt = current.startedAt;
74
+ owners.clear();
75
+ messageIds.clear();
76
+ continue;
77
+ }
78
+ if (!current)
79
+ continue;
80
+ for (const block of results) {
81
+ const id = text(block.tool_use_id);
82
+ const owner = owners.get(id);
83
+ if (!owner)
84
+ continue;
85
+ owner.content.push({ type: "tool_result", tool_use_id: id, content: typeof block.content === "string" ? block.content : streamContent(block.content), is_error: block.is_error === true });
86
+ }
87
+ if (array(message.content).some((block) => text(record(block).text).startsWith(INTERRUPTED)) || text(message.content).startsWith(INTERRUPTED)) {
88
+ interrupted = true;
89
+ current.ended = true;
90
+ }
91
+ completedAt = iso(value.timestamp);
92
+ extendTurn(current, line, true);
93
+ continue;
94
+ }
95
+ if (!current)
96
+ continue;
97
+ if (value.type === "assistant") {
98
+ const native = record(value.message);
99
+ const id = text(native.id);
100
+ let target = current.messages.at(-1);
101
+ if (!target || !id || messageIds.get(target) !== id) {
102
+ target = { content: [], provider: null, model: null, stopReason: null, errorMessage: null };
103
+ current.messages.push(target);
104
+ messageIds.set(target, id);
105
+ }
106
+ const content = streamContent(native.content).filter(nonEmpty);
107
+ for (const block of content) {
108
+ const index = block.type === "tool_use" ? target.content.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id) : -1;
109
+ if (index >= 0)
110
+ target.content[index] = block;
111
+ else
112
+ target.content.push(block);
113
+ if (block.type === "tool_use")
114
+ owners.set(block.id, target);
115
+ }
116
+ const model = text(native.model);
117
+ if (model && model !== "<synthetic>")
118
+ target.model = model;
119
+ if (text(native.stop_reason))
120
+ target.stopReason = text(native.stop_reason);
121
+ const usage = streamUsage(native.usage, undefined);
122
+ if (usage)
123
+ target.usage = usage;
124
+ if (value.isApiErrorMessage === true) {
125
+ target.stopReason = "error";
126
+ target.errorMessage = content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n") || "API error";
127
+ }
128
+ completedAt = iso(value.timestamp);
129
+ extendTurn(current, line, true);
130
+ continue;
131
+ }
132
+ if (value.type === "system" && value.subtype === "turn_duration")
133
+ current.ended = true;
134
+ extendTurn(current, line, false);
135
+ }
136
+ finish();
137
+ return { nativeSessionId, cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes: read.prefixes };
138
+ }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, join } from "node:path";
4
4
  import { JsonRpcProcess, record } from "../json-rpc.js";
5
5
  import { copyNativeArchive } from "../native/archive.js";
6
6
  import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
7
+ import { chainedSessionHeader, projectDirectory, projectTranscript } from "./chained-transcript.js";
8
+ import { CLAUDE_REPLY_ENDED, readClaudeTranscript } from "./claude-transcript.js";
7
9
  import { array, harnessCommand, text } from "./process.js";
8
10
  import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
9
11
  const MAX_PROJECT_NAME = 200;
@@ -23,6 +25,9 @@ export function claudeProjectName(cwd) {
23
25
  return `${name.slice(0, MAX_PROJECT_NAME)}-${Math.abs(hash).toString(36)}`;
24
26
  }
25
27
  export const claudeProjectDirectory = (cwd) => join(dataRoot(), "projects", claudeProjectName(cwd));
28
+ const projectsRoot = () => join(dataRoot(), "projects");
29
+ /** Every subdirectory's project name starts with this; a hashed long name keeps its first 200 units. */
30
+ const projectPrefix = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-").slice(0, MAX_PROJECT_NAME);
26
31
  const profile = {
27
32
  harness: "claude",
28
33
  label: "Claude Code",
@@ -31,6 +36,7 @@ const profile = {
31
36
  dataRoot,
32
37
  projectDirectory: claudeProjectDirectory,
33
38
  effortLevels: ["low", "medium", "high", "xhigh", "max"],
39
+ promptUuid: true,
34
40
  };
35
41
  function anthropicBlock(block) {
36
42
  if (block.type === "text")
@@ -149,5 +155,17 @@ export const claudeHarness = {
149
155
  },
150
156
  },
151
157
  },
152
- nativeSync: false,
158
+ transcript: {
159
+ roots: () => [projectsRoot()],
160
+ acceptsDirectory: (path, root, cwd) => projectDirectory(path, root, projectPrefix(cwd)),
161
+ acceptsTranscript: (path, cwd) => projectTranscript(path, projectsRoot(), projectPrefix(cwd)),
162
+ header: (path, signal) => chainedSessionHeader(path, "claude", signal),
163
+ read: (path, signal) => readClaudeTranscript(path, basename(path, ".jsonl"), signal),
164
+ boundaries: ['"promptSource"', '"subtype":"turn_duration"', '"interruptedMessageId"', '"isApiErrorMessage":true', ...CLAUDE_REPLY_ENDED.map((reason) => `"stop_reason":"${reason}"`)],
165
+ turnEndRecorded: false,
166
+ replyEnded: (turn) => CLAUDE_REPLY_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
167
+ userFinalMarked: false,
168
+ globalTurnKeys: false,
169
+ marksCohubTurns: true,
170
+ },
153
171
  };
@@ -223,7 +223,7 @@ export const codexHarness = {
223
223
  turnEndRecorded: true,
224
224
  userFinalMarked: true,
225
225
  globalTurnKeys: true,
226
+ marksCohubTurns: true,
226
227
  archiveSource: (path, cacheDirectory) => ensurePlainCodexRollout(path, cacheDirectory),
227
228
  },
228
- nativeSync: true,
229
229
  };
@@ -0,0 +1,37 @@
1
+ import type { NativeTranscript } from "../native/transcript.js";
2
+ import type { SessionHeader } from "./types.js";
3
+ /** Chats (`store.db` and `meta.json`) live under the config directory, transcripts under the data directory. */
4
+ export declare const cursorConfigDirectory: () => string;
5
+ export declare const cursorProjectsRoot: () => string;
6
+ export declare const cursorChatsRoot: () => string;
7
+ /** Cursor's project name for a workspace path. */
8
+ export declare const cursorProjectName: (cwd: string) => string;
9
+ export declare const cursorTranscriptPath: (nativeSessionId: string, cwd: string) => string;
10
+ /** A chat's store directory is keyed by the MD5 of the directory the CLI ran in. */
11
+ export declare const cursorChatDirectory: (nativeSessionId: string, cwd: string) => string;
12
+ export declare function acceptsCursorDirectory(path: string, root: string, cwd: string): boolean;
13
+ export declare function acceptsCursorTranscript(path: string, cwd: string): boolean;
14
+ /**
15
+ * The directory a project name stands for, matched against real directories. On Windows the name
16
+ * starts with the drive letter and matching ignores case. Several directories with the same name
17
+ * (`a/b` and `a-b`) cannot be told apart, so none is chosen.
18
+ */
19
+ type Entry = {
20
+ name: string;
21
+ isDirectory(): boolean;
22
+ isSymbolicLink(): boolean;
23
+ };
24
+ export declare function projectDirectory(name: string, platform?: NodeJS.Platform, list?: (directory: string) => Promise<Entry[]>): Promise<string | null>;
25
+ /**
26
+ * The transcript does not name its project. A CLI chat's `meta.json` does; the IDE writes the same
27
+ * transcripts without one, so its project name is resolved to the directory it stands for.
28
+ */
29
+ export declare function cursorSessionHeader(path: string): Promise<SessionHeader | null>;
30
+ /**
31
+ * Cursor writes the model's messages without ids or times: a prompt (`<user_query>`, or a bare text
32
+ * that no other user line follows), one line per reply step with its tool calls (results are not
33
+ * recorded), and `turn_ended` after every Turn. A user line followed by another is context. A Turn
34
+ * is keyed by the file's checksum through its prompt, so a rewritten history never reuses a key.
35
+ */
36
+ export declare function readCursorTranscript(path: string, nativeSessionId: string, signal?: AbortSignal): Promise<NativeTranscript>;
37
+ export {};
@@ -0,0 +1,183 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync } from "node:fs";
3
+ import { readdir, readFile, stat } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join, posix, relative, resolve, sep, win32 } from "node:path";
6
+ import { record } from "../json-rpc.js";
7
+ import { extendTurn, readChainedLines } from "./chained-transcript.js";
8
+ import { array, missing, text } from "./process.js";
9
+ const TRANSCRIPTS = "agent-transcripts";
10
+ const USER_QUERY = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/;
11
+ const TIMESTAMP = /<timestamp>([^<]*)<\/timestamp>/;
12
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
13
+ /** Chats (`store.db` and `meta.json`) live under the config directory, transcripts under the data directory. */
14
+ export const cursorConfigDirectory = () => process.env.CURSOR_CONFIG_DIR?.trim()
15
+ || (process.env.XDG_CONFIG_HOME?.trim() ? join(process.env.XDG_CONFIG_HOME.trim(), "cursor") : join(homedir(), ".cursor"));
16
+ const dataDirectory = () => process.env.CURSOR_DATA_DIR?.trim() || join(homedir(), ".cursor");
17
+ export const cursorProjectsRoot = () => join(dataDirectory(), "projects");
18
+ export const cursorChatsRoot = () => join(cursorConfigDirectory(), "chats");
19
+ /** Cursor's project name for a workspace path. */
20
+ export const cursorProjectName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
21
+ /** Windows reuses a project folder whose name differs only in case, and file events name that folder. */
22
+ function projectFolder(cwd) {
23
+ const name = cursorProjectName(cwd);
24
+ if (process.platform !== "win32")
25
+ return name;
26
+ try {
27
+ return readdirSync(cursorProjectsRoot()).find((entry) => entry.toLowerCase() === name.toLowerCase()) ?? name;
28
+ }
29
+ catch {
30
+ return name;
31
+ }
32
+ }
33
+ export const cursorTranscriptPath = (nativeSessionId, cwd) => join(cursorProjectsRoot(), projectFolder(cwd), TRANSCRIPTS, nativeSessionId, `${nativeSessionId}.jsonl`);
34
+ /** A chat's store directory is keyed by the MD5 of the directory the CLI ran in. */
35
+ export const cursorChatDirectory = (nativeSessionId, cwd) => join(cursorChatsRoot(), createHash("md5").update(resolve(cwd)).digest("hex"), nativeSessionId);
36
+ /** A chat's project never changes, so its header is read once. */
37
+ const headers = new Map();
38
+ /** `<project>/agent-transcripts/<id>/<id>.jsonl`; a subdirectory's project name extends its parent's. Sub-agent files are skipped. */
39
+ function transcriptParts(path, root) {
40
+ const parts = relative(root, path).split(sep);
41
+ return parts.length && !parts[0]?.startsWith("..") ? parts : null;
42
+ }
43
+ /** Windows paths ignore case, so a terminal opened as `c:\\users` names the same project as `C:\\Users`. */
44
+ const fold = (value, platform) => platform === "win32" ? value.toLowerCase() : value;
45
+ const ownsProject = (name, cwd, platform = process.platform) => {
46
+ const project = fold(cursorProjectName(cwd), platform), own = fold(name ?? "", platform);
47
+ return own === project || own.startsWith(`${project}-`);
48
+ };
49
+ export function acceptsCursorDirectory(path, root, cwd) {
50
+ const parts = transcriptParts(path, root);
51
+ if (!parts || parts.length > 3 || !ownsProject(parts[0], cwd))
52
+ return false;
53
+ return parts.length === 1 || parts[1] === TRANSCRIPTS;
54
+ }
55
+ export function acceptsCursorTranscript(path, cwd) {
56
+ const parts = transcriptParts(path, cursorProjectsRoot());
57
+ return parts?.length === 4 && ownsProject(parts[0], cwd) && parts[1] === TRANSCRIPTS && parts[3] === `${parts[2]}.jsonl`;
58
+ }
59
+ export async function projectDirectory(name, platform = process.platform, list = (directory) => readdir(directory, { withFileTypes: true })) {
60
+ const paths = platform === "win32" ? win32 : posix;
61
+ const drive = platform === "win32" ? /^([A-Za-z])(?:-(.*))?$/.exec(name) : null;
62
+ if (platform === "win32" && !drive)
63
+ return null;
64
+ const found = [];
65
+ const walk = async (directory, rest) => {
66
+ if (found.length > 1)
67
+ return;
68
+ if (!rest) {
69
+ found.push(directory);
70
+ return;
71
+ }
72
+ const entries = await list(directory).catch(() => []);
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
75
+ continue;
76
+ const part = fold(cursorProjectName(entry.name), platform);
77
+ if (part && (fold(rest, platform) === part || fold(rest, platform).startsWith(`${part}-`)))
78
+ await walk(paths.join(directory, entry.name), rest.slice(part.length + 1));
79
+ }
80
+ };
81
+ await (drive ? walk(`${drive[1]?.toUpperCase()}:\\`, drive[2] ?? "") : walk("/", name));
82
+ return found.length === 1 ? found[0] ?? null : null;
83
+ }
84
+ /**
85
+ * The transcript does not name its project. A CLI chat's `meta.json` does; the IDE writes the same
86
+ * transcripts without one, so its project name is resolved to the directory it stands for.
87
+ */
88
+ export async function cursorSessionHeader(path) {
89
+ const nativeSessionId = basename(path, ".jsonl");
90
+ if (basename(dirname(path)) !== nativeSessionId)
91
+ return null;
92
+ const known = headers.get(nativeSessionId);
93
+ if (known)
94
+ return known;
95
+ let header = null;
96
+ const hashes = await readdir(cursorChatsRoot()).catch((error) => { if (missing(error))
97
+ return []; throw error; });
98
+ for (const hash of hashes) {
99
+ const meta = await readFile(join(cursorChatsRoot(), hash, nativeSessionId, "meta.json"), "utf8").then((raw) => record(JSON.parse(raw)), () => null);
100
+ if (!meta || !text(meta.cwd))
101
+ continue;
102
+ header = { harness: "cursor", nativeSessionId, cwd: text(meta.cwd), thread: meta.isSubagent === true ? "spawned" : "user" };
103
+ break;
104
+ }
105
+ const project = basename(dirname(dirname(dirname(path))));
106
+ const cwd = header ? null : await projectDirectory(project);
107
+ header ??= cwd ? { harness: "cursor", nativeSessionId, cwd, thread: "user" } : null;
108
+ if (header)
109
+ headers.set(nativeSessionId, header);
110
+ return header;
111
+ }
112
+ /** "Saturday, Aug 01, 2026, 9:30 AM (UTC+8)": minutes only, so Turn times are approximate. */
113
+ function promptTime(value) {
114
+ const match = /^\w+, (\w{3}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}) (AM|PM) \(UTC(?:([+-])(\d{1,2})(?::(\d{2}))?)?\)$/.exec(value.trim());
115
+ const month = MONTHS.indexOf(match?.[1] ?? "");
116
+ if (!match || month < 0)
117
+ return null;
118
+ const hour = Number(match[4]) % 12 + (match[6] === "PM" ? 12 : 0);
119
+ const offset = (match[7] === "-" ? -1 : 1) * (Number(match[8] ?? 0) * 60 + Number(match[9] ?? 0));
120
+ const time = Date.UTC(Number(match[3]), month, Number(match[2]), hour, Number(match[5])) - offset * 60_000;
121
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
122
+ }
123
+ const blocks = (value) => array(record(record(value).message).content).map(record);
124
+ /**
125
+ * Cursor writes the model's messages without ids or times: a prompt (`<user_query>`, or a bare text
126
+ * that no other user line follows), one line per reply step with its tool calls (results are not
127
+ * recorded), and `turn_ended` after every Turn. A user line followed by another is context. A Turn
128
+ * is keyed by the file's checksum through its prompt, so a rewritten history never reuses a key.
129
+ */
130
+ export async function readCursorTranscript(path, nativeSessionId, signal) {
131
+ const read = await readChainedLines(path, signal);
132
+ const modified = (await stat(path)).mtime.toISOString();
133
+ const turns = [];
134
+ let current = null;
135
+ for (const [index, line] of read.lines.entries()) {
136
+ const value = line.value;
137
+ if (value.role === "user") {
138
+ const raw = blocks(value).map((block) => text(block.text)).join("\n");
139
+ // A bare text is a prompt only once a reply or end follows it; until then it may be context.
140
+ const next = read.lines[index + 1]?.value;
141
+ const query = USER_QUERY.exec(raw)?.[1] ?? (next && next.role !== "user" ? raw.replace(TIMESTAMP, "").trim() : null);
142
+ if (!query)
143
+ continue;
144
+ const startedAt = promptTime(TIMESTAMP.exec(raw)?.[1] ?? "") ?? current?.startedAt ?? modified;
145
+ current = {
146
+ key: line.sha256, parentKey: turns.at(-1)?.key ?? null, userContent: [{ type: "text", text: query }], messages: [], startedAt,
147
+ startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null,
148
+ };
149
+ turns.push(current);
150
+ continue;
151
+ }
152
+ if (!current)
153
+ continue;
154
+ if (value.role === "assistant") {
155
+ const content = blocks(value).flatMap((block, position) => {
156
+ if (block.type === "text")
157
+ return text(block.text) ? [{ type: "text", text: text(block.text) }] : [];
158
+ if (block.type !== "tool_use")
159
+ return [];
160
+ return [{ type: "tool_use", id: `cursor_${line.sha256.slice(0, 24)}_${position}`, name: text(block.name) || "tool", input: record(block.input) }];
161
+ });
162
+ if (content.length)
163
+ current.messages.push({ content, provider: null, model: null, stopReason: content.some((block) => block.type === "tool_use") ? "tool_use" : "stop", errorMessage: null });
164
+ extendTurn(current, line, true);
165
+ continue;
166
+ }
167
+ if (value.type === "turn_ended" && !current.result) {
168
+ const status = text(value.status) || "success";
169
+ const last = current.messages.at(-1);
170
+ if (last && status === "error")
171
+ Object.assign(last, { stopReason: "error", errorMessage: text(value.error) || "Turn failed" });
172
+ if (last && status === "aborted")
173
+ last.stopReason = "aborted";
174
+ current.result = { messages: current.messages, completedAt: modified, status: status === "error" ? "failed" : status === "aborted" ? "interrupted" : "completed" };
175
+ current.ended = true;
176
+ extendTurn(current, line, false);
177
+ }
178
+ }
179
+ // A Turn ends no later than the next one starts.
180
+ turns.forEach((turn, index) => { if (turn.result)
181
+ turn.result.completedAt = turns[index + 1]?.startedAt ?? modified; });
182
+ return { nativeSessionId, cwd: (await cursorSessionHeader(path))?.cwd ?? "", turns, prefixes: read.prefixes };
183
+ }
@@ -0,0 +1,24 @@
1
+ import type { RuntimeExecutionEvent, RuntimeMessage } from "@neta-art/cohub";
2
+ import { type JsonRecord } from "../json-rpc.js";
3
+ import type { HarnessModel, LocalHarnessAdapter } from "./types.js";
4
+ /** The process a Cursor CLI invocation runs, and the variables its launcher would set. */
5
+ export declare function cursorCommand(executable: string, args: string[], cwd: string): {
6
+ command: [string, string[]];
7
+ env: Record<string, string>;
8
+ };
9
+ /** `cursor-agent models`: `<id> - <name> (current, default)` per line. */
10
+ export declare function cursorModels(output: string): HarnessModel[];
11
+ /**
12
+ * Maps `--output-format stream-json --stream-partial-output` onto Cohub's streaming protocol. A reply
13
+ * step ends once its tool calls returned. Cursor repeats a step's streamed text as one line right
14
+ * before a tool call, question or retry (with a timestamp) and at the end (without one); such a line
15
+ * is held until the next event says whether it was a repeat.
16
+ */
17
+ export declare function cursorTranslator(emit: (event: RuntimeExecutionEvent) => void, identity: {
18
+ model: string | null;
19
+ }): {
20
+ result: () => RuntimeMessage | null;
21
+ last: () => RuntimeMessage;
22
+ push(event: JsonRecord): boolean;
23
+ };
24
+ export declare const cursorHarness: LocalHarnessAdapter;