@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,79 @@
1
+ import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
2
+ import { theme } from "./theme/theme";
3
+
4
+ /**
5
+ * "ultrathink" keyword support, mirroring Claude Code's affordance.
6
+ *
7
+ * Typing the standalone word in the input editor paints it with a rainbow
8
+ * gradient ({@link highlightUltrathink}); submitting a message that mentions it
9
+ * appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
10
+ * multi-step reasoning. Matching is word-bounded and case-insensitive, so
11
+ * "ultrathinking"/"ultrathinks" never trigger either behavior.
12
+ */
13
+
14
+ // Cheap, stateless presence probe used to skip the boundary regex on most lines.
15
+ const ULTRATHINK_PROBE = /ultrathink/i;
16
+ // Detection: standalone keyword, any case. Non-global so `.test` stays stateless.
17
+ const ULTRATHINK_WORD = /\bultrathink\b/i;
18
+ // Highlight: global so `.replace` walks every occurrence.
19
+ const ULTRATHINK_HIGHLIGHT = /\bultrathink\b/gi;
20
+
21
+ /** Hidden system notice appended after a user message that mentions "ultrathink". */
22
+ export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
23
+
24
+ /** Whether `text` contains the standalone keyword "ultrathink" (any case). */
25
+ export function containsUltrathink(text: string): boolean {
26
+ return ULTRATHINK_WORD.test(text);
27
+ }
28
+
29
+ const FG_RESET = "\x1b[39m";
30
+ // Hue stops swept across the visible spectrum. More stops than the keyword has
31
+ // letters so the gradient resolves smoothly regardless of casing/match length.
32
+ const RAINBOW_STOPS = 14;
33
+
34
+ let cachedMode: string | undefined;
35
+ let cachedPalette: readonly string[] | undefined;
36
+
37
+ /** Rainbow foreground escapes for the active color mode, compiled once per mode. */
38
+ function rainbowPalette(): readonly string[] {
39
+ const mode = theme.getColorMode();
40
+ if (cachedPalette && cachedMode === mode) return cachedPalette;
41
+ const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
42
+ const palette: string[] = [];
43
+ for (let i = 0; i < RAINBOW_STOPS; i++) {
44
+ // Sweep red→violet (0..330°), stopping short of the wrap back to red.
45
+ const hue = Math.round((i / RAINBOW_STOPS) * 330);
46
+ palette.push(Bun.color(`hsl(${hue}, 90%, 62%)`, format) ?? "");
47
+ }
48
+ cachedMode = mode;
49
+ cachedPalette = palette;
50
+ return palette;
51
+ }
52
+
53
+ /** Paint each character of `word` with the next rainbow stop, resetting fg after. */
54
+ function rainbow(word: string): string {
55
+ const palette = rainbowPalette();
56
+ const n = word.length;
57
+ let out = "";
58
+ let prev = "";
59
+ for (let i = 0; i < n; i++) {
60
+ const color = palette[Math.floor((i / n) * palette.length)] ?? palette[0] ?? "";
61
+ // Coalesce consecutive characters that resolve to the same stop.
62
+ if (color !== prev) {
63
+ out += color;
64
+ prev = color;
65
+ }
66
+ out += word[i];
67
+ }
68
+ return `${out}${FG_RESET}`;
69
+ }
70
+
71
+ /**
72
+ * Rainbow-highlight every standalone "ultrathink" in `text` for editor display.
73
+ * Adds only zero-width SGR escapes — the visible width is unchanged — and returns
74
+ * the input untouched when the keyword is absent.
75
+ */
76
+ export function highlightUltrathink(text: string): string {
77
+ if (!ULTRATHINK_PROBE.test(text)) return text;
78
+ return text.replace(ULTRATHINK_HIGHLIGHT, rainbow);
79
+ }
@@ -0,0 +1,3 @@
1
+ <system-notice>
2
+ This task involves multi-step reasoning. Think carefully through the problem before responding.
3
+ </system-notice>
@@ -148,6 +148,7 @@ import type { HindsightSessionState } from "../hindsight/state";
148
148
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
149
149
  import { resolveMemoryBackend } from "../memory-backend";
150
150
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
151
+ import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
151
152
  import type { PlanModeState } from "../plan-mode/state";
152
153
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
153
154
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -3997,6 +3998,21 @@ export class AgentSession {
3997
3998
  // Expand file-based prompt templates if requested
3998
3999
  const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
3999
4000
 
4001
+ // "ultrathink" keyword: nudge the model toward careful multi-step reasoning by
4002
+ // appending a hidden notice after the user's message. User-authored prompts only —
4003
+ // synthetic/agent-initiated turns never trigger it.
4004
+ const ultrathinkNotice: CustomMessage | undefined =
4005
+ !options?.synthetic && containsUltrathink(expandedText)
4006
+ ? {
4007
+ role: "custom",
4008
+ customType: "ultrathink-notice",
4009
+ content: ULTRATHINK_NOTICE,
4010
+ display: false,
4011
+ attribution: "user",
4012
+ timestamp: Date.now(),
4013
+ }
4014
+ : undefined;
4015
+
4000
4016
  // If streaming, queue via steer() or followUp() based on option
4001
4017
  if (this.isStreaming) {
4002
4018
  if (!options?.streamingBehavior) {
@@ -4007,6 +4023,10 @@ export class AgentSession {
4007
4023
  } else {
4008
4024
  await this.#queueSteer(expandedText, options?.images);
4009
4025
  }
4026
+ // Steer/follow-up the ultrathink notice alongside the queued user message.
4027
+ if (ultrathinkNotice) {
4028
+ await this.sendCustomMessage(ultrathinkNotice, { deliverAs: options.streamingBehavior });
4029
+ }
4010
4030
  return;
4011
4031
  }
4012
4032
 
@@ -4035,6 +4055,7 @@ export class AgentSession {
4035
4055
  await this.#promptWithMessage(message, expandedText, {
4036
4056
  ...options,
4037
4057
  prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
4058
+ appendMessages: ultrathinkNotice ? [ultrathinkNotice] : undefined,
4038
4059
  });
4039
4060
  } finally {
4040
4061
  // Clean up residual eager-todo directive if the prompt never consumed it
@@ -4084,6 +4105,7 @@ export class AgentSession {
4084
4105
  expandedText: string,
4085
4106
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
4086
4107
  prependMessages?: AgentMessage[];
4108
+ appendMessages?: AgentMessage[];
4087
4109
  skipPostPromptRecoveryWait?: boolean;
4088
4110
  },
4089
4111
  ): Promise<void> {
@@ -4147,6 +4169,12 @@ export class AgentSession {
4147
4169
 
4148
4170
  messages.push(message);
4149
4171
 
4172
+ // Inject the ultrathink notice (and any other per-turn appends) right after the
4173
+ // user message so the model reads it as part of the same turn.
4174
+ if (options?.appendMessages) {
4175
+ messages.push(...options.appendMessages);
4176
+ }
4177
+
4150
4178
  // Early bail-out: if a newer abort/prompt cycle started during setup,
4151
4179
  // return before mutating shared state (nextTurn messages, system prompt).
4152
4180
  if (this.#promptGeneration !== generation) {
@@ -0,0 +1,481 @@
1
+ import { logger, toError } from "@oh-my-pi/pi-utils";
2
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+
4
+ /**
5
+ * Minimal subset of the `bun:redis` `RedisClient` surface used by
6
+ * {@link RedisSessionStorage}. Keeping the contract narrow (and accepting any
7
+ * client that conforms) lets callers swap in test doubles or shared clients
8
+ * without dragging the entire Bun typings into this module.
9
+ */
10
+ export interface RedisSessionStorageClient {
11
+ get(key: string): Promise<string | null>;
12
+ set(key: string, value: string): Promise<unknown>;
13
+ append(key: string, value: string): Promise<number>;
14
+ del(...keys: string[]): Promise<number>;
15
+ rename(src: string, dst: string): Promise<unknown>;
16
+ scan(cursor: string, ...args: string[]): Promise<[string, string[]]>;
17
+ hset(key: string, field: string, value: string): Promise<unknown>;
18
+ hgetall(key: string): Promise<Record<string, string>>;
19
+ hdel(key: string, ...fields: string[]): Promise<unknown>;
20
+ }
21
+
22
+ export interface RedisSessionStorageOptions {
23
+ /** A connected `bun:redis` RedisClient (or any compatible adapter). */
24
+ client: RedisSessionStorageClient;
25
+ /**
26
+ * Key prefix applied to every Redis key this storage owns. Default `omp:sessions:`.
27
+ * Trailing colon is preserved verbatim — set to a project-scoped prefix to share
28
+ * one Redis instance between multiple agents.
29
+ */
30
+ prefix?: string;
31
+ /**
32
+ * Maximum number of keys returned per SCAN batch when warming the mirror.
33
+ * Default 500.
34
+ */
35
+ scanCount?: number;
36
+ }
37
+
38
+ interface MirrorEntry {
39
+ content: string;
40
+ mtimeMs: number;
41
+ }
42
+
43
+ const DEFAULT_PREFIX = "omp:sessions:";
44
+ const DEFAULT_SCAN_COUNT = 500;
45
+
46
+ function enoent(p: string): NodeJS.ErrnoException {
47
+ const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
48
+ err.code = "ENOENT";
49
+ err.errno = -2;
50
+ err.path = p;
51
+ err.syscall = "open";
52
+ return err;
53
+ }
54
+
55
+ function matchesGlob(name: string, pattern: string): boolean {
56
+ if (pattern === "*") return true;
57
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
58
+ return name === pattern;
59
+ }
60
+
61
+ /**
62
+ * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
63
+ * file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
64
+ * single sibling HASH. An in-memory mirror is loaded on construction so the
65
+ * interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`,
66
+ * `readTextSync`, `writeTextSync`) keep their contracts — Bun's Redis client
67
+ * is async only, and the persist hot path (`writer.writeLineSync`) cannot
68
+ * wait on a network round-trip.
69
+ *
70
+ * Trade-offs vs `FileSessionStorage`:
71
+ * - Mirror state is process-local. Two processes writing the same session key
72
+ * will diverge until one of them reloads via {@link refresh}. This matches
73
+ * `FileSessionStorage`'s existing single-writer assumption.
74
+ * - `writeLineSync` updates the mirror synchronously and queues an async
75
+ * `APPEND`. The promise is awaited by `flush()` / `close()` / {@link drain}.
76
+ * A SIGKILL landing between the sync mirror update and the network round
77
+ * trip loses the last line; the file-backed implementation survives that
78
+ * window because bytes are handed to the kernel page cache before
79
+ * returning.
80
+ * - Blobs (image data) and tool artifact files still live on disk via
81
+ * `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
82
+ */
83
+ export class RedisSessionStorage implements SessionStorage {
84
+ readonly #client: RedisSessionStorageClient;
85
+ readonly #prefix: string;
86
+ readonly #scanCount: number;
87
+ readonly #mirror = new Map<string, MirrorEntry>();
88
+ readonly #writers = new Set<RedisSessionStorageWriter>();
89
+ #nextMtimeMs = 0;
90
+ #pendingTail: Promise<void> = Promise.resolve();
91
+
92
+ private constructor(options: RedisSessionStorageOptions) {
93
+ this.#client = options.client;
94
+ this.#prefix = options.prefix ?? DEFAULT_PREFIX;
95
+ this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
96
+ }
97
+
98
+ /**
99
+ * Warm the in-memory mirror with every existing session key under the
100
+ * configured prefix and return the ready-to-use storage. Must be awaited
101
+ * before passing the storage into `SessionManager.create()` so synchronous
102
+ * lookups (session resume, recent sessions, EPERM-backup recovery) see
103
+ * the existing keyspace.
104
+ */
105
+ static async create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage> {
106
+ const storage = new RedisSessionStorage(options);
107
+ await storage.refresh();
108
+ return storage;
109
+ }
110
+
111
+ /**
112
+ * Re-scan Redis and replace the mirror's contents. Call this from a
113
+ * different process that took over a session keyspace, or after an
114
+ * out-of-band write made by another agent.
115
+ */
116
+ async refresh(): Promise<void> {
117
+ this.#mirror.clear();
118
+ const filePrefix = this.#fileKey("");
119
+ const metaRaw = await this.#client.hgetall(this.#metaKey());
120
+ const meta: Record<string, string> = metaRaw ?? {};
121
+
122
+ const seen = new Set<string>();
123
+ let cursor = "0";
124
+ do {
125
+ const [next, batch] = await this.#client.scan(
126
+ cursor,
127
+ "MATCH",
128
+ `${filePrefix}*`,
129
+ "COUNT",
130
+ String(this.#scanCount),
131
+ );
132
+ cursor = next;
133
+ for (const key of batch) seen.add(key);
134
+ } while (cursor !== "0");
135
+
136
+ await Promise.all(
137
+ Array.from(seen, async key => {
138
+ const path = key.slice(filePrefix.length);
139
+ const content = await this.#client.get(key);
140
+ if (content === null) return;
141
+ const mtimeRaw = meta[path];
142
+ const mtimeMs = mtimeRaw ? Number(mtimeRaw) : Date.now();
143
+ this.#mirror.set(path, { content, mtimeMs });
144
+ if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
145
+ }),
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Resolve once every pending background write (issued via `writeTextSync`
151
+ * or `writer.writeLineSync`) has been acknowledged by Redis. Throws if any
152
+ * background write failed since the last drain.
153
+ *
154
+ * Call this on graceful shutdown to avoid losing the last unflushed line.
155
+ * The session-manager's own `flush()` / `close()` already drain through
156
+ * the writer chain — this method exists for callers (test harnesses,
157
+ * subprocess-style consumers) that bypass the writer.
158
+ */
159
+ async drain(): Promise<void> {
160
+ // Take ownership of the current tail, then reset so subsequent
161
+ // operations start from a clean (resolved) chain. Without the reset,
162
+ // any failure observed here would also be re-thrown by every later
163
+ // write that piggybacks on the tail via `#trackPending`.
164
+ const tail = this.#pendingTail;
165
+ this.#pendingTail = Promise.resolve();
166
+ await tail;
167
+ }
168
+
169
+ #fileKey(path: string): string {
170
+ return `${this.#prefix}file:${path}`;
171
+ }
172
+
173
+ #metaKey(): string {
174
+ return `${this.#prefix}meta`;
175
+ }
176
+
177
+ /**
178
+ * Allocate a strictly monotonic mtime. Multiple writes within the same
179
+ * millisecond would otherwise yield identical `mtimeMs` values and break
180
+ * `getSortedSessions`' newest-first ordering.
181
+ */
182
+ #allocMtimeMs(): number {
183
+ const now = Date.now();
184
+ const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
185
+ this.#nextMtimeMs = next;
186
+ return next;
187
+ }
188
+
189
+ #trackPending(promise: Promise<void>): void {
190
+ // `Promise.all` rejects if either input rejects, which is exactly
191
+ // what we want for `drain()`. The follow-up `.catch(() => {})` is
192
+ // attached only to silence the unhandled-rejection signal on the
193
+ // shared tail — `drain()` keeps its own handler chain and still
194
+ // observes the original error, because rejection delivery is
195
+ // per-handler-chain, not per-promise.
196
+ this.#pendingTail = Promise.all([this.#pendingTail, promise]).then(() => {});
197
+ this.#pendingTail.catch(() => {});
198
+ }
199
+
200
+ // --- sync surface ---------------------------------------------------------
201
+
202
+ ensureDirSync(_dir: string): void {
203
+ // Redis is flat: directories are derived from key prefixes.
204
+ }
205
+
206
+ existsSync(path: string): boolean {
207
+ return this.#mirror.has(path);
208
+ }
209
+
210
+ writeTextSync(path: string, content: string): void {
211
+ const mtimeMs = this.#allocMtimeMs();
212
+ this.#mirror.set(path, { content, mtimeMs });
213
+ this.#trackPending(this.#writeRemote(path, content, mtimeMs));
214
+ }
215
+
216
+ readTextSync(path: string): string {
217
+ const entry = this.#mirror.get(path);
218
+ if (!entry) throw enoent(path);
219
+ return entry.content;
220
+ }
221
+
222
+ statSync(path: string): SessionStorageStat {
223
+ const entry = this.#mirror.get(path);
224
+ if (!entry) throw enoent(path);
225
+ return {
226
+ size: Buffer.byteLength(entry.content, "utf-8"),
227
+ mtimeMs: entry.mtimeMs,
228
+ mtime: new Date(entry.mtimeMs),
229
+ };
230
+ }
231
+
232
+ listFilesSync(dir: string, pattern: string): string[] {
233
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
234
+ const out: string[] = [];
235
+ for (const path of this.#mirror.keys()) {
236
+ if (!path.startsWith(prefix)) continue;
237
+ const name = path.slice(prefix.length);
238
+ if (name.includes("/")) continue;
239
+ if (!matchesGlob(name, pattern)) continue;
240
+ out.push(path);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ // --- async surface --------------------------------------------------------
246
+
247
+ async exists(path: string): Promise<boolean> {
248
+ // Mirror is the source of truth; checking Redis would only diverge
249
+ // when a peer process mutated the key, which is outside the
250
+ // storage's contract (see class JSDoc).
251
+ return this.#mirror.has(path);
252
+ }
253
+
254
+ async readText(path: string): Promise<string> {
255
+ const entry = this.#mirror.get(path);
256
+ if (!entry) throw enoent(path);
257
+ return entry.content;
258
+ }
259
+
260
+ async readTextPrefix(path: string, maxBytes: number): Promise<string> {
261
+ const entry = this.#mirror.get(path);
262
+ if (!entry) throw enoent(path);
263
+ if (maxBytes <= 0) return "";
264
+ // `entry.content` is a JS string (UTF-16 code units), but the prefix
265
+ // contract is byte-oriented. Encode to UTF-8, slice, then decode —
266
+ // matching `peekFile`'s behaviour for the file-backed storage.
267
+ const bytes = Buffer.from(entry.content, "utf-8");
268
+ const slice = bytes.subarray(0, Math.min(maxBytes, bytes.byteLength));
269
+ return slice.toString("utf-8");
270
+ }
271
+
272
+ async writeText(path: string, content: string): Promise<void> {
273
+ const mtimeMs = this.#allocMtimeMs();
274
+ this.#mirror.set(path, { content, mtimeMs });
275
+ await this.#writeRemote(path, content, mtimeMs);
276
+ }
277
+
278
+ async rename(src: string, dst: string): Promise<void> {
279
+ const entry = this.#mirror.get(src);
280
+ if (!entry) throw enoent(src);
281
+ // Update the mirror first so a synchronous existsSync() right after
282
+ // the await resolves consistently. If RENAME fails the mirror is
283
+ // rolled back below.
284
+ this.#mirror.delete(src);
285
+ this.#mirror.set(dst, entry);
286
+
287
+ try {
288
+ await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
289
+ } catch (err) {
290
+ this.#mirror.delete(dst);
291
+ this.#mirror.set(src, entry);
292
+ throw toError(err);
293
+ }
294
+
295
+ // Move the mtime hash entry too. Failures here cause meta drift but
296
+ // the mirror cache keeps statSync accurate, so log and continue.
297
+ try {
298
+ await this.#client.hdel(this.#metaKey(), src);
299
+ await this.#client.hset(this.#metaKey(), dst, String(entry.mtimeMs));
300
+ } catch (err) {
301
+ logger.warn("Redis session storage meta rename failed", {
302
+ src,
303
+ dst,
304
+ error: toError(err).message,
305
+ });
306
+ }
307
+ }
308
+
309
+ async unlink(path: string): Promise<void> {
310
+ const existed = this.#mirror.delete(path);
311
+ await this.#client.del(this.#fileKey(path));
312
+ await this.#client.hdel(this.#metaKey(), path);
313
+ if (!existed) {
314
+ throw enoent(path);
315
+ }
316
+ }
317
+
318
+ async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
319
+ await this.unlink(sessionPath);
320
+
321
+ // Mirror artifacts live under `<sessionPath without .jsonl>/...`. The
322
+ // Redis storage doesn't actually persist tool artifact bytes — those
323
+ // stay on disk via `ArtifactManager` — but a draft sidecar may have
324
+ // been written through `writeText`. Sweep any keys under that prefix.
325
+ const artifactsDir = sessionPath.slice(0, -6);
326
+ const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
327
+ const victims: string[] = [];
328
+ for (const key of this.#mirror.keys()) {
329
+ if (key.startsWith(prefix)) victims.push(key);
330
+ }
331
+ if (victims.length === 0) return;
332
+
333
+ for (const key of victims) this.#mirror.delete(key);
334
+ await this.#client.del(...victims.map(v => this.#fileKey(v)));
335
+ await this.#client.hdel(this.#metaKey(), ...victims);
336
+ }
337
+
338
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
339
+ const writer = new RedisSessionStorageWriter(this, path, options);
340
+ this.#writers.add(writer);
341
+ return writer;
342
+ }
343
+
344
+ // --- writer support -------------------------------------------------------
345
+
346
+ _writerClosed(writer: RedisSessionStorageWriter): void {
347
+ this.#writers.delete(writer);
348
+ }
349
+
350
+ /** Mirror-only mutation, no Redis call. Used by writers to update local state synchronously. */
351
+ _mirrorAppend(path: string, line: string): void {
352
+ const existing = this.#mirror.get(path);
353
+ const content = existing ? existing.content + line : line;
354
+ this.#mirror.set(path, { content, mtimeMs: this.#allocMtimeMs() });
355
+ }
356
+
357
+ /** Mirror-only mutation, no Redis call. Used by writers opened with `flags: "w"` to truncate. */
358
+ _mirrorTruncate(path: string): void {
359
+ this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
360
+ }
361
+
362
+ async _remoteTruncate(path: string): Promise<void> {
363
+ const entry = this.#mirror.get(path);
364
+ const mtimeMs = entry?.mtimeMs ?? Date.now();
365
+ await this.#client.set(this.#fileKey(path), "");
366
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
367
+ }
368
+
369
+ async _remoteAppend(path: string, line: string): Promise<void> {
370
+ await this.#client.append(this.#fileKey(path), line);
371
+ const entry = this.#mirror.get(path);
372
+ if (entry) {
373
+ await this.#client.hset(this.#metaKey(), path, String(entry.mtimeMs));
374
+ }
375
+ }
376
+
377
+ /** Record a writer's pending promise on the storage-level tail so `drain()` waits for it. */
378
+ _attachPending(promise: Promise<void>): void {
379
+ this.#trackPending(promise);
380
+ }
381
+
382
+ async #writeRemote(path: string, content: string, mtimeMs: number): Promise<void> {
383
+ await this.#client.set(this.#fileKey(path), content);
384
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
385
+ }
386
+ }
387
+
388
+ class RedisSessionStorageWriter implements SessionStorageWriter {
389
+ #storage: RedisSessionStorage;
390
+ #path: string;
391
+ #closed = false;
392
+ #error: Error | undefined;
393
+ #onError: ((err: Error) => void) | undefined;
394
+ #pendingChain: Promise<void> = Promise.resolve();
395
+
396
+ constructor(
397
+ storage: RedisSessionStorage,
398
+ path: string,
399
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
400
+ ) {
401
+ this.#storage = storage;
402
+ this.#path = path;
403
+ this.#onError = options?.onError;
404
+ const flags = options?.flags ?? "a";
405
+ if (flags === "w") {
406
+ // "w" mirrors FileSessionStorageWriter passing `"w"` to
407
+ // `fs.openSync`: start from empty content. Materialize the
408
+ // truncate in the mirror synchronously so an immediate reader
409
+ // can't observe stale content, then queue the remote SET.
410
+ storage._mirrorTruncate(path);
411
+ this.#enqueueRaw(() => storage._remoteTruncate(path));
412
+ }
413
+ }
414
+
415
+ #recordError(err: unknown): Error {
416
+ const error = toError(err);
417
+ if (!this.#error) this.#error = error;
418
+ this.#onError?.(error);
419
+ return error;
420
+ }
421
+
422
+ #enqueueRaw(task: () => Promise<void>): Promise<void> {
423
+ const next = this.#pendingChain.then(async () => {
424
+ if (this.#error) throw this.#error;
425
+ try {
426
+ await task();
427
+ } catch (err) {
428
+ throw this.#recordError(err);
429
+ }
430
+ });
431
+ this.#pendingChain = next.catch(() => {
432
+ // Errors are recorded on `this.#error`; subsequent enqueues
433
+ // throw from inside the wrapper above. The outer chain swallows
434
+ // to avoid surfacing as an unhandled promise rejection.
435
+ });
436
+ // Storage-level drain() waits for every writer's pending work too.
437
+ this.#storage._attachPending(next);
438
+ return next;
439
+ }
440
+
441
+ writeLineSync(line: string): void {
442
+ if (this.#closed) throw new Error("Writer closed");
443
+ if (this.#error) throw this.#error;
444
+ this.#storage._mirrorAppend(this.#path, line);
445
+ this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
446
+ }
447
+
448
+ async writeLine(line: string): Promise<void> {
449
+ if (this.#closed) throw new Error("Writer closed");
450
+ if (this.#error) throw this.#error;
451
+ this.#storage._mirrorAppend(this.#path, line);
452
+ await this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
453
+ }
454
+
455
+ async flush(): Promise<void> {
456
+ if (this.#error) throw this.#error;
457
+ await this.#enqueueRaw(async () => {});
458
+ if (this.#error) throw this.#error;
459
+ }
460
+
461
+ async fsync(): Promise<void> {
462
+ // Bun's `RedisClient` has no fsync equivalent; APPEND/SET return only
463
+ // after the server has acknowledged the write. `flush()` already
464
+ // awaits that ack, so this collapses into a drain.
465
+ await this.flush();
466
+ }
467
+
468
+ async close(): Promise<void> {
469
+ if (this.#closed) return;
470
+ this.#closed = true;
471
+ try {
472
+ await this.flush();
473
+ } finally {
474
+ this.#storage._writerClosed(this);
475
+ }
476
+ }
477
+
478
+ getError(): Error | undefined {
479
+ return this.#error;
480
+ }
481
+ }