@oh-my-pi/pi-coding-agent 15.5.10 → 15.5.11

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.
@@ -0,0 +1,565 @@
1
+ import { logger, toError } from "@oh-my-pi/pi-utils";
2
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+
4
+ /**
5
+ * Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
6
+ * `client.options.adapter`; we detect it once at construction and pick the
7
+ * correct DDL / upsert / concat syntax for the underlying engine.
8
+ */
9
+ export type SqlSessionStorageAdapter = "postgres" | "mysql" | "sqlite";
10
+
11
+ /**
12
+ * Minimal subset of the `Bun.SQL` instance surface used by
13
+ * {@link SqlSessionStorage}. The real client exposes a callable
14
+ * tagged-template too; we only ever call `unsafe()` so the contract here is
15
+ * narrow — making it trivial to swap in a test double or wrap a pooled
16
+ * client.
17
+ */
18
+ export interface SqlSessionStorageClient {
19
+ unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
20
+ /**
21
+ * `Bun.SQL` exposes the parsed connection options here. We only consult
22
+ * `adapter` to pick the dialect; the field is typed as
23
+ * `string | undefined` so the real `Bun.SQL` instance type slots in
24
+ * without casting (it reports `string | undefined` across adapters).
25
+ */
26
+ options: { adapter?: string; [key: string]: unknown };
27
+ end?(): Promise<void>;
28
+ }
29
+
30
+ export interface SqlSessionStorageOptions {
31
+ /** Connected `Bun.SQL` instance (PostgreSQL, MySQL, or SQLite). */
32
+ client: SqlSessionStorageClient;
33
+ /**
34
+ * Override the auto-detected adapter. Useful when the client is wrapped
35
+ * (e.g. by a pool) and `client.options.adapter` is unreliable.
36
+ */
37
+ adapter?: SqlSessionStorageAdapter;
38
+ /**
39
+ * Table name to use. Default: `omp_session_files`. Must match
40
+ * `[A-Za-z_][A-Za-z0-9_]{0,62}` — inlined into prepared statements at
41
+ * startup, so we accept identifier-safe inputs only (no quoted/dotted
42
+ * names).
43
+ */
44
+ table?: string;
45
+ /**
46
+ * If true, run `CREATE TABLE IF NOT EXISTS` during `create()`.
47
+ * Default: true. Disable when the table is owned by an external
48
+ * migration.
49
+ */
50
+ createTable?: boolean;
51
+ }
52
+
53
+ interface MirrorEntry {
54
+ content: string;
55
+ mtimeMs: number;
56
+ }
57
+
58
+ interface DialectQueries {
59
+ createTable: string;
60
+ /** Insert or replace the full content for `path`. Used for `writeText`/`flags="w"` truncate. */
61
+ upsertReplace: string;
62
+ /** Insert if missing; otherwise append the new chunk to existing content. Used for `writeLine`. */
63
+ upsertAppend: string;
64
+ /** Delete a single row by path. */
65
+ delete: string;
66
+ /** Delete every row whose `path` starts with the supplied LIKE pattern. */
67
+ deletePrefix: string;
68
+ /** Move a row from one path to another (caller deletes any conflicting destination first). */
69
+ rename: string;
70
+ /** Read everything for the in-memory mirror warm-up. */
71
+ selectAll: string;
72
+ }
73
+
74
+ const DEFAULT_TABLE = "omp_session_files";
75
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
76
+ const LIKE_ESCAPE_CHAR = "#";
77
+ const LIKE_ESCAPE_RE = /[%_#]/g;
78
+
79
+ function enoent(p: string): NodeJS.ErrnoException {
80
+ const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
81
+ err.code = "ENOENT";
82
+ err.errno = -2;
83
+ err.path = p;
84
+ err.syscall = "open";
85
+ return err;
86
+ }
87
+
88
+ function matchesGlob(name: string, pattern: string): boolean {
89
+ if (pattern === "*") return true;
90
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
91
+ return name === pattern;
92
+ }
93
+
94
+ function escapeLikeLiteral(value: string): string {
95
+ return value.replace(LIKE_ESCAPE_RE, ch => `${LIKE_ESCAPE_CHAR}${ch}`);
96
+ }
97
+
98
+ function detectAdapter(client: SqlSessionStorageClient): SqlSessionStorageAdapter {
99
+ const reported = String(client.options?.adapter ?? "").toLowerCase();
100
+ if (reported === "postgres" || reported === "postgresql" || reported === "pg") return "postgres";
101
+ if (reported === "mysql" || reported === "mariadb") return "mysql";
102
+ if (reported === "sqlite" || reported === "sqlite3") return "sqlite";
103
+ throw new Error(
104
+ `SqlSessionStorage: unable to infer adapter from client.options.adapter=${JSON.stringify(reported)}. ` +
105
+ `Pass an explicit \`adapter\` option ("postgres" | "mysql" | "sqlite").`,
106
+ );
107
+ }
108
+
109
+ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): DialectQueries {
110
+ const placeholder = adapter === "postgres" ? (n: number): string => `$${n}` : (_n: number): string => "?";
111
+
112
+ if (adapter === "mysql") {
113
+ return {
114
+ createTable:
115
+ `CREATE TABLE IF NOT EXISTS ${table} (` +
116
+ `path VARCHAR(512) NOT NULL PRIMARY KEY, ` +
117
+ `content LONGTEXT NOT NULL, ` +
118
+ `mtime_ms BIGINT NOT NULL` +
119
+ `) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`,
120
+ upsertReplace:
121
+ `INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
122
+ `ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms)`,
123
+ upsertAppend:
124
+ `INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
125
+ `ON DUPLICATE KEY UPDATE content = CONCAT(content, VALUES(content)), mtime_ms = VALUES(mtime_ms)`,
126
+ delete: `DELETE FROM ${table} WHERE path = ?`,
127
+ deletePrefix: `DELETE FROM ${table} WHERE path LIKE ? ESCAPE '${LIKE_ESCAPE_CHAR}'`,
128
+ rename: `UPDATE ${table} SET path = ?, mtime_ms = ? WHERE path = ?`,
129
+ selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
130
+ };
131
+ }
132
+
133
+ // PostgreSQL + SQLite — both support `ON CONFLICT(path) DO UPDATE …` and
134
+ // `||` for string concatenation. The `excluded` keyword references the
135
+ // row that would have been inserted, in both engines.
136
+ const mtimeType = adapter === "postgres" ? "BIGINT" : "INTEGER";
137
+ const tableQualifier = `${table}.content`;
138
+ return {
139
+ createTable:
140
+ `CREATE TABLE IF NOT EXISTS ${table} (` +
141
+ `path TEXT PRIMARY KEY, ` +
142
+ `content TEXT NOT NULL, ` +
143
+ `mtime_ms ${mtimeType} NOT NULL` +
144
+ `)`,
145
+ upsertReplace:
146
+ `INSERT INTO ${table} (path, content, mtime_ms) ` +
147
+ `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
148
+ `ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms`,
149
+ upsertAppend:
150
+ `INSERT INTO ${table} (path, content, mtime_ms) ` +
151
+ `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
152
+ `ON CONFLICT (path) DO UPDATE SET content = ${tableQualifier} || excluded.content, mtime_ms = excluded.mtime_ms`,
153
+ delete: `DELETE FROM ${table} WHERE path = ${placeholder(1)}`,
154
+ deletePrefix: `DELETE FROM ${table} WHERE path LIKE ${placeholder(1)} ESCAPE '${LIKE_ESCAPE_CHAR}'`,
155
+ rename: `UPDATE ${table} SET path = ${placeholder(1)}, mtime_ms = ${placeholder(2)} WHERE path = ${placeholder(3)}`,
156
+ selectAll: `SELECT path, content, mtime_ms FROM ${table}`,
157
+ };
158
+ }
159
+
160
+ interface DbRow {
161
+ path: string;
162
+ content: string;
163
+ mtime_ms: number | bigint | string;
164
+ }
165
+
166
+ function rowMtime(value: number | bigint | string): number {
167
+ if (typeof value === "number") return value;
168
+ if (typeof value === "bigint") return Number(value);
169
+ return Number.parseInt(value, 10);
170
+ }
171
+
172
+ /**
173
+ * SQL-backed implementation of {@link SessionStorage} using `bun:sql`. Each
174
+ * session JSONL file maps to a row keyed by `path`; one table stores
175
+ * everything.
176
+ *
177
+ * Works against PostgreSQL, MySQL/MariaDB, and SQLite by selecting the
178
+ * dialect-correct DDL, upsert, and string-concat syntax at construction.
179
+ *
180
+ * Trade-offs vs `FileSessionStorage`:
181
+ * - An in-memory mirror is loaded on construction so the interface's
182
+ * synchronous methods (`existsSync`, `statSync`, `listFilesSync`, …) keep
183
+ * their contracts; `bun:sql` is async only. Mirror state is process-local,
184
+ * matching `FileSessionStorage`'s existing single-writer assumption — peer
185
+ * processes need {@link refresh} to pick up out-of-band writes.
186
+ * - `writeLineSync` updates the mirror synchronously and queues an async
187
+ * upsert that appends the line to the existing row (or inserts it as the
188
+ * first chunk). The promise is awaited by `flush()` / `close()` /
189
+ * {@link drain}. A SIGKILL between the sync mirror update and the network
190
+ * round-trip loses the last line.
191
+ * - Blobs (image data) and tool artifact files still live on disk via
192
+ * `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
193
+ */
194
+ export class SqlSessionStorage implements SessionStorage {
195
+ readonly #client: SqlSessionStorageClient;
196
+ readonly #adapter: SqlSessionStorageAdapter;
197
+ readonly #table: string;
198
+ readonly #q: DialectQueries;
199
+ readonly #mirror = new Map<string, MirrorEntry>();
200
+ readonly #writers = new Set<SqlSessionStorageWriter>();
201
+ #nextMtimeMs = 0;
202
+ #pendingTail: Promise<void> = Promise.resolve();
203
+
204
+ private constructor(options: SqlSessionStorageOptions) {
205
+ this.#client = options.client;
206
+ this.#adapter = options.adapter ?? detectAdapter(options.client);
207
+ const table = options.table ?? DEFAULT_TABLE;
208
+ if (!IDENT_RE.test(table)) {
209
+ throw new Error(`SqlSessionStorage: table name must match ${IDENT_RE.source} (got ${JSON.stringify(table)})`);
210
+ }
211
+ this.#table = table;
212
+ this.#q = buildQueries(this.#adapter, table);
213
+ }
214
+
215
+ /**
216
+ * Apply the dialect-correct DDL (unless `createTable: false` is set) and
217
+ * warm the in-memory mirror with every existing row. Must be awaited
218
+ * before passing the storage into `SessionManager.create()`.
219
+ */
220
+ static async create(options: SqlSessionStorageOptions): Promise<SqlSessionStorage> {
221
+ const storage = new SqlSessionStorage(options);
222
+ if (options.createTable !== false) {
223
+ await storage.#client.unsafe(storage.#q.createTable);
224
+ }
225
+ await storage.refresh();
226
+ return storage;
227
+ }
228
+
229
+ get adapter(): SqlSessionStorageAdapter {
230
+ return this.#adapter;
231
+ }
232
+
233
+ get table(): string {
234
+ return this.#table;
235
+ }
236
+
237
+ /**
238
+ * Re-load the mirror from the database. Call this from a different
239
+ * process that took over the table, or after an out-of-band write made
240
+ * by another agent.
241
+ */
242
+ async refresh(): Promise<void> {
243
+ this.#mirror.clear();
244
+ const rows = (await this.#client.unsafe(this.#q.selectAll)) as DbRow[];
245
+ for (const row of rows) {
246
+ const mtimeMs = rowMtime(row.mtime_ms);
247
+ this.#mirror.set(row.path, { content: row.content, mtimeMs });
248
+ if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Resolve once every pending background write (issued via `writeTextSync`
254
+ * or `writer.writeLineSync`) has been acknowledged by the database.
255
+ * Throws if any background write failed since the last drain. Call on
256
+ * graceful shutdown to avoid losing the last unflushed line.
257
+ */
258
+ async drain(): Promise<void> {
259
+ // Take ownership of the current tail, then reset so subsequent
260
+ // operations start from a clean (resolved) chain. Without the reset,
261
+ // any failure observed here would also be re-thrown by every later
262
+ // write that piggybacks on the tail via `#trackPending`.
263
+ const tail = this.#pendingTail;
264
+ this.#pendingTail = Promise.resolve();
265
+ await tail;
266
+ }
267
+
268
+ /**
269
+ * Allocate a strictly monotonic mtime. Two writes within the same
270
+ * millisecond would otherwise yield identical `mtimeMs` values and break
271
+ * `getSortedSessions`' newest-first ordering.
272
+ */
273
+ #allocMtimeMs(): number {
274
+ const now = Date.now();
275
+ const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
276
+ this.#nextMtimeMs = next;
277
+ return next;
278
+ }
279
+
280
+ #trackPending(promise: Promise<void>): void {
281
+ // `Promise.all` rejects when either input rejects, which is exactly
282
+ // what we want for `drain()`. The follow-up `.catch(() => {})` only
283
+ // silences the unhandled-rejection signal on the shared tail —
284
+ // `drain()` keeps its own handler chain and still observes the
285
+ // original error, because rejection delivery is per-handler-chain.
286
+ this.#pendingTail = Promise.all([this.#pendingTail, promise]).then(() => {});
287
+ this.#pendingTail.catch(() => {});
288
+ }
289
+
290
+ // --- sync surface ---------------------------------------------------------
291
+
292
+ ensureDirSync(_dir: string): void {
293
+ // SQL is flat: directories are derived from key prefixes.
294
+ }
295
+
296
+ existsSync(path: string): boolean {
297
+ return this.#mirror.has(path);
298
+ }
299
+
300
+ writeTextSync(path: string, content: string): void {
301
+ const mtimeMs = this.#allocMtimeMs();
302
+ this.#mirror.set(path, { content, mtimeMs });
303
+ this.#trackPending(this.#upsertReplace(path, content, mtimeMs));
304
+ }
305
+
306
+ readTextSync(path: string): string {
307
+ const entry = this.#mirror.get(path);
308
+ if (!entry) throw enoent(path);
309
+ return entry.content;
310
+ }
311
+
312
+ statSync(path: string): SessionStorageStat {
313
+ const entry = this.#mirror.get(path);
314
+ if (!entry) throw enoent(path);
315
+ return {
316
+ size: Buffer.byteLength(entry.content, "utf-8"),
317
+ mtimeMs: entry.mtimeMs,
318
+ mtime: new Date(entry.mtimeMs),
319
+ };
320
+ }
321
+
322
+ listFilesSync(dir: string, pattern: string): string[] {
323
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
324
+ const out: string[] = [];
325
+ for (const path of this.#mirror.keys()) {
326
+ if (!path.startsWith(prefix)) continue;
327
+ const name = path.slice(prefix.length);
328
+ if (name.includes("/")) continue;
329
+ if (!matchesGlob(name, pattern)) continue;
330
+ out.push(path);
331
+ }
332
+ return out;
333
+ }
334
+
335
+ // --- async surface --------------------------------------------------------
336
+
337
+ async exists(path: string): Promise<boolean> {
338
+ return this.#mirror.has(path);
339
+ }
340
+
341
+ async readText(path: string): Promise<string> {
342
+ const entry = this.#mirror.get(path);
343
+ if (!entry) throw enoent(path);
344
+ return entry.content;
345
+ }
346
+
347
+ async readTextPrefix(path: string, maxBytes: number): Promise<string> {
348
+ const entry = this.#mirror.get(path);
349
+ if (!entry) throw enoent(path);
350
+ if (maxBytes <= 0) return "";
351
+ // `entry.content` is a JS string (UTF-16 code units); the prefix
352
+ // contract is byte-oriented. Encode to UTF-8, slice, then decode —
353
+ // matching `peekFile`'s behaviour for the file-backed storage.
354
+ const bytes = Buffer.from(entry.content, "utf-8");
355
+ const slice = bytes.subarray(0, Math.min(maxBytes, bytes.byteLength));
356
+ return slice.toString("utf-8");
357
+ }
358
+
359
+ async writeText(path: string, content: string): Promise<void> {
360
+ const mtimeMs = this.#allocMtimeMs();
361
+ this.#mirror.set(path, { content, mtimeMs });
362
+ await this.#upsertReplace(path, content, mtimeMs);
363
+ }
364
+
365
+ async rename(src: string, dst: string): Promise<void> {
366
+ const entry = this.#mirror.get(src);
367
+ if (!entry) throw enoent(src);
368
+ // Update the mirror first so a synchronous existsSync() right after
369
+ // the await resolves consistently. If the DB update fails the mirror
370
+ // is rolled back below.
371
+ const dstPrev = this.#mirror.get(dst);
372
+ this.#mirror.delete(src);
373
+ this.#mirror.set(dst, entry);
374
+
375
+ try {
376
+ // `fs.promises.rename` overwrites the destination when one
377
+ // exists; mirror that here so the JSONL atomic-rewrite flow
378
+ // (temp file → rename) keeps working unchanged.
379
+ if (dstPrev !== undefined) {
380
+ await this.#client.unsafe(this.#q.delete, [dst]);
381
+ }
382
+ await this.#client.unsafe(this.#q.rename, [dst, entry.mtimeMs, src]);
383
+ } catch (err) {
384
+ this.#mirror.delete(dst);
385
+ if (dstPrev !== undefined) this.#mirror.set(dst, dstPrev);
386
+ this.#mirror.set(src, entry);
387
+ throw toError(err);
388
+ }
389
+ }
390
+
391
+ async unlink(path: string): Promise<void> {
392
+ const existed = this.#mirror.delete(path);
393
+ await this.#client.unsafe(this.#q.delete, [path]);
394
+ if (!existed) {
395
+ throw enoent(path);
396
+ }
397
+ }
398
+
399
+ async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
400
+ await this.unlink(sessionPath);
401
+
402
+ // Tool artifact bytes don't live in SQL (the file-backed
403
+ // `ArtifactManager` keeps them on disk), but a draft sidecar may
404
+ // have been written through `writeText` under the artifacts
405
+ // directory prefix. Sweep those keys in one statement.
406
+ const artifactsDir = sessionPath.slice(0, -6);
407
+ const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
408
+
409
+ const victims: string[] = [];
410
+ for (const key of this.#mirror.keys()) {
411
+ if (key.startsWith(prefix)) victims.push(key);
412
+ }
413
+ if (victims.length === 0) return;
414
+
415
+ for (const key of victims) this.#mirror.delete(key);
416
+ const likePattern = `${escapeLikeLiteral(prefix)}%`;
417
+ try {
418
+ await this.#client.unsafe(this.#q.deletePrefix, [likePattern]);
419
+ } catch (err) {
420
+ logger.warn("SQL session storage artifact sweep failed", {
421
+ sessionPath,
422
+ prefix,
423
+ error: toError(err).message,
424
+ });
425
+ throw toError(err);
426
+ }
427
+ }
428
+
429
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
430
+ const writer = new SqlSessionStorageWriter(this, path, options);
431
+ this.#writers.add(writer);
432
+ return writer;
433
+ }
434
+
435
+ // --- writer support -------------------------------------------------------
436
+
437
+ _writerClosed(writer: SqlSessionStorageWriter): void {
438
+ this.#writers.delete(writer);
439
+ }
440
+
441
+ _mirrorAppend(path: string, line: string): { content: string; mtimeMs: number } {
442
+ const existing = this.#mirror.get(path);
443
+ const content = existing ? existing.content + line : line;
444
+ const mtimeMs = this.#allocMtimeMs();
445
+ this.#mirror.set(path, { content, mtimeMs });
446
+ return { content, mtimeMs };
447
+ }
448
+
449
+ _mirrorTruncate(path: string): void {
450
+ this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
451
+ }
452
+
453
+ async _remoteTruncate(path: string): Promise<void> {
454
+ const entry = this.#mirror.get(path);
455
+ const mtimeMs = entry?.mtimeMs ?? this.#allocMtimeMs();
456
+ await this.#upsertReplace(path, "", mtimeMs);
457
+ }
458
+
459
+ /**
460
+ * Append a chunk to the row at `path`, inserting if the row doesn't
461
+ * exist yet. Single round-trip via the dialect-specific `upsertAppend`.
462
+ */
463
+ async _remoteAppend(path: string, line: string, mtimeMs: number): Promise<void> {
464
+ await this.#client.unsafe(this.#q.upsertAppend, [path, line, mtimeMs]);
465
+ }
466
+
467
+ _attachPending(promise: Promise<void>): void {
468
+ this.#trackPending(promise);
469
+ }
470
+
471
+ async #upsertReplace(path: string, content: string, mtimeMs: number): Promise<void> {
472
+ await this.#client.unsafe(this.#q.upsertReplace, [path, content, mtimeMs]);
473
+ }
474
+ }
475
+
476
+ class SqlSessionStorageWriter implements SessionStorageWriter {
477
+ #storage: SqlSessionStorage;
478
+ #path: string;
479
+ #closed = false;
480
+ #error: Error | undefined;
481
+ #onError: ((err: Error) => void) | undefined;
482
+ #pendingChain: Promise<void> = Promise.resolve();
483
+
484
+ constructor(
485
+ storage: SqlSessionStorage,
486
+ path: string,
487
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
488
+ ) {
489
+ this.#storage = storage;
490
+ this.#path = path;
491
+ this.#onError = options?.onError;
492
+ const flags = options?.flags ?? "a";
493
+ if (flags === "w") {
494
+ // Mirror `FileSessionStorageWriter`'s `flags: "w"` contract by
495
+ // truncating both the mirror and the underlying row immediately.
496
+ storage._mirrorTruncate(path);
497
+ this.#enqueueRaw(() => storage._remoteTruncate(path));
498
+ }
499
+ }
500
+
501
+ #recordError(err: unknown): Error {
502
+ const error = toError(err);
503
+ if (!this.#error) this.#error = error;
504
+ this.#onError?.(error);
505
+ return error;
506
+ }
507
+
508
+ #enqueueRaw(task: () => Promise<void>): Promise<void> {
509
+ const next = this.#pendingChain.then(async () => {
510
+ if (this.#error) throw this.#error;
511
+ try {
512
+ await task();
513
+ } catch (err) {
514
+ throw this.#recordError(err);
515
+ }
516
+ });
517
+ this.#pendingChain = next.catch(() => {
518
+ // Errors are recorded on `this.#error`; subsequent enqueues
519
+ // throw from inside the wrapper above. The outer chain swallows
520
+ // to avoid surfacing as an unhandled promise rejection.
521
+ });
522
+ this.#storage._attachPending(next);
523
+ return next;
524
+ }
525
+
526
+ writeLineSync(line: string): void {
527
+ if (this.#closed) throw new Error("Writer closed");
528
+ if (this.#error) throw this.#error;
529
+ const { mtimeMs } = this.#storage._mirrorAppend(this.#path, line);
530
+ this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line, mtimeMs));
531
+ }
532
+
533
+ async writeLine(line: string): Promise<void> {
534
+ if (this.#closed) throw new Error("Writer closed");
535
+ if (this.#error) throw this.#error;
536
+ const { mtimeMs } = this.#storage._mirrorAppend(this.#path, line);
537
+ await this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line, mtimeMs));
538
+ }
539
+
540
+ async flush(): Promise<void> {
541
+ if (this.#error) throw this.#error;
542
+ await this.#enqueueRaw(async () => {});
543
+ if (this.#error) throw this.#error;
544
+ }
545
+
546
+ async fsync(): Promise<void> {
547
+ // `bun:sql` returns once the server has acknowledged the write;
548
+ // flush() already drains every queued statement.
549
+ await this.flush();
550
+ }
551
+
552
+ async close(): Promise<void> {
553
+ if (this.#closed) return;
554
+ this.#closed = true;
555
+ try {
556
+ await this.flush();
557
+ } finally {
558
+ this.#storage._writerClosed(this);
559
+ }
560
+ }
561
+
562
+ getError(): Error | undefined {
563
+ return this.#error;
564
+ }
565
+ }
package/src/tools/read.ts CHANGED
@@ -1063,21 +1063,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1063
1063
  }
1064
1064
 
1065
1065
  const collectedLines = streamResult.lines;
1066
+ // Column truncation is display-only. The snapshot (sparseSnapshotEntries)
1067
+ // MUST hold on-disk content so later edits can verify line content against
1068
+ // the live file. Stamping ellipsis-truncated lines into the snapshot makes
1069
+ // every long-line file uneditable on the next edit attempt.
1070
+ let displayLines: string[] = collectedLines;
1066
1071
  if (!rawSelector && maxColumns > 0) {
1072
+ let cloned: string[] | undefined;
1067
1073
  for (let i = 0; i < collectedLines.length; i++) {
1068
1074
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1069
1075
  if (wasTruncated) {
1070
- collectedLines[i] = text;
1076
+ if (!cloned) cloned = collectedLines.slice();
1077
+ cloned[i] = text;
1071
1078
  columnTruncated = maxColumns;
1072
1079
  }
1073
1080
  }
1081
+ if (cloned) displayLines = cloned;
1074
1082
  }
1075
1083
 
1076
1084
  for (let index = 0; index < collectedLines.length; index++) {
1077
1085
  sparseSnapshotEntries.push([range.startLine + index, collectedLines[index]]);
1078
1086
  }
1079
1087
 
1080
- const blockText = collectedLines.join("\n");
1088
+ const blockText = displayLines.join("\n");
1081
1089
  blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1082
1090
  }
1083
1091
 
@@ -1854,17 +1862,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1854
1862
  // view — column truncation surfaces separately via `.limits()`.
1855
1863
  const rawSelector = isRawSelector(parsed);
1856
1864
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1865
+ // Column truncation is display-only. `collectedLines` MUST stay
1866
+ // byte-for-byte with the on-disk content so the snapshot recorded
1867
+ // below can be verified against the live file. Mutating it with
1868
+ // ellipsis-truncated text made every long-line file uneditable on
1869
+ // the next edit attempt.
1870
+ let displayLines: string[] = collectedLines;
1857
1871
  if (!rawSelector && maxColumns > 0) {
1872
+ let cloned: string[] | undefined;
1858
1873
  for (let i = 0; i < collectedLines.length; i++) {
1859
1874
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1860
1875
  if (wasTruncated) {
1861
- collectedLines[i] = text;
1876
+ if (!cloned) cloned = collectedLines.slice();
1877
+ cloned[i] = text;
1862
1878
  columnTruncated = maxColumns;
1863
1879
  }
1864
1880
  }
1881
+ if (cloned) displayLines = cloned;
1865
1882
  }
1866
1883
 
1867
- const selectedContent = collectedLines.join("\n");
1884
+ const selectedContent = displayLines.join("\n");
1868
1885
  const userLimitedLines = collectedLines.length;
1869
1886
 
1870
1887
  const totalSelectedLines = totalFileLines - startLine;
@@ -1890,9 +1907,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1890
1907
  if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
1891
1908
  const store = getFileSnapshotStore(this.session);
1892
1909
  const tag =
1893
- offset === undefined && limit === undefined && !wasTruncated && columnTruncated === 0
1910
+ offset === undefined && limit === undefined && !wasTruncated
1894
1911
  ? (() => {
1895
- const normalized = normalizeToLF(selectedContent);
1912
+ const normalized = normalizeToLF(collectedLines.join("\n"));
1896
1913
  return store.recordContiguous(absolutePath, 1, normalized.split("\n"), {
1897
1914
  fullText: normalized,
1898
1915
  });