@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +74 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/config/settings-schema.d.ts +36 -22
  4. package/dist/types/debug/protocol-probe.d.ts +38 -0
  5. package/dist/types/debug/terminal-info.d.ts +34 -0
  6. package/dist/types/export/html/template.generated.d.ts +1 -1
  7. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -1
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  13. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  14. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  15. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +20 -1
  19. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  20. package/dist/types/session/agent-session.d.ts +1 -1
  21. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  22. package/dist/types/session/redis-session-storage.d.ts +12 -85
  23. package/dist/types/session/session-manager.d.ts +20 -0
  24. package/dist/types/session/session-storage.d.ts +5 -7
  25. package/dist/types/session/sql-session-storage.d.ts +16 -85
  26. package/dist/types/task/executor.d.ts +9 -0
  27. package/dist/types/task/index.d.ts +3 -1
  28. package/dist/types/telemetry-export.d.ts +19 -0
  29. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  30. package/dist/types/tools/ask.d.ts +1 -0
  31. package/dist/types/tools/index.d.ts +4 -2
  32. package/dist/types/tools/path-utils.d.ts +1 -1
  33. package/dist/types/tools/search.d.ts +2 -2
  34. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  35. package/dist/types/utils/session-color.d.ts +7 -2
  36. package/dist/types/web/search/index.d.ts +1 -1
  37. package/dist/types/web/search/provider.d.ts +2 -2
  38. package/dist/types/web/search/types.d.ts +65 -1
  39. package/package.json +15 -9
  40. package/scripts/build-binary.ts +12 -0
  41. package/src/capability/skill.ts +7 -0
  42. package/src/cli/args.ts +1 -1
  43. package/src/config/settings-schema.ts +22 -55
  44. package/src/debug/index.ts +67 -1
  45. package/src/debug/protocol-probe.ts +267 -0
  46. package/src/debug/terminal-info.ts +127 -0
  47. package/src/export/html/template.generated.ts +1 -1
  48. package/src/export/html/template.js +3 -3
  49. package/src/extensibility/custom-tools/types.ts +1 -1
  50. package/src/extensibility/extensions/types.ts +11 -0
  51. package/src/extensibility/shared-events.ts +1 -1
  52. package/src/extensibility/skills.ts +3 -3
  53. package/src/index.ts +1 -0
  54. package/src/internal-urls/docs-index.generated.ts +7 -7
  55. package/src/main.ts +12 -0
  56. package/src/mcp/transports/stdio.ts +37 -12
  57. package/src/modes/acp/acp-event-mapper.ts +7 -7
  58. package/src/modes/components/assistant-message.ts +3 -2
  59. package/src/modes/components/hook-selector.ts +149 -14
  60. package/src/modes/components/session-selector.ts +36 -5
  61. package/src/modes/components/status-line/segments.ts +2 -1
  62. package/src/modes/components/status-line.ts +1 -1
  63. package/src/modes/components/tips.txt +1 -1
  64. package/src/modes/components/todo-reminder.ts +1 -1
  65. package/src/modes/components/tool-execution.ts +9 -4
  66. package/src/modes/components/transcript-container.ts +91 -0
  67. package/src/modes/controllers/event-controller.ts +12 -7
  68. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  69. package/src/modes/controllers/todo-command-controller.ts +1 -1
  70. package/src/modes/interactive-mode.ts +18 -6
  71. package/src/modes/print-mode.ts +5 -0
  72. package/src/modes/rpc/rpc-types.ts +1 -1
  73. package/src/modes/theme/theme.ts +48 -8
  74. package/src/modes/utils/ui-helpers.ts +1 -0
  75. package/src/plan-mode/plan-handoff.ts +37 -0
  76. package/src/priority.json +4 -0
  77. package/src/prompts/goals/goal-continuation.md +1 -1
  78. package/src/prompts/system/eager-todo.md +3 -3
  79. package/src/prompts/system/orchestrate-notice.md +5 -5
  80. package/src/prompts/system/plan-mode-approved.md +14 -17
  81. package/src/prompts/system/plan-mode-reference.md +3 -6
  82. package/src/prompts/system/subagent-system-prompt.md +11 -0
  83. package/src/prompts/system/workflow-notice.md +1 -1
  84. package/src/prompts/tools/browser.md +5 -2
  85. package/src/prompts/tools/search.md +1 -1
  86. package/src/prompts/tools/task.md +3 -1
  87. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  88. package/src/sdk.ts +1 -0
  89. package/src/session/agent-session.ts +14 -14
  90. package/src/session/indexed-session-storage.ts +430 -0
  91. package/src/session/redis-session-storage.ts +66 -377
  92. package/src/session/session-manager.ts +102 -22
  93. package/src/session/session-storage.ts +148 -68
  94. package/src/session/sql-session-storage.ts +131 -382
  95. package/src/slash-commands/helpers/todo.ts +2 -2
  96. package/src/task/executor.ts +9 -1
  97. package/src/task/index.ts +51 -1
  98. package/src/telemetry-export.ts +126 -0
  99. package/src/tiny/compiled-runtime.ts +179 -0
  100. package/src/tiny/worker.ts +24 -2
  101. package/src/tools/ask.ts +133 -87
  102. package/src/tools/fetch.ts +17 -4
  103. package/src/tools/find.ts +2 -2
  104. package/src/tools/index.ts +6 -4
  105. package/src/tools/path-utils.ts +16 -7
  106. package/src/tools/renderers.ts +2 -2
  107. package/src/tools/search.ts +6 -3
  108. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  109. package/src/utils/session-color.ts +39 -14
  110. package/src/web/search/index.ts +1 -1
  111. package/src/web/search/provider.ts +18 -34
  112. package/src/web/search/types.ts +73 -32
@@ -38,6 +38,8 @@ Subagents have no conversation history. Every fact, file path, and direction the
38
38
  - Pass large payloads via `local://<path>` URIs, not inline. {{#if contextEnabled}} (other than the context){{/if}}
39
39
  {{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
40
40
  - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
41
+ - **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands — they cannot do it and the turn is wasted. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
42
+ - **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
41
43
  </rules>
42
44
 
43
45
  <parallelization>
@@ -71,7 +73,7 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
71
73
  Agent spawning is disabled for this context.
72
74
  {{else}}
73
75
  {{#list agents join="\n"}}
74
- # {{name}}
76
+ # {{name}}{{#if readOnly}} — READ-ONLY (no edit/write/exec tools){{/if}}
75
77
  {{description}}
76
78
  {{/list}}
77
79
  {{/if}}
@@ -50,9 +50,9 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
50
50
  </examples>
51
51
 
52
52
  <critical>
53
- When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
54
- - You MUST `init` the list with EVERY item as its own task before doing the work.
55
- - Enumerate all of them;
56
- - NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
53
+ When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
54
+ - You MUST `init` the list with EVERY item as its own task before doing the work.
55
+ - Enumerate all of them;
56
+ - NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
57
57
  The entire point is to remember every one.
58
- </critical>
58
+ </critical>
package/src/sdk.ts CHANGED
@@ -1259,6 +1259,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1259
1259
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1260
1260
  getActiveModelString,
1261
1261
  getPlanModeState: () => session?.getPlanModeState(),
1262
+ getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
1262
1263
  getGoalModeState: () => session?.getGoalModeState(),
1263
1264
  getGoalRuntime: () => session?.goalRuntime,
1264
1265
  getUsageStatistics: () => sessionManager.getUsageStatistics(),
@@ -203,7 +203,7 @@ import type { CheckpointState } from "../tools/checkpoint";
203
203
  import { outputMeta } from "../tools/output-meta";
204
204
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
205
205
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
206
- import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
206
+ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
207
207
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
208
208
  import { clampTimeout } from "../tools/tool-timeouts";
209
209
  import { parseCommandArgs } from "../utils/command-args";
@@ -1807,21 +1807,21 @@ export class AgentSession {
1807
1807
  if (toolName === "edit" && details?.path) {
1808
1808
  this.#invalidateFileCacheForPath(details.path);
1809
1809
  }
1810
- if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
1810
+ if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
1811
1811
  this.setTodoPhases(details.phases);
1812
1812
  }
1813
- if (toolName === "todo_write" && isError) {
1813
+ if (toolName === "todo" && isError) {
1814
1814
  const errorText = content?.find(part => part.type === "text")?.text;
1815
1815
  const reminderText = [
1816
1816
  "<system-reminder>",
1817
- "todo_write failed, so todo progress is not visible to the user.",
1818
- errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
1819
- "Fix the todo payload and call todo_write again before continuing.",
1817
+ "todo failed, so todo progress is not visible to the user.",
1818
+ errorText ? `Failure: ${errorText}` : "Failure: todo returned an error.",
1819
+ "Fix the todo payload and call todo again before continuing.",
1820
1820
  "</system-reminder>",
1821
1821
  ].join("\n");
1822
1822
  await this.sendCustomMessage(
1823
1823
  {
1824
- customType: "todo-write-error-reminder",
1824
+ customType: "todo-error-reminder",
1825
1825
  content: reminderText,
1826
1826
  display: false,
1827
1827
  details: { toolName, errorText },
@@ -4994,7 +4994,7 @@ export class AgentSession {
4994
4994
  // splice mutated canonical `#todoPhases` between tool calls, so the model
4995
4995
  // observed phase totals shrinking ("5 → 4") after marking tasks done. The
4996
4996
  // `tasks.todoClearDelay` setting is now inert; completed tasks survive
4997
- // until the next explicit `todo_write` call removes them via `rm`/`drop`.
4997
+ // until the next explicit `todo` call removes them via `rm`/`drop`.
4998
4998
 
4999
4999
  /**
5000
5000
  * Abort current operation and wait for agent to become idle.
@@ -6553,16 +6553,16 @@ export class AgentSession {
6553
6553
  return undefined;
6554
6554
  }
6555
6555
 
6556
- if (!this.#toolRegistry.has("todo_write")) {
6557
- logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
6556
+ if (!this.#toolRegistry.has("todo")) {
6557
+ logger.warn("Eager todo enforcement skipped because todo is unavailable", {
6558
6558
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
6559
6559
  });
6560
6560
  return undefined;
6561
6561
  }
6562
6562
 
6563
- const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
6564
- if (!todoWriteToolChoice) {
6565
- logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
6563
+ const todoToolChoice = buildNamedToolChoice("todo", this.model);
6564
+ if (!todoToolChoice) {
6565
+ logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo", {
6566
6566
  modelApi: this.model?.api,
6567
6567
  modelId: this.model?.id,
6568
6568
  });
@@ -6580,7 +6580,7 @@ export class AgentSession {
6580
6580
  attribution: "agent",
6581
6581
  timestamp: Date.now(),
6582
6582
  },
6583
- toolChoice: todoWriteToolChoice,
6583
+ toolChoice: todoToolChoice,
6584
6584
  };
6585
6585
  }
6586
6586
  /**
@@ -0,0 +1,430 @@
1
+ import { toError } from "@oh-my-pi/pi-utils";
2
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+
4
+ export interface SessionStorageIndexEntry {
5
+ path: string;
6
+ size: number;
7
+ mtimeMs: number;
8
+ }
9
+
10
+ export interface SessionStorageBackend {
11
+ init(): Promise<void>;
12
+ loadIndex(): Promise<Iterable<SessionStorageIndexEntry>>;
13
+ readFull(path: string): Promise<string | null>;
14
+ readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
15
+ writeFull(path: string, content: string, mtimeMs: number): Promise<void>;
16
+ append(path: string, line: string, mtimeMs: number): Promise<void>;
17
+ truncate(path: string, mtimeMs: number): Promise<void>;
18
+ remove(paths: string[]): Promise<void>;
19
+ move(src: string, dst: string, mtimeMs: number): Promise<void>;
20
+ }
21
+
22
+ interface IndexEntry {
23
+ size: number;
24
+ mtimeMs: number;
25
+ }
26
+
27
+ interface EnqueueOptions {
28
+ trackDrain: boolean;
29
+ }
30
+
31
+ const RESOLVED = Promise.resolve();
32
+
33
+ function enoent(p: string): NodeJS.ErrnoException {
34
+ const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
35
+ err.code = "ENOENT";
36
+ err.errno = -2;
37
+ err.path = p;
38
+ err.syscall = "open";
39
+ return err;
40
+ }
41
+
42
+ function matchesGlob(name: string, pattern: string): boolean {
43
+ if (pattern === "*") return true;
44
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
45
+ return name === pattern;
46
+ }
47
+
48
+ function byteLength(text: string): number {
49
+ return Buffer.byteLength(text, "utf-8");
50
+ }
51
+
52
+ function normalizeByteLimit(maxBytes: number): number {
53
+ if (!(maxBytes > 0)) return 0;
54
+ return Math.trunc(maxBytes);
55
+ }
56
+
57
+ function uniquePaths(paths: readonly string[]): string[] {
58
+ const out: string[] = [];
59
+ const seen = new Set<string>();
60
+ for (const path of paths) {
61
+ if (seen.has(path)) continue;
62
+ seen.add(path);
63
+ out.push(path);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export class IndexedSessionStorage implements SessionStorage {
69
+ readonly #backend: SessionStorageBackend;
70
+ readonly #index = new Map<string, IndexEntry>();
71
+ readonly #writers = new Set<IndexedSessionStorageWriter>();
72
+ readonly #pathTails = new Map<string, Promise<void>>();
73
+ readonly #pathPending = new Map<string, Promise<void>>();
74
+ readonly #drainPending = new Set<Promise<void>>();
75
+ #nextMtimeMs = 0;
76
+ #firstDrainError: Error | undefined;
77
+
78
+ constructor(backend: SessionStorageBackend) {
79
+ this.#backend = backend;
80
+ }
81
+
82
+ async initialize(): Promise<void> {
83
+ await this.#backend.init();
84
+ await this.refresh();
85
+ }
86
+
87
+ async refresh(): Promise<void> {
88
+ await this.drain();
89
+ const rows = await this.#backend.loadIndex();
90
+ this.#index.clear();
91
+ for (const row of rows) {
92
+ this.#setIndex(row.path, row.size, row.mtimeMs);
93
+ }
94
+ }
95
+
96
+ async drain(): Promise<void> {
97
+ while (this.#drainPending.size > 0) {
98
+ await Promise.allSettled(this.#drainPending);
99
+ }
100
+ const error = this.#firstDrainError;
101
+ this.#firstDrainError = undefined;
102
+ if (error) throw error;
103
+ }
104
+
105
+ ensureDirSync(_dir: string): void {
106
+ // Indexed backends are flat: directories are derived from key prefixes.
107
+ }
108
+
109
+ existsSync(path: string): boolean {
110
+ return this.#index.has(path);
111
+ }
112
+
113
+ writeTextSync(path: string, content: string): void {
114
+ const mtimeMs = this.#allocMtimeMs();
115
+ this.#setIndex(path, byteLength(content), mtimeMs);
116
+ this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: true });
117
+ }
118
+
119
+ statSync(path: string): SessionStorageStat {
120
+ const entry = this.#index.get(path);
121
+ if (!entry) throw enoent(path);
122
+ return {
123
+ size: entry.size,
124
+ mtimeMs: entry.mtimeMs,
125
+ mtime: new Date(entry.mtimeMs),
126
+ };
127
+ }
128
+
129
+ listFilesSync(dir: string, pattern: string): string[] {
130
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
131
+ const out: string[] = [];
132
+ for (const path of this.#index.keys()) {
133
+ if (!path.startsWith(prefix)) continue;
134
+ const name = path.slice(prefix.length);
135
+ if (name.includes("/") || name.includes("\\")) continue;
136
+ if (!matchesGlob(name, pattern)) continue;
137
+ out.push(path);
138
+ }
139
+ return out;
140
+ }
141
+
142
+ exists(path: string): Promise<boolean> {
143
+ return Promise.resolve(this.existsSync(path));
144
+ }
145
+
146
+ async readText(path: string): Promise<string> {
147
+ if (!this.#index.has(path)) throw enoent(path);
148
+ await this.#awaitPath(path);
149
+ const content = await this.#backend.readFull(path);
150
+ if (content === null) throw enoent(path);
151
+ return content;
152
+ }
153
+
154
+ async readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
155
+ if (!this.#index.has(path)) throw enoent(path);
156
+ const prefixLimit = normalizeByteLimit(prefixBytes);
157
+ const suffixLimit = normalizeByteLimit(suffixBytes);
158
+ if (prefixLimit === 0 && suffixLimit === 0) return ["", ""];
159
+ await this.#awaitPath(path);
160
+ return this.#backend.readSlices(path, prefixLimit, suffixLimit);
161
+ }
162
+
163
+ async writeText(path: string, content: string): Promise<void> {
164
+ await this.#awaitPath(path);
165
+ const previous = this.#index.get(path);
166
+ const mtimeMs = this.#allocMtimeMs();
167
+ this.#setIndex(path, byteLength(content), mtimeMs);
168
+ try {
169
+ await this.#enqueuePath(path, () => this.#backend.writeFull(path, content, mtimeMs), { trackDrain: false });
170
+ } catch (err) {
171
+ this.#restoreIndex(path, previous);
172
+ throw toError(err);
173
+ }
174
+ }
175
+
176
+ async rename(src: string, dst: string): Promise<void> {
177
+ await this.#awaitPath(src);
178
+ await this.#awaitPath(dst);
179
+ const entry = this.#index.get(src);
180
+ if (!entry) throw enoent(src);
181
+ const dstPrevious = this.#index.get(dst);
182
+ this.#index.delete(src);
183
+ this.#index.set(dst, { ...entry });
184
+ try {
185
+ await this.#enqueuePaths([src, dst], () => this.#backend.move(src, dst, entry.mtimeMs), { trackDrain: false });
186
+ } catch (err) {
187
+ this.#index.delete(dst);
188
+ this.#restoreIndex(dst, dstPrevious);
189
+ this.#index.set(src, entry);
190
+ throw toError(err);
191
+ }
192
+ }
193
+
194
+ async unlink(path: string): Promise<void> {
195
+ await this.#awaitPath(path);
196
+ const previous = this.#index.get(path);
197
+ if (!previous) throw enoent(path);
198
+ this.#index.delete(path);
199
+ try {
200
+ await this.#enqueuePath(path, () => this.#backend.remove([path]), { trackDrain: false });
201
+ } catch (err) {
202
+ this.#index.set(path, previous);
203
+ throw toError(err);
204
+ }
205
+ }
206
+
207
+ async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
208
+ await this.#awaitPath(sessionPath);
209
+ const sessionEntry = this.#index.get(sessionPath);
210
+ if (!sessionEntry) throw enoent(sessionPath);
211
+
212
+ const artifactsDir = sessionPath.slice(0, -6);
213
+ const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
214
+ const paths = [sessionPath];
215
+ for (const key of this.#index.keys()) {
216
+ if (key.startsWith(prefix)) paths.push(key);
217
+ }
218
+
219
+ for (const path of paths) await this.#awaitPath(path);
220
+
221
+ const previous = new Map<string, IndexEntry>();
222
+ for (const path of paths) {
223
+ const entry = this.#index.get(path);
224
+ if (entry) previous.set(path, entry);
225
+ this.#index.delete(path);
226
+ }
227
+
228
+ try {
229
+ await this.#enqueuePaths(paths, () => this.#backend.remove(paths), { trackDrain: false });
230
+ } catch (err) {
231
+ for (const [path, entry] of previous) this.#index.set(path, entry);
232
+ throw toError(err);
233
+ }
234
+ }
235
+
236
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
237
+ const writer = new IndexedSessionStorageWriter(this, path, options);
238
+ this.#writers.add(writer);
239
+ return writer;
240
+ }
241
+
242
+ _writerClosed(writer: IndexedSessionStorageWriter): void {
243
+ this.#writers.delete(writer);
244
+ }
245
+
246
+ _truncateForWriter(path: string): number {
247
+ const mtimeMs = this.#allocMtimeMs();
248
+ this.#setIndex(path, 0, mtimeMs);
249
+ return mtimeMs;
250
+ }
251
+
252
+ _queueTruncate(path: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
253
+ return this.#enqueuePath(
254
+ path,
255
+ async () => {
256
+ const error = getError?.();
257
+ if (error) throw error;
258
+ await this.#backend.truncate(path, mtimeMs);
259
+ },
260
+ { trackDrain: true },
261
+ );
262
+ }
263
+
264
+ _appendForWriter(path: string, line: string): number {
265
+ const mtimeMs = this.#allocMtimeMs();
266
+ const existing = this.#index.get(path);
267
+ const size = (existing?.size ?? 0) + byteLength(line);
268
+ this.#setIndex(path, size, mtimeMs);
269
+ return mtimeMs;
270
+ }
271
+
272
+ _queueAppend(path: string, line: string, mtimeMs: number, getError?: () => Error | undefined): Promise<void> {
273
+ return this.#enqueuePath(
274
+ path,
275
+ async () => {
276
+ const error = getError?.();
277
+ if (error) throw error;
278
+ await this.#backend.append(path, line, mtimeMs);
279
+ },
280
+ { trackDrain: true },
281
+ );
282
+ }
283
+
284
+ #restoreIndex(path: string, entry: IndexEntry | undefined): void {
285
+ if (entry) {
286
+ this.#index.set(path, entry);
287
+ } else {
288
+ this.#index.delete(path);
289
+ }
290
+ }
291
+
292
+ #setIndex(path: string, size: number, mtimeMs: number): void {
293
+ this.#index.set(path, { size, mtimeMs });
294
+ if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
295
+ }
296
+
297
+ #allocMtimeMs(): number {
298
+ const now = Date.now();
299
+ const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
300
+ this.#nextMtimeMs = next;
301
+ return next;
302
+ }
303
+
304
+ #enqueuePath(path: string, task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
305
+ return this.#enqueuePaths([path], task, options);
306
+ }
307
+
308
+ #enqueuePaths(paths: readonly string[], task: () => Promise<void>, options: EnqueueOptions): Promise<void> {
309
+ const unique = uniquePaths(paths);
310
+ const previous = unique.map(path => this.#pathTails.get(path) ?? RESOLVED);
311
+ const operation = Promise.all(previous).then(task);
312
+ const tracked = operation.catch(err => {
313
+ const error = toError(err);
314
+ if (options.trackDrain && !this.#firstDrainError) this.#firstDrainError = error;
315
+ throw error;
316
+ });
317
+ const tail = tracked.catch(() => {});
318
+ for (const path of unique) {
319
+ this.#pathTails.set(path, tail);
320
+ this.#pathPending.set(path, tracked);
321
+ }
322
+ tail.finally(() => {
323
+ for (const path of unique) {
324
+ if (this.#pathTails.get(path) === tail) this.#pathTails.delete(path);
325
+ }
326
+ });
327
+ tracked
328
+ .finally(() => {
329
+ for (const path of unique) {
330
+ if (this.#pathPending.get(path) === tracked) this.#pathPending.delete(path);
331
+ }
332
+ })
333
+ .catch(() => {});
334
+ tracked.catch(() => {});
335
+ if (options.trackDrain) {
336
+ this.#drainPending.add(tracked);
337
+ tracked
338
+ .finally(() => {
339
+ this.#drainPending.delete(tracked);
340
+ })
341
+ .catch(() => {});
342
+ }
343
+ return tracked;
344
+ }
345
+
346
+ #awaitPath(path: string): Promise<void> {
347
+ return this.#pathPending.get(path) ?? RESOLVED;
348
+ }
349
+ }
350
+
351
+ class IndexedSessionStorageWriter implements SessionStorageWriter {
352
+ #storage: IndexedSessionStorage;
353
+ #path: string;
354
+ #closed = false;
355
+ #error: Error | undefined;
356
+ #onError: ((err: Error) => void) | undefined;
357
+ #pendingChain: Promise<void> = Promise.resolve();
358
+
359
+ constructor(
360
+ storage: IndexedSessionStorage,
361
+ path: string,
362
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
363
+ ) {
364
+ this.#storage = storage;
365
+ this.#path = path;
366
+ this.#onError = options?.onError;
367
+ if ((options?.flags ?? "a") === "w") {
368
+ const mtimeMs = storage._truncateForWriter(path);
369
+ this.#trackPromise(storage._queueTruncate(path, mtimeMs, () => this.#error));
370
+ }
371
+ }
372
+
373
+ #recordError(err: unknown): Error {
374
+ const error = toError(err);
375
+ if (!this.#error) this.#error = error;
376
+ this.#onError?.(error);
377
+ return error;
378
+ }
379
+
380
+ #trackPromise(promise: Promise<void>): Promise<void> {
381
+ const next = this.#pendingChain.then(async () => {
382
+ if (this.#error) throw this.#error;
383
+ try {
384
+ await promise;
385
+ } catch (err) {
386
+ throw this.#recordError(err);
387
+ }
388
+ });
389
+ this.#pendingChain = next.catch(() => {});
390
+ return next;
391
+ }
392
+
393
+ writeLineSync(line: string): void {
394
+ if (this.#closed) throw new Error("Writer closed");
395
+ if (this.#error) throw this.#error;
396
+ const mtimeMs = this.#storage._appendForWriter(this.#path, line);
397
+ this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
398
+ }
399
+
400
+ async writeLine(line: string): Promise<void> {
401
+ if (this.#closed) throw new Error("Writer closed");
402
+ if (this.#error) throw this.#error;
403
+ const mtimeMs = this.#storage._appendForWriter(this.#path, line);
404
+ await this.#trackPromise(this.#storage._queueAppend(this.#path, line, mtimeMs, () => this.#error));
405
+ }
406
+
407
+ async flush(): Promise<void> {
408
+ if (this.#error) throw this.#error;
409
+ await this.#pendingChain;
410
+ if (this.#error) throw this.#error;
411
+ }
412
+
413
+ async fsync(): Promise<void> {
414
+ await this.flush();
415
+ }
416
+
417
+ async close(): Promise<void> {
418
+ if (this.#closed) return;
419
+ this.#closed = true;
420
+ try {
421
+ await this.flush();
422
+ } finally {
423
+ this.#storage._writerClosed(this);
424
+ }
425
+ }
426
+
427
+ getError(): Error | undefined {
428
+ return this.#error;
429
+ }
430
+ }