mini-coder 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ createProvider,
6
+ envApiKeyAuth,
7
+ Type,
8
+ type Api,
9
+ type Model,
10
+ type MutableModels,
11
+ type Static,
12
+ } from "@earendil-works/pi-ai";
13
+ import { Value } from "typebox/value";
14
+ import { builtinModels } from "@earendil-works/pi-ai/providers/all";
15
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
16
+ import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
17
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
18
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
19
+
20
+ const ToolNameSchema = Type.Union([Type.Literal("edit"), Type.Literal("read"), Type.Literal("bash")]);
21
+ export type ToolName = Static<typeof ToolNameSchema>;
22
+
23
+ const CustomApiSchema = Type.Union([
24
+ Type.Literal("openai-completions"),
25
+ Type.Literal("openai-responses"),
26
+ Type.Literal("anthropic-messages"),
27
+ Type.Literal("google-generative-ai"),
28
+ ]);
29
+ type CustomApi = Static<typeof CustomApiSchema>;
30
+
31
+ const CustomProviderSchema = Type.Object(
32
+ {
33
+ id: Type.String({ minLength: 1 }),
34
+ name: Type.Optional(Type.String()),
35
+ baseUrl: Type.String({ minLength: 1 }),
36
+ api: CustomApiSchema,
37
+ models: Type.Array(Type.String(), { minItems: 1 }),
38
+ envKeys: Type.Optional(Type.Array(Type.String())),
39
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
40
+ },
41
+ { additionalProperties: false },
42
+ );
43
+
44
+ const ConfigSchema = Type.Object(
45
+ {
46
+ sessionsDir: Type.String({ default: join(process.cwd(), "sessions") }),
47
+ systemPrompt: Type.String({ default: "" }),
48
+ discoverAgentFiles: Type.Boolean({ default: true }),
49
+ skillsDirs: Type.Array(Type.String(), { default: [] }),
50
+ tools: Type.Array(ToolNameSchema, { default: ["edit", "read", "bash"] }),
51
+ provider: Type.String(),
52
+ model: Type.String(),
53
+ thinkingEffort: Type.Union(
54
+ [
55
+ Type.Literal("minimal"),
56
+ Type.Literal("low"),
57
+ Type.Literal("medium"),
58
+ Type.Literal("high"),
59
+ Type.Literal("xhigh"),
60
+ Type.Literal("max"),
61
+ ],
62
+ { default: "medium" },
63
+ ),
64
+ customProviders: Type.Array(CustomProviderSchema, { default: [] }),
65
+ },
66
+ { additionalProperties: false },
67
+ );
68
+ export type Config = Static<typeof ConfigSchema>;
69
+
70
+ function configPath(): string {
71
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
72
+ return join(base, "mini-coder", "config.json");
73
+ }
74
+
75
+ function readJson(path: string): unknown {
76
+ let text: string;
77
+ try {
78
+ text = readFileSync(path, "utf8");
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
81
+ throw new Error(`config ${path}: ${(error as Error).message}`);
82
+ }
83
+ try {
84
+ return JSON.parse(text);
85
+ } catch (error) {
86
+ throw new Error(`config ${path}: ${(error as Error).message}`);
87
+ }
88
+ }
89
+
90
+ export function loadConfig(): Config {
91
+ const path = configPath();
92
+ const filled = Value.Default(ConfigSchema, readJson(path));
93
+ try {
94
+ return Value.Parse(ConfigSchema, filled);
95
+ } catch {
96
+ const first = [...Value.Errors(ConfigSchema, filled)][0];
97
+ throw new Error(`config ${path}: ${first ? `${first.instancePath || "/"} ${first.message}` : "invalid"}`);
98
+ }
99
+ }
100
+
101
+ const API_FACTORY: Record<CustomApi, () => ReturnType<typeof openAICompletionsApi>> = {
102
+ "openai-completions": openAICompletionsApi,
103
+ "openai-responses": openAIResponsesApi,
104
+ "anthropic-messages": anthropicMessagesApi,
105
+ "google-generative-ai": googleGenerativeAIApi,
106
+ };
107
+
108
+ export function resolveModel(config: Config): { models: MutableModels; model: Model<Api> } {
109
+ const models = builtinModels();
110
+ for (const provider of config.customProviders) {
111
+ const name = provider.name ?? provider.id;
112
+ models.setProvider(
113
+ createProvider({
114
+ id: provider.id,
115
+ name,
116
+ baseUrl: provider.baseUrl,
117
+ headers: provider.headers,
118
+ auth: {
119
+ apiKey: provider.envKeys?.length
120
+ ? envApiKeyAuth(name, provider.envKeys)
121
+ : { name, resolve: async () => ({ auth: { apiKey: "unused" } }) },
122
+ },
123
+ api: API_FACTORY[provider.api](),
124
+ models: provider.models.map((id) => ({
125
+ id,
126
+ name: id,
127
+ api: provider.api,
128
+ provider: provider.id,
129
+ baseUrl: provider.baseUrl,
130
+ reasoning: false,
131
+ input: ["text"] satisfies ("text" | "image")[],
132
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
133
+ contextWindow: 200_000,
134
+ maxTokens: 32_768,
135
+ })),
136
+ }),
137
+ );
138
+ }
139
+
140
+ const model = models.getModel(config.provider, config.model);
141
+ if (!model) {
142
+ const known = models
143
+ .getModels(config.provider)
144
+ .map((m) => m.id)
145
+ .slice(0, 12);
146
+ const hint = known.length ? ` (available: ${known.join(", ")})` : "";
147
+ throw new Error(`config model: unknown model "${config.model}" for provider "${config.provider}"${hint}`);
148
+ }
149
+ return { models, model };
150
+ }
package/src/prompt.ts CHANGED
@@ -1,232 +1,79 @@
1
- import { promises } from "node:fs";
2
- import { readdir } from "node:fs/promises";
3
- import { homedir, platform } from "node:os";
4
- import { join } from "node:path";
5
- import type { Message, ToolResultMessage } from "@earendil-works/pi-ai";
6
- import { getGitStatus } from "./git";
7
- import { parseSkillFrontmatter } from "./shared";
8
-
9
- export const MAIN_PROMPT = `You are a coding agent interacting with users via the mini-coder harness. You help users by reading files, executing commands, and editting code.
10
-
11
- - Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without unnecessary superlatives, praise, or emotional validation.
12
- - User messages and Tool results may include <system-reminder> tags. These contain system-generated reminders and bear no direct relation to the specific tool result in which they appear.
13
- - You have access to bash, read and edit tools. Prefer using read and edit for file operations, use bash for finding read candidates or to run development commands.
14
- - Use recent online information, the current environment, and your training data combined for a complete answer.
15
- - Ensure that you fulfill the user's expectation, requirements and contract **exactly**.
16
- - Do not overstate what changed or what was verified. Summaries must match the diff.
17
- - Use temp directory for temp files, scripts, plan files, or anything that doesn't match the requested output.
18
- - Be concise. Use a professional colleague tone: direct, never condescending, and never rude.
19
- `;
20
-
21
- async function getDir() {
22
- const ignoreFile = Bun.file(".gitignore");
23
- let ignoreContent = "";
24
- if (await ignoreFile.exists()) {
25
- ignoreContent = await ignoreFile.text();
26
- }
27
- const ignored = ignoreContent.split("\n");
28
- const dir = [];
29
- const glob = promises.glob(["*", "*/*"], { exclude: ignored });
30
- for await (const file of glob) {
31
- dir.push(file);
32
- }
33
- return dir;
34
- }
35
-
36
- async function getEnvPrompt() {
37
- // TODO: What else do the agents always check before answering every time?
38
- const gitStatus = await getGitStatus();
39
- const envKeys = ["PATH", "USER", "LANG", "HOME", "SHELL", "BUN_INSTALL"];
40
- const env: Record<string, string> = {};
41
- for (const key of envKeys) {
42
- const v = Bun.env[key];
43
-
44
- if (v !== undefined) {
45
- env[key] = v;
46
- }
47
- }
48
-
49
- const envStatus = JSON.stringify(
50
- {
51
- os: platform(),
52
- env,
53
- cwd: process.cwd(),
54
- dir: await getDir(),
55
- git: gitStatus,
56
- },
57
- null,
58
- 4,
59
- );
60
-
61
- const text = `### Environment status and information
62
-
63
- \`\`\`json
64
- ${envStatus}
65
- \`\`\`
66
- `;
67
-
68
- return text;
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { Config } from "./config.ts";
5
+
6
+ interface Skill {
7
+ name: string;
8
+ description: string;
9
+ path: string;
69
10
  }
70
11
 
71
- // `AGENTS.md` support: find it in current folder (./AGENTS.md) and a global one. (`.agents/AGENTS.md`)
72
- export async function getAGENTSFiles() {
73
- const content: string[] = [];
74
-
75
- const globalPath = join(homedir(), ".agents/AGENTS.md");
76
- const globalFile = Bun.file(globalPath);
77
-
78
- if (await globalFile.exists()) {
79
- content.push(await globalFile.text());
80
- }
81
-
82
- const localPath = join(process.cwd(), "AGENTS.md");
83
- const localFile = Bun.file(localPath);
84
-
85
- if (await localFile.exists()) {
86
- content.push(await localFile.text());
12
+ function frontmatter(text: string): { name?: string; description?: string } {
13
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
14
+ if (!match) return {};
15
+ const out: { name?: string; description?: string } = {};
16
+ for (const line of match[1].split(/\r?\n/)) {
17
+ const separator = line.indexOf(":");
18
+ if (separator < 0) continue;
19
+ const key = line.slice(0, separator).trim();
20
+ const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, "");
21
+ if (key === "name") out.name = value;
22
+ if (key === "description") out.description = value;
87
23
  }
88
-
89
- return content.join("\n\n").trim();
24
+ return out;
90
25
  }
91
26
 
92
- // `SKILLS.md` discovery from [~|.]/agents/skills/*/SKILL.md
93
- export async function getSkills(): Promise<string> {
94
- let skillsBlock: string = "";
95
- const skillRoots = [
96
- join(homedir(), ".agents", "skills"),
97
- join(process.cwd(), ".agents", "skills"),
98
- ];
99
-
100
- for (const root of skillRoots) {
27
+ function discoverSkills(dirs: string[]): Skill[] {
28
+ const skills: Skill[] = [];
29
+ for (const root of dirs) {
101
30
  let entries: string[];
102
-
103
31
  try {
104
- entries = await readdir(root);
32
+ entries = readdirSync(root);
105
33
  } catch {
106
34
  continue;
107
35
  }
108
-
109
36
  for (const entry of entries) {
110
- const path = join(root, entry, "SKILL.md");
111
- const file = Bun.file(path);
112
-
113
- if (!(await file.exists())) {
114
- continue;
115
- }
116
-
117
- const parsed = parseSkillFrontmatter(await file.text());
118
-
119
- if (!parsed) {
120
- continue;
121
- }
122
-
123
- skillsBlock += `## ${parsed.name}
124
-
125
- > Absolute file path to read: ${path}
126
-
127
- ${parsed.description}
128
-
129
- `;
37
+ const path = resolve(root, entry, "SKILL.md");
38
+ if (!existsSync(path)) continue;
39
+ const meta = frontmatter(readFileSync(path, "utf8"));
40
+ if (!meta.name) continue;
41
+ skills.push({ name: meta.name, description: meta.description ?? "", path });
130
42
  }
131
43
  }
132
-
133
- if (!skillsBlock.length) return "";
134
-
135
- const skills = `# Skills
136
-
137
- - The following skills provide specialized instructions for specific tasks.
138
- - Use the bash tool to read a skill's file when the task matches its description.
139
- - Use the skill provided absolute file path instead of guessing or constructing one.
140
- - Skills can be global (in ~/.agents/skills) or local to the directory (./agents/skills)
141
-
142
- ${skillsBlock}`;
143
-
144
- return skills.trim();
44
+ return skills;
145
45
  }
146
46
 
147
- export async function buildSystemPrompt(systemPrompt: string) {
148
- const agentsContent = await getAGENTSFiles();
149
- const skillsContent = await getSkills();
150
- let complete = systemPrompt;
151
-
152
- if (skillsContent) {
153
- complete += `\n${skillsContent}`;
154
- }
155
-
156
- if (agentsContent) {
157
- complete += `\n${agentsContent}`;
47
+ function agentFiles(): { path: string; text: string }[] {
48
+ const roots = [join(homedir(), ".agents"), process.cwd()];
49
+ const found: { path: string; text: string }[] = [];
50
+ for (const root of roots) {
51
+ for (const name of ["AGENTS.md", "CLAUDE.md"]) {
52
+ const path = join(root, name);
53
+ if (existsSync(path)) found.push({ path, text: readFileSync(path, "utf8").trim() });
54
+ }
158
55
  }
159
-
160
- return complete;
161
- }
162
-
163
- export async function injectEnvReminder(): Promise<string> {
164
- const envStatus = await getEnvPrompt();
165
- return `<system-reminder>\n${envStatus}\n</system-reminder>`;
56
+ return found;
166
57
  }
167
58
 
168
- const doomLoopReminder = `<system-reminder>
169
- You may be entering a tool-call doom loop: recent tool usage is repetitive or not clearly progressing.
59
+ export function buildSystemPrompt(config: Config): string {
60
+ const sections: string[] = [];
61
+ if (config.systemPrompt.trim()) sections.push(config.systemPrompt.trim());
170
62
 
171
- - Stop repeating the same or similar tool call unless new evidence requires it.
172
- - Re-read the user's request and summarize what is known, what failed, and what is still needed.
173
- - Change strategy before calling more tools: narrow the next check, use a different source of evidence, or ask the user if blocked.
174
- - If the request is complete, stop calling tools and provide the final answer.
175
- </system-reminder>`;
176
-
177
- // Checks recent tool usage for simple repeated-call patterns and inserts an
178
- // anti-doom-loop reminder when the agent appears stuck.
179
- export function insertToolUsageReminder(
180
- messages: Message[],
181
- toolMessage: ToolResultMessage,
182
- ): ToolResultMessage {
183
- const lastReminderIdx = messages.findLastIndex((m) => {
184
- return (
185
- m.role === "toolResult" &&
186
- m.content.find(
187
- (b) => b.type === "text" && b.text.includes(doomLoopReminder),
188
- )
189
- );
190
- });
191
- const lastUserMessageIdx = messages.findLastIndex((m) => {
192
- return m.role === "user";
193
- });
194
- const messagesSinceLast = messages.slice(
195
- Math.max(lastReminderIdx, lastUserMessageIdx) + 1,
196
- );
197
- const minMessagesForReminder = 4;
198
-
199
- if (messagesSinceLast.length < minMessagesForReminder) return toolMessage;
200
-
201
- let errorCount = 0;
202
- let sameToolCount = 0;
203
- const seenArgs = new Set<string>();
204
-
205
- for (const msg of messagesSinceLast) {
206
- if (msg.role === "toolResult" && msg.isError) errorCount++;
207
-
208
- if (msg.role === "assistant") {
209
- const calls = msg.content.filter((b) => b.type === "toolCall");
210
- for (const c of calls) {
211
- const args = JSON.stringify(c.arguments);
212
- if (seenArgs.has(args)) sameToolCount++;
213
- seenArgs.add(args);
214
- }
63
+ if (config.skillsDirs.length > 0) {
64
+ const skills = discoverSkills(config.skillsDirs);
65
+ if (skills.length > 0) {
66
+ sections.push(
67
+ "## Skills\n\n" + skills.map((s) => `- ${s.name}: ${s.description} (${s.path})`).join("\n"),
68
+ );
215
69
  }
216
70
  }
217
71
 
218
- if (
219
- errorCount >= minMessagesForReminder ||
220
- sameToolCount >= minMessagesForReminder
221
- ) {
222
- return {
223
- ...toolMessage,
224
- content: [
225
- ...toolMessage.content,
226
- { type: "text", text: doomLoopReminder },
227
- ],
228
- };
72
+ if (config.discoverAgentFiles) {
73
+ for (const file of agentFiles()) {
74
+ sections.push(`## ${file.path}\n\n${file.text}`);
75
+ }
229
76
  }
230
77
 
231
- return toolMessage;
78
+ return sections.join("\n\n");
232
79
  }
package/src/session.ts CHANGED
@@ -1,91 +1,146 @@
1
- import { mkdir } from "node:fs/promises";
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ mkdirSync,
5
+ openSync,
6
+ writeSync,
7
+ } from "node:fs";
8
+ import { randomBytes } from "node:crypto";
2
9
  import { join } from "node:path";
3
- import type { Message } from "@earendil-works/pi-ai";
4
- import { Value } from "typebox/value";
5
- import { SESSIONS_DIR } from "./shared";
6
- import { type Session, SessionSchema } from "./types";
10
+ import type { Message, Tool } from "@earendil-works/pi-ai";
7
11
 
8
- export async function ensureSessionsDir(): Promise<void> {
9
- await mkdir(SESSIONS_DIR, { recursive: true });
12
+ interface SessionHeader {
13
+ type: "session";
14
+ version: 1;
15
+ id: string;
16
+ cwd: string;
17
+ createdAt: string;
18
+ title: string;
10
19
  }
11
20
 
12
- export async function getSession(id: string): Promise<Session | undefined> {
13
- const file = Bun.file(join(SESSIONS_DIR, `${id}.json`));
21
+ interface RequestRecord {
22
+ type: "request";
23
+ at: string;
24
+ provider: string;
25
+ model: string;
26
+ api: string;
27
+ thinkingEffort: string;
28
+ systemPrompt: string;
29
+ tools: Tool[];
30
+ }
14
31
 
15
- if (!(await file.exists())) {
16
- return;
17
- }
32
+ interface MessageRecord {
33
+ type: "message";
34
+ at: string;
35
+ message: Message;
36
+ }
18
37
 
19
- try {
20
- const sessionJson = await file.text();
21
- const parsed = JSON.parse(sessionJson) as unknown;
22
- const valid = Value.Check(SessionSchema, parsed);
38
+ type SessionRecord = SessionHeader | RequestRecord | MessageRecord;
23
39
 
24
- if (valid) return parsed;
25
- } catch {
26
- return;
27
- }
40
+ function slugify(text: string): string {
41
+ const slug = text
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, "-")
44
+ .replace(/^-+|-+$/g, "")
45
+ .slice(0, 40)
46
+ .replace(/-+$/, "");
47
+ return slug || "session";
48
+ }
49
+
50
+ const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
28
51
 
29
- return;
52
+ function shortId(): string {
53
+ const bytes = randomBytes(6);
54
+ let out = "";
55
+ for (let i = 0; i < 6; i++) out += ALPHABET[bytes[i] % 36];
56
+ return out;
30
57
  }
31
58
 
32
- function latestMessageTimestamp(session: Session): number {
33
- return Math.max(0, ...session.messages.map((message) => message.timestamp));
59
+ function timestamp(date: Date): string {
60
+ const p = (n: number) => String(n).padStart(2, "0");
61
+ return (
62
+ `${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
63
+ `-${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}`
64
+ );
34
65
  }
35
66
 
36
- export async function listSessionsForCwd(): Promise<Session[]> {
37
- const sessions: Session[] = [];
38
- const sessionFiles = new Bun.Glob("*.json");
39
- const cwd = process.cwd();
67
+ function titleFor(message: Message): string {
68
+ if (message.role !== "user") return "session";
69
+ const text = typeof message.content === "string"
70
+ ? message.content
71
+ : message.content.map((block) => (block.type === "text" ? block.text : "")).join(" ");
72
+ return slugify(text);
73
+ }
40
74
 
41
- try {
42
- for await (const entry of sessionFiles.scan({
43
- cwd: SESSIONS_DIR,
44
- dot: true,
45
- })) {
46
- try {
47
- const sessionJson = await Bun.file(join(SESSIONS_DIR, entry)).text();
48
- const parsed = JSON.parse(sessionJson) as unknown;
49
-
50
- if (Value.Check(SessionSchema, parsed) && cwd === parsed.cwd) {
51
- sessions.push(parsed);
52
- }
53
- } catch {
54
- // Ignore invalid session files.
55
- }
56
- }
57
- } catch {
58
- return [];
75
+ export class Session {
76
+ private readonly sessionsDir: string;
77
+ private readonly cwd: string;
78
+ id: string | null = null;
79
+ private fd: number | null = null;
80
+ private closed = false;
81
+
82
+ constructor(sessionsDir: string, cwd: string) {
83
+ this.sessionsDir = sessionsDir;
84
+ this.cwd = cwd;
59
85
  }
60
86
 
61
- return sessions.sort(
62
- (a, b) => latestMessageTimestamp(b) - latestMessageTimestamp(a),
63
- );
64
- }
87
+ appendMessage(message: Message): void {
88
+ if (this.closed) return;
89
+ this.ensure(titleFor(message));
90
+ this.write({
91
+ type: "message",
92
+ at: new Date().toISOString(),
93
+ message,
94
+ });
95
+ }
65
96
 
66
- export async function saveSession(s: Session) {
67
- await ensureSessionsDir();
68
- const file = Bun.file(join(SESSIONS_DIR, `${s.id}.json`));
69
- await Bun.write(file, JSON.stringify(s));
70
- }
97
+ appendRequest(input: Omit<RequestRecord, "type" | "at">): void {
98
+ if (this.closed) return;
99
+ this.ensure("session");
100
+ this.write({ type: "request", at: new Date().toISOString(), ...input });
101
+ }
71
102
 
72
- export async function updateSession(id: string, messages: Message[]) {
73
- const existing = await getSession(id);
74
- if (existing) {
75
- // Only append new messages, so we don't save compacted messages.
76
- if (existing.messages.length < messages.length) {
77
- const newMessages = messages.slice(existing.messages.length);
78
- existing.messages = [...existing.messages, ...newMessages];
79
- await saveSession(existing);
103
+ close(): void {
104
+ this.closed = true;
105
+ if (this.fd !== null) {
106
+ closeSync(this.fd);
107
+ this.fd = null;
80
108
  }
81
- return;
82
109
  }
83
110
 
84
- const s = {
85
- id,
86
- cwd: process.cwd(),
87
- messages,
88
- };
111
+ private ensure(title: string): void {
112
+ if (this.fd !== null) return;
113
+ mkdirSync(this.sessionsDir, { recursive: true });
114
+ const stamp = timestamp(new Date());
115
+ for (let attempt = 0; attempt < 16; attempt++) {
116
+ const name = `${stamp}-${title}-${shortId()}`;
117
+ const dir = join(this.sessionsDir, name);
118
+ try {
119
+ mkdirSync(dir);
120
+ } catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
122
+ throw error;
123
+ }
124
+ this.id = name;
125
+ const logPath = join(dir, "session.jsonl");
126
+ this.fd = openSync(logPath, "a");
127
+ const header: SessionHeader = {
128
+ type: "session",
129
+ version: 1,
130
+ id: name,
131
+ cwd: this.cwd,
132
+ createdAt: new Date().toISOString(),
133
+ title,
134
+ };
135
+ this.write(header);
136
+ return;
137
+ }
138
+ throw new Error(`session: could not create a unique directory in ${this.sessionsDir}`);
139
+ }
89
140
 
90
- await saveSession(s);
141
+ private write(record: SessionRecord): void {
142
+ if (this.fd === null) throw new Error("session: append before create");
143
+ writeSync(this.fd, JSON.stringify(record) + "\n");
144
+ fsyncSync(this.fd);
145
+ }
91
146
  }