@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.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 (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
@@ -1,5 +1,9 @@
1
1
  import { logger, toError } from "@oh-my-pi/pi-utils";
2
- import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
2
+ import {
3
+ IndexedSessionStorage,
4
+ type SessionStorageBackend,
5
+ type SessionStorageIndexEntry,
6
+ } from "./indexed-session-storage";
3
7
 
4
8
  /**
5
9
  * Minimal subset of the `bun:redis` `RedisClient` surface used by
@@ -9,6 +13,8 @@ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from ".
9
13
  */
10
14
  export interface RedisSessionStorageClient {
11
15
  get(key: string): Promise<string | null>;
16
+ getrange(key: string, start: number, end: number): Promise<string>;
17
+ strlen(key: string): Promise<number>;
12
18
  set(key: string, value: string): Promise<unknown>;
13
19
  append(key: string, value: string): Promise<number>;
14
20
  del(...keys: string[]): Promise<number>;
@@ -29,92 +35,52 @@ export interface RedisSessionStorageOptions {
29
35
  */
30
36
  prefix?: string;
31
37
  /**
32
- * Maximum number of keys returned per SCAN batch when warming the mirror.
38
+ * Maximum number of keys returned per SCAN batch when warming the metadata index.
33
39
  * Default 500.
34
40
  */
35
41
  scanCount?: number;
36
42
  }
37
43
 
38
- interface MirrorEntry {
39
- content: string;
40
- mtimeMs: number;
41
- }
42
-
43
44
  const DEFAULT_PREFIX = "omp:sessions:";
44
45
  const DEFAULT_SCAN_COUNT = 500;
45
46
 
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
47
  /**
62
48
  * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
63
49
  * 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.
50
+ * single sibling HASH. This process keeps only a metadata index (`size`,
51
+ * `mtimeMs`) in memory so synchronous `existsSync` / `statSync` /
52
+ * `listFilesSync` calls remain available without mirroring full content.
82
53
  */
83
- export class RedisSessionStorage implements SessionStorage {
54
+ export class RedisSessionStorage extends IndexedSessionStorage {
55
+ /**
56
+ * Warm the metadata index with every existing session key under the configured
57
+ * prefix and return the ready-to-use storage. Must be awaited before passing
58
+ * the storage into `SessionManager.create()` so synchronous lookups (session
59
+ * resume, recent sessions, EPERM-backup recovery) see the existing keyspace.
60
+ */
61
+ static async create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage> {
62
+ const storage = new RedisSessionStorage(new RedisSessionStorageBackend(options));
63
+ await storage.initialize();
64
+ return storage;
65
+ }
66
+ }
67
+
68
+ class RedisSessionStorageBackend implements SessionStorageBackend {
84
69
  readonly #client: RedisSessionStorageClient;
85
70
  readonly #prefix: string;
86
71
  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
72
 
92
- private constructor(options: RedisSessionStorageOptions) {
73
+ constructor(options: RedisSessionStorageOptions) {
93
74
  this.#client = options.client;
94
75
  this.#prefix = options.prefix ?? DEFAULT_PREFIX;
95
76
  this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
96
77
  }
97
78
 
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;
79
+ init(): Promise<void> {
80
+ return Promise.resolve();
109
81
  }
110
82
 
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();
83
+ async loadIndex(): Promise<SessionStorageIndexEntry[]> {
118
84
  const filePrefix = this.#fileKey("");
119
85
  const metaRaw = await this.#client.hgetall(this.#metaKey());
120
86
  const meta: Record<string, string> = metaRaw ?? {};
@@ -133,170 +99,58 @@ export class RedisSessionStorage implements SessionStorage {
133
99
  for (const key of batch) seen.add(key);
134
100
  } while (cursor !== "0");
135
101
 
136
- await Promise.all(
102
+ const fallbackMtimeMs = Date.now();
103
+ return Promise.all(
137
104
  Array.from(seen, async key => {
138
105
  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;
106
+ const size = await this.#client.strlen(key);
107
+ const rawMtime = meta[path];
108
+ const parsedMtime = rawMtime === undefined ? Number.NaN : Number(rawMtime);
109
+ return {
110
+ path,
111
+ size,
112
+ mtimeMs: Number.isFinite(parsedMtime) ? parsedMtime : fallbackMtimeMs,
113
+ };
145
114
  }),
146
115
  );
147
116
  }
148
117
 
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}`;
118
+ readFull(path: string): Promise<string | null> {
119
+ return this.#client.get(this.#fileKey(path));
171
120
  }
172
121
 
173
- #metaKey(): string {
174
- return `${this.#prefix}meta`;
122
+ async readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
123
+ const key = this.#fileKey(path);
124
+ const head = prefixBytes > 0 ? this.#client.getrange(key, 0, prefixBytes - 1) : Promise.resolve("");
125
+ const tail = suffixBytes > 0 ? this.#client.getrange(key, -suffixBytes, -1) : Promise.resolve("");
126
+ return Promise.all([head, tail]);
175
127
  }
176
128
 
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);
129
+ async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
130
+ await this.#client.set(this.#fileKey(path), content);
131
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
252
132
  }
253
133
 
254
- async readText(path: string): Promise<string> {
255
- const entry = this.#mirror.get(path);
256
- if (!entry) throw enoent(path);
257
- return entry.content;
134
+ async append(path: string, line: string, mtimeMs: number): Promise<void> {
135
+ await this.#client.append(this.#fileKey(path), line);
136
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
258
137
  }
259
138
 
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");
139
+ async truncate(path: string, mtimeMs: number): Promise<void> {
140
+ await this.writeFull(path, "", mtimeMs);
270
141
  }
271
142
 
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);
143
+ async remove(paths: string[]): Promise<void> {
144
+ if (paths.length === 0) return;
145
+ await this.#client.del(...paths.map(path => this.#fileKey(path)));
146
+ await this.#client.hdel(this.#metaKey(), ...paths);
276
147
  }
277
148
 
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.
149
+ async move(src: string, dst: string, mtimeMs: number): Promise<void> {
150
+ await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
297
151
  try {
298
152
  await this.#client.hdel(this.#metaKey(), src);
299
- await this.#client.hset(this.#metaKey(), dst, String(entry.mtimeMs));
153
+ await this.#client.hset(this.#metaKey(), dst, String(mtimeMs));
300
154
  } catch (err) {
301
155
  logger.warn("Redis session storage meta rename failed", {
302
156
  src,
@@ -306,176 +160,11 @@ export class RedisSessionStorage implements SessionStorage {
306
160
  }
307
161
  }
308
162
 
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
- }
163
+ #fileKey(path: string): string {
164
+ return `${this.#prefix}file:${path}`;
476
165
  }
477
166
 
478
- getError(): Error | undefined {
479
- return this.#error;
167
+ #metaKey(): string {
168
+ return `${this.#prefix}meta`;
480
169
  }
481
170
  }