@100xprompt/brain 0.0.0-stage → 0.1.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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,383 @@
1
+ import { randomBytes, randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, isDir } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { fileContext } from "./project.ts"
10
+ import { readChat } from "./reader.ts"
11
+ import { CHAT_MESSAGES, CHAT_META, LABEL, RUN_STATE, TESTED_VERSIONS, TOOL, chatId, chatsDir, continuedNote, installed, summaryMessageText, withSystemTags } from "./records.ts"
12
+
13
+ // Continues a portable session in Codebuff / Freebuff as a new chat of the project: a folder
14
+ // ~/.config/manicode/projects/<project folder name>/chats/<chat id>/ holding the three files the CLI saves
15
+ // (run-storage saveChatState): run-state.json, chat-messages.json and chat-meta.json, written in that order
16
+ // and each through a temp file and a rename, as the CLI writes them. `freebuff --continue <chat id>` run in the
17
+ // project loads them (loadMostRecentChatState): the screen gets chat-messages.json, the model gets the run
18
+ // state's messageHistory on the next prompt, and /history lists the chat from chat-meta.json.
19
+ //
20
+ // The model's history is written the way the runtime appends to it: the person's prompts as user messages
21
+ // tagged USER_PROMPT, each assistant text, reasoning part and tool call as its own assistant message, and each
22
+ // result as a tool message with a JSON result, right after the calls it answers. Shell, read, write and edit
23
+ // calls become Codebuff's own run_terminal_command, read_files, write_file and str_replace, with results in
24
+ // those tools' shapes; other calls keep their name. A compaction is the runtime's own <conversation_summary>
25
+ // message: the model gets the summary and what follows, the screen keeps every turn. The note saying where the
26
+ // chat came from is a <system> user message without USER_PROMPT, how the runtime adds its own notes, and a
27
+ // notice on screen. The fileContext is a snapshot of the project made as for a new chat (project.ts).
28
+
29
+ type Json = Record<string, unknown>
30
+ type Call = Extract<Turn, { kind: "tool_call" }>
31
+ type Result = Extract<Turn, { kind: "tool_result" }>
32
+
33
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
34
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
35
+ const IMAGE_LIMIT = 5 * 1024 * 1024
36
+ /** What the agent runtime starts every agent with (session-state.ts MAX_AGENT_STEPS_DEFAULT). */
37
+ const MAX_AGENT_STEPS = 600
38
+
39
+ /** The exit code a failed command's output states ("Exit code 2", "exited with code 2"), if it states one. */
40
+ const exitCodeOf = (out: string) => {
41
+ const m = out.match(/exit(?:ed with)?(?: status| code)?:?\s*(-?\d+)\b/i)
42
+ return m && Number(m[1]) !== 0 ? Number(m[1]) : undefined
43
+ }
44
+
45
+ const pick = (o: Json, keys: string[]) => {
46
+ for (const k of keys) if (typeof o[k] === "string") return o[k] as string
47
+ return undefined
48
+ }
49
+
50
+ /** The time of day as the CLI stamps messages (helpers.ts formatTimestamp). */
51
+ const clockTime = (ms: number) => {
52
+ try {
53
+ return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(ms))
54
+ } catch {
55
+ return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The call as Codebuff's own tool where one fits: its name and input, and its result as that tool's JSON.
61
+ * Paths inside the project become project-relative, as Codebuff's tools take them.
62
+ */
63
+ export function mapCall(call: Call, roots: string[]): { name: string; input: Json; result: (ok: boolean, out: string) => unknown; native: boolean } {
64
+ const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Json) : undefined
65
+ // The project folder as given and as resolved (/tmp and /private/tmp): paths in the session may use either.
66
+ const rel = (p: string) => {
67
+ const root = isAbsolute(p) ? roots.find((r) => !relative(r, p).startsWith("..")) : undefined
68
+ return root ? relative(root, p) || "." : p
69
+ }
70
+ if (input) {
71
+ const command = pick(input, ["command", "cmd"]) ?? (Array.isArray(input.command) ? (input.command as unknown[]).map(String).join(" ") : undefined)
72
+ if (call.toolKind === "shell" && command)
73
+ // A failure takes the exit code its output states, else 1 (the writer lists how many it guessed).
74
+ return { name: "run_terminal_command", input: { command }, result: (ok, out) => ({ command, stdout: out, exitCode: ok ? 0 : (exitCodeOf(out) ?? 1) }), native: true }
75
+ const raw = pick(input, ["file_path", "path", "filePath", "absolute_path", "target_file", "file"])
76
+ if (raw) {
77
+ const path = rel(raw)
78
+ if (call.toolKind === "read") return { name: "read_files", input: { paths: [path] }, result: (ok, out) => (ok ? [{ path, content: out }] : { errorMessage: out }), native: true }
79
+ const fileResult = (ok: boolean, out: string) => (ok ? { file: path, message: out } : { file: path, errorMessage: out })
80
+ const content = pick(input, ["content", "file_text", "contents"])
81
+ if (call.toolKind === "write" && content !== undefined) return { name: "write_file", input: { path, content }, result: fileResult, native: true }
82
+ const oldString = pick(input, ["old_string", "old_str", "oldText", "old_text", "oldString"])
83
+ const newString = pick(input, ["new_string", "new_str", "newText", "new_text", "newString"])
84
+ if (call.toolKind === "edit" && oldString !== undefined && newString !== undefined)
85
+ return { name: "str_replace", input: { path, replacements: [{ oldString, newString, allowMultiple: input.replace_all === true }] }, result: fileResult, native: true }
86
+ }
87
+ }
88
+ // Names the model APIs accept: letters, digits, _ and -, at most 64.
89
+ const name = call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
90
+ return { name, input: input ?? { input: call.input ?? null }, result: (ok, out) => (ok ? out : { errorMessage: out }), native: false }
91
+ }
92
+
93
+ export interface ChatPlan {
94
+ runState: Json
95
+ /** chat-messages.json: what the CLI shows. */
96
+ messages: Json[]
97
+ lost: string[]
98
+ /** What a read-back of the run state must find. */
99
+ expect: { users: number; calls: number }
100
+ }
101
+
102
+ /** The files of a new Codebuff chat continuing `session`. Pure apart from reading attached images. */
103
+ export function planChat(session: Session, opts: { now: Date; cwd: string; given?: string; fileContext: Json }): ChatPlan {
104
+ const drops = new Map<string, number>()
105
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
106
+ const label = toolLabel(session.header.tool)
107
+ const results = new Map<string, Result>()
108
+ const callIds = new Set<string>()
109
+ for (const t of session.turns) {
110
+ if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
111
+ if (t.kind === "tool_call") callIds.add(t.id)
112
+ }
113
+ // The model's history starts at the last compaction; the screen keeps everything.
114
+ let start = -1
115
+ session.turns.forEach((t, i) => {
116
+ if (t.kind === "compaction") start = i
117
+ })
118
+
119
+ // Times that never go backwards: the turn's own where it has one.
120
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
121
+ let clock = Math.floor(known ?? opts.now.getTime()) - 1
122
+ const tick = (at?: string) => {
123
+ const t = at ? Date.parse(at) : Number.NaN
124
+ clock = Number.isFinite(t) && t > clock ? Math.floor(t) : clock + 1
125
+ return clock
126
+ }
127
+
128
+ const ids = new Map<string, string>()
129
+ const usedIds = new Set<string>()
130
+ let remapped = 0
131
+ const toolCallId = (id: string) => {
132
+ let v = ids.get(id)
133
+ if (v) return v
134
+ const base = /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${id.replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
135
+ v = base
136
+ for (let n = 2; usedIds.has(v); n++) v = `${base}_${n}`
137
+ ids.set(id, v)
138
+ usedIds.add(v)
139
+ if (v !== id) remapped++
140
+ return v
141
+ }
142
+
143
+ const history: Json[] = []
144
+ const screen: Json[] = []
145
+ const expect = { users: 0, calls: 0 }
146
+ let renamed = 0
147
+ let thinking = 0
148
+ let ai: { at: number; blocks: Json[] } | undefined
149
+ const aiMessage = (at?: string) => (ai ??= { at: tick(at), blocks: [] })
150
+ const flushAi = () => {
151
+ if (ai?.blocks.length) screen.push({ id: `ai-${ai.at}-${randomBytes(7).toString("hex").slice(0, 13)}`, variant: "ai", content: "", blocks: ai.blocks, timestamp: clockTime(ai.at), metadata: {}, isComplete: true })
152
+ ai = undefined
153
+ }
154
+ const notice = (text: string, at?: string) => {
155
+ const ms = tick(at)
156
+ screen.push({ id: `sys-${ms}`, variant: "ai", content: text, timestamp: clockTime(ms) })
157
+ }
158
+
159
+ // Calls since the last result; their results are written together right after them, as the runtime does.
160
+ let batch: Array<{ call: Call; id: string; mapped: ReturnType<typeof mapCall>; block: Json; model: boolean }> = []
161
+ const flushCalls = () => {
162
+ for (const b of batch) {
163
+ const r = results.get(b.call.id)
164
+ let out = r?.output ?? "No result was recorded for this call; the session moved on before it finished."
165
+ if (r?.fullPath) out += `\n\n[Full output: ${r.fullPath}]`
166
+ if (r && !out.trim()) out = "(no output)"
167
+ if (!r) drop("tool calls without a result (closed as errors)")
168
+ else if (!r.ok && b.mapped.name === "run_terminal_command" && exitCodeOf(out) === undefined) drop("exit codes of failed shell commands, recorded as 1")
169
+ b.block.output = out
170
+ if (b.model) history.push({ role: "tool", toolName: b.mapped.name, toolCallId: b.id, content: [{ type: "json", value: b.mapped.result(!!r?.ok, out) }] })
171
+ }
172
+ batch = []
173
+ }
174
+
175
+ history.push({ role: "user", content: [{ type: "text", text: withSystemTags(continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))) }], sentAt: tick() })
176
+ notice(`Continued from ${label} session ${session.header.sourceId}${session.header.title ? ` (${session.header.title})` : ""}.`)
177
+
178
+ const seen = new Set<string>()
179
+ session.turns.forEach((t, i) => {
180
+ const model = i > start
181
+ switch (t.kind) {
182
+ case "user": {
183
+ flushCalls()
184
+ flushAi()
185
+ const content: Json[] = [{ type: "text", text: t.text }]
186
+ const attachments: Json[] = []
187
+ let body = t.text
188
+ for (const a of t.attachments ?? []) {
189
+ if (!a.path) continue
190
+ if (a.kind === "file") {
191
+ body += `\n\n[Attached file: ${a.path}]`
192
+ continue
193
+ }
194
+ const mediaType = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
195
+ let size = Number.POSITIVE_INFINITY
196
+ try {
197
+ size = statSync(a.path).size
198
+ } catch {}
199
+ if (mediaType && Object.values(IMAGE_TYPES).includes(mediaType) && size <= IMAGE_LIMIT) {
200
+ content.push({ type: "image", image: readFileSync(a.path).toString("base64"), mediaType })
201
+ attachments.push({ filename: basename(a.path), path: a.path, size })
202
+ } else drop("images that could not be attached")
203
+ }
204
+ content[0] = { type: "text", text: body }
205
+ if (!body.trim() && content.length === 1) break
206
+ const ms = tick(t.at)
207
+ screen.push({ id: `user-${ms}`, variant: "user", content: body, timestamp: clockTime(ms), ...(attachments.length ? { attachments } : {}) })
208
+ if (model) {
209
+ history.push({ role: "user", content, sentAt: ms, tags: ["USER_PROMPT"], keepDuringTruncation: true })
210
+ expect.users++
211
+ }
212
+ break
213
+ }
214
+ case "assistant":
215
+ flushCalls()
216
+ if (!t.text.trim()) break
217
+ aiMessage(t.at).blocks.push({ type: "text", content: t.text, textType: "text" })
218
+ if (model) history.push({ role: "assistant", content: [{ type: "text", text: t.text }], sentAt: tick(t.at) })
219
+ break
220
+ case "reasoning":
221
+ flushCalls()
222
+ if (!t.text.trim()) break
223
+ aiMessage(t.at).blocks.push({ type: "text", content: t.text, textType: "reasoning", thinkingCollapseState: "preview", thinkingId: `thinking-${++thinking}`, thinkingOpen: false })
224
+ if (model) history.push({ role: "assistant", content: [{ type: "reasoning", text: t.text }], sentAt: tick(t.at) })
225
+ break
226
+ case "tool_call": {
227
+ if (seen.has(t.id)) break
228
+ seen.add(t.id)
229
+ const mapped = mapCall(t, [opts.cwd, ...(opts.given && opts.given !== opts.cwd ? [opts.given] : [])])
230
+ if (!mapped.native && mapped.name !== t.name) renamed++
231
+ const id = toolCallId(t.id)
232
+ const block: Json = { type: "tool", toolCallId: id, toolName: mapped.name, input: mapped.input }
233
+ aiMessage(t.at).blocks.push(block)
234
+ if (model) {
235
+ history.push({ role: "assistant", content: [{ toolCallId: id, toolName: mapped.name, input: mapped.input, type: "tool-call" }], sentAt: tick(t.at) })
236
+ expect.calls++
237
+ }
238
+ batch.push({ call: t, id, mapped, block, model })
239
+ break
240
+ }
241
+ case "tool_result":
242
+ if (!callIds.has(t.callId)) drop("tool results without a call")
243
+ else if (batch.some((b) => b.call.id === t.callId)) flushCalls()
244
+ break
245
+ case "compaction":
246
+ flushCalls()
247
+ flushAi()
248
+ notice("Earlier messages were summarized; the model continues from the summary.", t.at)
249
+ if (i === start) history.push({ role: "user", content: [{ type: "text", text: summaryMessageText(t.summary) }], sentAt: tick(t.at) })
250
+ break
251
+ case "file_edit":
252
+ if (t.callId && callIds.has(t.callId)) break
253
+ flushCalls()
254
+ aiMessage(t.at).blocks.push({ type: "text", content: `[Edited ${t.path}]${t.patch ? `\n\n\`\`\`diff\n${t.patch}\n\`\`\`` : ""}`, textType: "text" })
255
+ if (model) history.push({ role: "assistant", content: [{ type: "text", text: `[Edited ${t.path}]${t.patch ? `\n\n\`\`\`diff\n${t.patch}\n\`\`\`` : ""}` }], sentAt: tick(t.at) })
256
+ drop("file edits outside a tool call, kept as text")
257
+ break
258
+ case "subagent":
259
+ drop("subagent sessions (their results stay in the parent's tool results)")
260
+ break
261
+ }
262
+ })
263
+ flushCalls()
264
+ flushAi()
265
+ if (!screen.some((m) => m.variant === "user")) throw new Error("nothing Codebuff can show: the session has no user messages")
266
+
267
+ // A finished run's output: the messages after the last prompt (output mode last_message).
268
+ let lastPrompt = -1
269
+ history.forEach((m, i) => {
270
+ if (m.role === "user" && (m.tags as string[] | undefined)?.includes("USER_PROMPT")) lastPrompt = i
271
+ })
272
+ const runState = {
273
+ sessionState: {
274
+ mainAgentState: {
275
+ agentId: "main-agent",
276
+ agentType: null,
277
+ agentContext: {},
278
+ ancestorRunIds: [],
279
+ subagents: [],
280
+ childRunIds: [],
281
+ messageHistory: history,
282
+ stepsRemaining: MAX_AGENT_STEPS,
283
+ creditsUsed: 0,
284
+ directCreditsUsed: 0,
285
+ systemPrompt: "",
286
+ toolDefinitions: {},
287
+ contextTokenCount: 0,
288
+ },
289
+ fileContext: opts.fileContext,
290
+ },
291
+ output: { type: "lastMessage", value: history.slice(lastPrompt + 1) },
292
+ traceSessionId: randomUUID(),
293
+ inference: { source: "codebuff" },
294
+ }
295
+
296
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
297
+ if (remapped) lost.push(`tool call ids, remapped to ids model APIs accept (${remapped})`)
298
+ if (renamed) lost.push(`tool names changed to fit model APIs (${renamed})`)
299
+ lost.push("Freebuff's skill list and symbol index in this chat's project snapshot (the skill and search tools still read the disk)")
300
+ return { runState, messages: screen, lost, expect }
301
+ }
302
+
303
+ /** chat-meta.json for `messages`, bound to the messages file as written (chat-meta.ts writeChatMeta). */
304
+ function chatMeta(dir: string, messages: Json[]) {
305
+ const first = messages.find((m) => m.variant === "user" && m.content)
306
+ const text = typeof first?.content === "string" ? first.content.trim() : ""
307
+ const st = statSync(join(dir, CHAT_MESSAGES))
308
+ return { messageCount: messages.length, firstPrompt: text ? (text.length > 100 ? `${text.slice(0, 97)}...` : text) : "(empty chat)", messagesSize: st.size, messagesMtimeMs: st.mtimeMs }
309
+ }
310
+
311
+ /** As write-file-atomic.ts: a private temp file renamed over the target. */
312
+ function writeAtomic(path: string, text: string) {
313
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
314
+ writeFileSync(tmp, text, { mode: 0o600, flag: "wx" })
315
+ renameSync(tmp, path)
316
+ }
317
+
318
+ /** The resolved folder (/private/tmp, not /tmp): what process.cwd() gives the CLI there. */
319
+ function realCwd(cwd: string): string {
320
+ try {
321
+ return realpathSync(cwd)
322
+ } catch {
323
+ return cwd
324
+ }
325
+ }
326
+
327
+ /** The Codebuff writer; `bin` and `version` are for tests, which must not depend on an installed CLI. */
328
+ export function codebuffWriter(opts: { bin?: string; version?: string } = {}): Writer {
329
+ const cli = () => (opts.version ? { bin: opts.bin ?? "freebuff", version: opts.version } : installed())
330
+ return {
331
+ tool: TOOL,
332
+ label: LABEL,
333
+ way: "native",
334
+
335
+ async ready(w: WriteOptions): Promise<Readiness> {
336
+ const c = cli()
337
+ if (!c) return { ok: false, why: "neither Freebuff nor Codebuff is installed (no `freebuff` or `codebuff` npm package on PATH)" }
338
+ if (!TESTED_VERSIONS.includes(c.version)) return { ok: false, why: `${c.bin} ${c.version} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
339
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Codebuff keeps chats per project folder` }
340
+ return { ok: true }
341
+ },
342
+
343
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
344
+ const cwd = realCwd(w.cwd)
345
+ const bin = cli()?.bin ?? "freebuff"
346
+ const now = new Date()
347
+ const root = chatsDir(w.home, cwd)
348
+ let id = chatId(now)
349
+ for (let ms = now.getTime() + 1; exists(join(root, id)); ms++) id = chatId(new Date(ms))
350
+ const dir = join(root, id)
351
+ // A dry run never walks the project: the snapshot does not change which files are written.
352
+ const plan = planChat(session, { now, cwd, given: resolve(w.cwd), fileContext: w.dryRun ? {} : fileContext(cwd, w.home) })
353
+ const files = [RUN_STATE, CHAT_MESSAGES, CHAT_META].map((f) => join(dir, f))
354
+ if (!w.dryRun) {
355
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
356
+ try {
357
+ writeAtomic(files[0]!, JSON.stringify(plan.runState))
358
+ writeAtomic(files[1]!, JSON.stringify(plan.messages))
359
+ writeAtomic(files[2]!, JSON.stringify(chatMeta(dir, plan.messages)))
360
+ const back = readChat(dir, id)
361
+ const n = countTurns(back)
362
+ const pairs = unpaired(back)
363
+ if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
364
+ throw new Error(`the Codebuff chat did not read back as written (users ${n.user}/${plan.expect.users}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`)
365
+ } catch (e) {
366
+ rmSync(dir, { recursive: true, force: true })
367
+ throw e
368
+ }
369
+ }
370
+ return {
371
+ tool: TOOL,
372
+ way: "native",
373
+ sessionId: id,
374
+ written: files,
375
+ open: `cd ${shellQuote(cwd)} && ${bin} --continue ${id}`,
376
+ lost: plan.lost,
377
+ dryRun: !!w.dryRun,
378
+ }
379
+ },
380
+ }
381
+ }
382
+
383
+ export const codebuffNativeWriter = codebuffWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { codexReader } from "./reader.ts"
3
+ import { codexWriter } from "./writer.ts"
4
+
5
+ // OpenAI Codex CLI: reads rollouts (and legacy .json sessions), and continues any session as a new
6
+ // native Codex thread.
7
+ export const readers: Reader[] = [codexReader]
8
+ export const writers: Writer[] = [codexWriter]
@@ -0,0 +1,239 @@
1
+ import { createHash } from "node:crypto"
2
+ import { isContext, SUMMARY_PREFIX } from "./records.ts"
3
+
4
+ // The shapes Codex itself writes, which a converted session must match so Codex treats it as its own:
5
+ // how a compaction is recorded and rebuilt, how a shell call's output and an apply_patch are written, and
6
+ // how the model's context is sized. Checked against codex-rs at rust-v0.147.0 and against rollouts Codex wrote
7
+ // against a local stand-in server (core/src/compact.rs, rollout_reconstruction.rs, unified_exec, apply_patch).
8
+
9
+ /** Codex keeps this many tokens of earlier user messages across a compaction (compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS). */
10
+ export const COMPACT_USER_BUDGET = 20_000
11
+
12
+ const bytesOf = (s: string) => Buffer.byteLength(s)
13
+
14
+ /** Codex's rough token count of a text: about four bytes a token, rounded up (approx_token_count). */
15
+ export const approxTokens = (text: string) => (bytesOf(text) + 3) >> 2
16
+
17
+ /** Codex's estimate of one history item: its serialized JSON over four, rounded up (history.rs estimate_item_token_count). */
18
+ export const itemTokens = (item: unknown) => Math.ceil(bytesOf(JSON.stringify(item) ?? "") / 4)
19
+
20
+ /** The text cut to about `tokens` tokens, on a character boundary, with a note of what was left out. */
21
+ export function cutToTokens(text: string, tokens: number): string {
22
+ const max = Math.max(0, tokens) * 4
23
+ const buf = Buffer.from(text)
24
+ if (buf.length <= max) return text
25
+ let end = max
26
+ while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--
27
+ return `${buf.subarray(0, end).toString("utf8")}…[${approxTokens(text) - (end >> 2)} tokens truncated]…`
28
+ }
29
+
30
+ /** A compaction's summary as Codex records and replays it: its prefix, a newline, then the summary. */
31
+ export const compactedMessage = (summary: string) => `${SUMMARY_PREFIX}\n${summary.trim() ? summary : "(no summary available)"}`
32
+
33
+ /** Whether a user message is a compaction summary Codex wrote (or this writer did): Codex never keeps those as user prompts. */
34
+ export const isSummaryMessage = (text: string) => text.startsWith(`${SUMMARY_PREFIX}\n`)
35
+
36
+ /** The summary's own text, without Codex's prefix. */
37
+ export const summaryBody = (message: string) => (isSummaryMessage(message) ? message.slice(SUMMARY_PREFIX.length + 1) : message)
38
+
39
+ const userItem = (text: string) => ({ type: "message", role: "user", content: [{ type: "input_text", text }] })
40
+
41
+ /**
42
+ * The history Codex rebuilds at a compaction (build_compacted_history): the person's earlier messages, newest first
43
+ * until the budget is used, in their original order, then the summary. Context Codex injects and earlier summaries
44
+ * are not kept. A rollout whose compacted record lacks this makes Codex fall back to its legacy replay, which adds the
45
+ * developer message and environment again on every resume.
46
+ */
47
+ export function replacementHistory(userTexts: string[], message: string): Array<ReturnType<typeof userItem>> {
48
+ const kept: string[] = []
49
+ let left = COMPACT_USER_BUDGET
50
+ for (let i = userTexts.length - 1; i >= 0; i--) {
51
+ const text = userTexts[i]!
52
+ const n = approxTokens(text)
53
+ if (n <= left) {
54
+ kept.push(text)
55
+ left -= n
56
+ } else {
57
+ if (left > 0) kept.push(cutToTokens(text, left))
58
+ break
59
+ }
60
+ }
61
+ kept.reverse()
62
+ return [...kept, message].map(userItem)
63
+ }
64
+
65
+ /** The texts of the person's messages in a history: user-role items that are neither Codex's context nor a summary. */
66
+ export function userTextsOf(history: Array<Record<string, any>>): string[] {
67
+ const out: string[] = []
68
+ for (const h of history) {
69
+ if (h.type !== "message" || h.role !== "user" || !Array.isArray(h.content)) continue
70
+ const text = h.content.map((c: any) => (typeof c?.text === "string" ? c.text : "")).join("")
71
+ if (text && !isContext(text) && !isSummaryMessage(text)) out.push(text)
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** A shell call's output as Codex's unified exec writes it, which is also where a failed command's exit code lives. */
77
+ export function execOutput(text: string, ok: boolean): string {
78
+ const body = text.replace(/^Exit code -?\d+\n?/, "")
79
+ const code = ok ? 0 : Number(text.match(/^Exit code (-?\d+)/)?.[1] ?? 1) || 1
80
+ const chunk = createHash("sha1").update(body).digest("hex").slice(0, 6)
81
+ return `Chunk ID: ${chunk}\nWall time: 0.0000 seconds\nProcess exited with code ${code}\nOriginal token count: ${approxTokens(body)}\nOutput:\n${body}`
82
+ }
83
+
84
+ export interface PatchFile {
85
+ /** Path as the source tool gave it. */
86
+ path: string
87
+ /** A unified diff: `--- a/…`, `+++ b/…`, then hunks. */
88
+ patch: string
89
+ }
90
+
91
+ export interface CodexPatch {
92
+ /** The patch in Codex's apply_patch grammar. */
93
+ text: string
94
+ /** What Codex shows for it (patch_apply_end.changes), keyed by absolute path. */
95
+ changes: Record<string, { type: "add"; content: string } | { type: "update"; unified_diff: string; move_path: null }>
96
+ /** "M path" / "A path" lines, as apply_patch reports them. */
97
+ summary: string[]
98
+ }
99
+
100
+ /**
101
+ * Unified diffs of the files one call changed, as Codex's apply_patch text and the display record Codex keeps for it.
102
+ * Undefined when a diff was cut short or does not parse, so the call stays as it was written.
103
+ */
104
+ export function codexPatch(files: PatchFile[], cwd: string): CodexPatch | undefined {
105
+ if (!files.length) return undefined
106
+ const sections: string[] = []
107
+ const changes: CodexPatch["changes"] = {}
108
+ const summary: string[] = []
109
+ for (const f of files) {
110
+ if (/\[\.\.\. \d+ characters cut \.\.\.\]/.test(f.patch)) return undefined
111
+ const lines = f.patch.split("\n")
112
+ const first = lines.findIndex((l) => l.startsWith("@@"))
113
+ if (first < 0) return undefined
114
+ const abs = f.path.startsWith("/") ? f.path : `${cwd}/${f.path}`
115
+ const rel = abs.startsWith(`${cwd}/`) ? abs.slice(cwd.length + 1) : abs
116
+ const hunks: Array<{ header: string; body: string[] }> = []
117
+ for (const l of lines.slice(first)) {
118
+ if (l.startsWith("@@")) hunks.push({ header: l, body: [] })
119
+ else if (hunks.length && (l[0] === " " || l[0] === "+" || l[0] === "-")) hunks.at(-1)!.body.push(l)
120
+ else if (hunks.length && l === "") hunks.at(-1)!.body.push(" ")
121
+ }
122
+ if (!hunks.length) return undefined
123
+ const created = hunks.length === 1 && /^@@ -0,0 /.test(hunks[0]!.header)
124
+ if (created) {
125
+ const content = `${hunks[0]!.body.map((l) => l.slice(1)).join("\n")}\n`
126
+ sections.push(`*** Add File: ${rel}\n${hunks[0]!.body.join("\n")}`)
127
+ changes[abs] = { type: "add", content }
128
+ summary.push(`A ${rel}`)
129
+ } else {
130
+ sections.push(`*** Update File: ${rel}\n${hunks.map((h) => `@@\n${h.body.join("\n")}`).join("\n")}`)
131
+ changes[abs] = { type: "update", unified_diff: `${hunks.map((h) => `${h.header}\n${h.body.join("\n")}`).join("\n")}\n`, move_path: null }
132
+ summary.push(`M ${rel}`)
133
+ }
134
+ }
135
+ return { text: `*** Begin Patch\n${sections.join("\n")}\n*** End Patch`, changes, summary }
136
+ }
137
+
138
+ /**
139
+ * The display record of one edit that cannot be written as an apply_patch (its diff was cut short, or the call is not
140
+ * one Codex could have made): a new file as its content, anything else as the hunks it has. Undefined when there is none.
141
+ */
142
+ export function displayChange(patch: string, cwd: string, path: string): { path: string; change: CodexPatch["changes"][string] } | undefined {
143
+ const lines = patch.split("\n")
144
+ const first = lines.findIndex((l) => l.startsWith("@@"))
145
+ if (first < 0) return undefined
146
+ const abs = path.startsWith("/") ? path : `${cwd}/${path}`
147
+ if (/^@@ -0,0 /.test(lines[first]!)) {
148
+ const body = lines.slice(first + 1).filter((l) => l.startsWith("+")).map((l) => l.slice(1))
149
+ return { path: abs, change: { type: "add", content: `${body.join("\n")}\n` } }
150
+ }
151
+ return { path: abs, change: { type: "update", unified_diff: `${lines.slice(first).join("\n").replace(/\n$/, "")}\n`, move_path: null } }
152
+ }
153
+
154
+ /** The command Codex's exec_command runs for an apply_patch it intercepts. */
155
+ export const patchCommand = (patch: string) => `apply_patch <<'EOF'\n${patch}\nEOF\n`
156
+
157
+ /** The output of that command, and what apply_patch printed. */
158
+ export const patchStdout = (summary: string[]) => `Success. Updated the following files:\n${summary.join("\n")}\n`
159
+ export const patchOutput = (summary: string[]) => `Wall time: 0.0000 seconds\nOutput:\nExit code: 0\nWall time: 0.1 seconds\nOutput:\n${patchStdout(summary)}`
160
+
161
+ /** `mcp__server__tool` as Codex's MCP event names it: the server and the tool. */
162
+ export function mcpParts(name: string): { server: string; tool: string } | undefined {
163
+ const m = name.match(/^mcp__(.+?)__(.+)$/)
164
+ return m ? { server: m[1]!, tool: m[2]! } : undefined
165
+ }
166
+
167
+ /**
168
+ * A one-line title for a thread: the session's own title, or its first request, without the tags Claude Code wraps
169
+ * around pasted text or injects into messages, cut to about 60 characters at a word.
170
+ */
171
+ export function plainTitle(text: string): string {
172
+ const t = text
173
+ .replace(/<(system-reminder|task-notification|ci-monitor-event|command-name|command-message|command-args|local-command-[a-z-]+)\b[^>]*>[\s\S]*?<\/\1>/g, " ")
174
+ .replace(/<\/?pasted_content\b[^>]*>/g, " ")
175
+ .replace(/\s+/g, " ")
176
+ .trim()
177
+ if (t.length <= 60) return t || "Session"
178
+ const cut = t.slice(0, 59)
179
+ const space = cut.lastIndexOf(" ")
180
+ return `${cut.slice(0, space > 30 ? space : 59)}…`
181
+ }
182
+
183
+ /** What registering a thread in Codex's own index came to; `named` says whether its title was set too. */
184
+ export type Registration = { ok: true; named?: boolean } | { ok: false; why: string }
185
+
186
+ /**
187
+ * Has Codex index a rollout it did not write, through its own read-repair: `thread/read` on its app server finds the
188
+ * file and adds the thread to state_5.sqlite, as it does for any rollout it meets. No model is called. Without it
189
+ * the thread is missing from the `codex resume` picker and `--last` resumes another thread instead.
190
+ */
191
+ export async function registerThread(codexHome: string, threadId: string, cwd: string, name?: string, timeoutMs = 20_000): Promise<Registration> {
192
+ let proc: ReturnType<typeof Bun.spawn> | undefined
193
+ try {
194
+ proc = Bun.spawn(["codex", "app-server"], { cwd, env: { ...process.env, CODEX_HOME: codexHome }, stdin: "pipe", stdout: "pipe", stderr: "ignore" })
195
+ const send = (m: unknown) => (proc!.stdin as { write(s: string): void; flush(): void }).write(`${JSON.stringify(m)}\n`)
196
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
197
+ const decoder = new TextDecoder()
198
+ let buffer = ""
199
+ const response = async (id: number): Promise<any> => {
200
+ for (;;) {
201
+ const nl = buffer.indexOf("\n")
202
+ if (nl >= 0) {
203
+ const line = buffer.slice(0, nl)
204
+ buffer = buffer.slice(nl + 1)
205
+ try {
206
+ const msg = JSON.parse(line)
207
+ if (msg.id === id) return msg
208
+ } catch {}
209
+ continue
210
+ }
211
+ const { value, done } = await reader.read()
212
+ if (done) throw new Error("Codex's app server closed before it answered")
213
+ buffer += decoder.decode(value, { stream: true })
214
+ }
215
+ }
216
+ const run = (async () => {
217
+ send({ id: 1, method: "initialize", params: { clientInfo: { name: "brain", title: "brain", version: "0.1.0" }, capabilities: { experimentalApi: true } } })
218
+ const init = await response(1)
219
+ if (init.error) throw new Error(`initialize: ${init.error.message ?? "refused"}`)
220
+ send({ method: "initialized" })
221
+ send({ id: 2, method: "thread/read", params: { threadId, includeTurns: false } })
222
+ const read = await response(2)
223
+ if (read.error) throw new Error(`thread/read: ${read.error.message ?? "refused"}`)
224
+ if (!name) return undefined
225
+ // Codex's own way to title a thread (what /rename does), rather than leaving it to the first message's text.
226
+ send({ id: 3, method: "thread/name/set", params: { threadId, name } })
227
+ const named = await response(3)
228
+ return !named.error
229
+ })()
230
+ const named = await Promise.race([run, new Promise<never>((_, no) => setTimeout(() => no(new Error("timed out")), timeoutMs))])
231
+ return { ok: true, ...(named === undefined ? {} : { named }) }
232
+ } catch (e) {
233
+ return { ok: false, why: e instanceof Error ? e.message : String(e) }
234
+ } finally {
235
+ try {
236
+ proc?.kill()
237
+ } catch {}
238
+ }
239
+ }