@oh-my-pi/pi-coding-agent 15.8.2 → 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 (116) hide show
  1. package/CHANGELOG.md +82 -29
  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/plan-mode/plan-protection.d.ts +12 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  23. package/dist/types/session/redis-session-storage.d.ts +12 -85
  24. package/dist/types/session/session-manager.d.ts +20 -0
  25. package/dist/types/session/session-storage.d.ts +5 -7
  26. package/dist/types/session/sql-session-storage.d.ts +16 -85
  27. package/dist/types/task/executor.d.ts +9 -0
  28. package/dist/types/task/index.d.ts +3 -1
  29. package/dist/types/telemetry-export.d.ts +19 -0
  30. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  31. package/dist/types/tools/ask.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -2
  33. package/dist/types/tools/path-utils.d.ts +1 -1
  34. package/dist/types/tools/search.d.ts +2 -2
  35. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  36. package/dist/types/utils/jj.d.ts +1 -1
  37. package/dist/types/utils/session-color.d.ts +7 -2
  38. package/dist/types/web/search/index.d.ts +1 -1
  39. package/dist/types/web/search/provider.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +65 -1
  41. package/package.json +15 -9
  42. package/scripts/build-binary.ts +12 -0
  43. package/src/capability/skill.ts +7 -0
  44. package/src/cli/args.ts +1 -1
  45. package/src/config/settings-schema.ts +22 -55
  46. package/src/debug/index.ts +67 -1
  47. package/src/debug/protocol-probe.ts +267 -0
  48. package/src/debug/terminal-info.ts +127 -0
  49. package/src/export/html/template.generated.ts +1 -1
  50. package/src/export/html/template.js +3 -3
  51. package/src/extensibility/custom-tools/types.ts +1 -1
  52. package/src/extensibility/extensions/types.ts +11 -0
  53. package/src/extensibility/shared-events.ts +1 -1
  54. package/src/extensibility/skills.ts +3 -3
  55. package/src/index.ts +1 -0
  56. package/src/internal-urls/docs-index.generated.ts +7 -7
  57. package/src/main.ts +12 -0
  58. package/src/mcp/transports/stdio.ts +37 -12
  59. package/src/modes/acp/acp-event-mapper.ts +7 -7
  60. package/src/modes/components/assistant-message.ts +3 -2
  61. package/src/modes/components/hook-selector.ts +149 -14
  62. package/src/modes/components/session-selector.ts +36 -5
  63. package/src/modes/components/status-line/segments.ts +2 -1
  64. package/src/modes/components/status-line.ts +1 -1
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/todo-reminder.ts +1 -1
  67. package/src/modes/components/tool-execution.ts +9 -4
  68. package/src/modes/components/transcript-container.ts +91 -0
  69. package/src/modes/controllers/event-controller.ts +12 -7
  70. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  71. package/src/modes/controllers/todo-command-controller.ts +1 -1
  72. package/src/modes/interactive-mode.ts +18 -6
  73. package/src/modes/print-mode.ts +5 -0
  74. package/src/modes/rpc/rpc-types.ts +1 -1
  75. package/src/modes/theme/theme.ts +48 -8
  76. package/src/modes/utils/ui-helpers.ts +1 -0
  77. package/src/plan-mode/plan-handoff.ts +37 -0
  78. package/src/plan-mode/plan-protection.ts +31 -0
  79. package/src/priority.json +4 -0
  80. package/src/prompts/goals/goal-continuation.md +1 -1
  81. package/src/prompts/system/eager-todo.md +3 -3
  82. package/src/prompts/system/orchestrate-notice.md +5 -5
  83. package/src/prompts/system/plan-mode-approved.md +14 -17
  84. package/src/prompts/system/plan-mode-reference.md +3 -6
  85. package/src/prompts/system/subagent-system-prompt.md +11 -0
  86. package/src/prompts/system/workflow-notice.md +1 -1
  87. package/src/prompts/tools/browser.md +5 -2
  88. package/src/prompts/tools/search.md +1 -1
  89. package/src/prompts/tools/task.md +3 -1
  90. package/src/prompts/tools/{todo-write.md → todo.md} +8 -0
  91. package/src/sdk.ts +1 -0
  92. package/src/session/agent-session.ts +29 -16
  93. package/src/session/indexed-session-storage.ts +430 -0
  94. package/src/session/redis-session-storage.ts +66 -377
  95. package/src/session/session-manager.ts +102 -22
  96. package/src/session/session-storage.ts +148 -68
  97. package/src/session/sql-session-storage.ts +131 -382
  98. package/src/slash-commands/helpers/todo.ts +2 -2
  99. package/src/task/executor.ts +9 -1
  100. package/src/task/index.ts +51 -1
  101. package/src/telemetry-export.ts +126 -0
  102. package/src/tiny/compiled-runtime.ts +179 -0
  103. package/src/tiny/worker.ts +24 -2
  104. package/src/tools/ask.ts +133 -87
  105. package/src/tools/fetch.ts +17 -4
  106. package/src/tools/find.ts +2 -2
  107. package/src/tools/index.ts +6 -4
  108. package/src/tools/path-utils.ts +16 -7
  109. package/src/tools/renderers.ts +2 -2
  110. package/src/tools/search.ts +6 -3
  111. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  112. package/src/utils/jj.ts +28 -5
  113. package/src/utils/session-color.ts +39 -14
  114. package/src/web/search/index.ts +1 -1
  115. package/src/web/search/provider.ts +18 -34
  116. package/src/web/search/types.ts +73 -32
@@ -25,7 +25,7 @@ Drives a real Chromium tab with full puppeteer access via JS execution.
25
25
  - `tab.waitForUrl(pattern, { timeout? })` — pattern is a substring or `RegExp`. Polls `location.href` so it works for SPA pushState navigations, not just real navigations. Returns the matched URL.
26
26
  - `tab.waitForResponse(pattern, { timeout? })` — pattern is a substring, `RegExp`, or `(response) => boolean`. Returns the raw puppeteer `HTTPResponse` (call `.text()` / `.json()` / `.status()` / `.headers()` on it).
27
27
  - `tab.evaluate(fn, …args)` — sugar for `page.evaluate` with the abort signal already wired. Use this instead of dropping to `page.evaluate` for ad-hoc DOM reads.
28
- - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — auto-attaches the image to the tool output unless `silent: true`. Saves full-res to `save` (or `browser.screenshotDir` setting) and a downscaled copy to the model.
28
+ - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — captures a screenshot and **auto-attaches it to the tool output for you to view** (unless `silent: true`). `save` is **strictly optional**: OMIT it when you just want to look at the page — the downscaled image is shown to you regardless, and the full-res capture is written to a temp file automatically. Pass `save` (a path) ONLY when you deliberately need to keep a full-res copy on disk for later use; `browser.screenshotDir` does the same for every shot. Do NOT invent a `save` path for a throwaway/temporal screenshot.
29
29
  - `tab.extract(format = "markdown")` — Readability-extracted page content.
30
30
  - Selectors accept CSS as well as puppeteer query handlers: `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`. Playwright-style `p-aria/[name="…"]`, `p-text/…`, etc. are normalized.
31
31
  - Default to `tab.observe()` over `tab.screenshot()` for understanding page state. Screenshot only when visual appearance matters.
@@ -46,7 +46,10 @@ Drives a real Chromium tab with full puppeteer access via JS execution.
46
46
  # Click an observed element by id
47
47
  `{"action":"run","name":"docs","code":"const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();"}`
48
48
 
49
- # Save a full-page screenshot to disk
49
+ # Take a transient screenshot just to look at the page — NO save path needed; the image is shown to you
50
+ `{"action":"run","name":"docs","code":"await tab.screenshot();"}`
51
+
52
+ # Persist a full-page screenshot to disk (only when you deliberately need to keep the file)
50
53
  `{"action":"run","name":"docs","code":"await tab.screenshot({ fullPage: true, save: 'screenshot.png' });"}`
51
54
 
52
55
  # Fill and submit a form via selectors
@@ -2,7 +2,7 @@ Searches files using powerful regex matching.
2
2
 
3
3
  <instruction>
4
4
  - Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)
5
- - `paths` is required and accepts either one string or an array of files, directories, globs, or internal URLs
5
+ - `paths` accepts either one string or an array of files, directories, globs, or internal URLs. Optional: when omitted or empty it searches the workspace root (`.`). Prefer scoping to specific paths when you know them.
6
6
  - For multiple targets, pass an array with one target per element: `["src", "tests"]`.
7
7
  - Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
8
8
  </instruction>
@@ -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}}
@@ -48,3 +48,11 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
48
48
  # Append tasks to a phase
49
49
  `{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
50
50
  </examples>
51
+
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.
57
+ The entire point is to remember every one.
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(),
@@ -55,6 +55,7 @@ import {
55
55
  shouldCompact,
56
56
  } from "@oh-my-pi/pi-agent-core/compaction";
57
57
  import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
58
+ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
58
59
  import type {
59
60
  AssistantMessage,
60
61
  Context,
@@ -163,6 +164,7 @@ import { parseTurnBudget } from "../modes/turn-budget";
163
164
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
164
165
  import { computeNonMessageTokens } from "../modes/utils/context-usage";
165
166
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
167
+ import { createPlanReadMatcher } from "../plan-mode/plan-protection";
166
168
  import type { PlanModeState } from "../plan-mode/state";
167
169
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
168
170
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -201,7 +203,7 @@ import type { CheckpointState } from "../tools/checkpoint";
201
203
  import { outputMeta } from "../tools/output-meta";
202
204
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
203
205
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
204
- import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
206
+ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
205
207
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
206
208
  import { clampTimeout } from "../tools/tool-timeouts";
207
209
  import { parseCommandArgs } from "../utils/command-args";
@@ -1805,21 +1807,21 @@ export class AgentSession {
1805
1807
  if (toolName === "edit" && details?.path) {
1806
1808
  this.#invalidateFileCacheForPath(details.path);
1807
1809
  }
1808
- if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
1810
+ if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
1809
1811
  this.setTodoPhases(details.phases);
1810
1812
  }
1811
- if (toolName === "todo_write" && isError) {
1813
+ if (toolName === "todo" && isError) {
1812
1814
  const errorText = content?.find(part => part.type === "text")?.text;
1813
1815
  const reminderText = [
1814
1816
  "<system-reminder>",
1815
- "todo_write failed, so todo progress is not visible to the user.",
1816
- errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
1817
- "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.",
1818
1820
  "</system-reminder>",
1819
1821
  ].join("\n");
1820
1822
  await this.sendCustomMessage(
1821
1823
  {
1822
- customType: "todo-write-error-reminder",
1824
+ customType: "todo-error-reminder",
1823
1825
  content: reminderText,
1824
1826
  display: false,
1825
1827
  details: { toolName, errorText },
@@ -4992,7 +4994,7 @@ export class AgentSession {
4992
4994
  // splice mutated canonical `#todoPhases` between tool calls, so the model
4993
4995
  // observed phase totals shrinking ("5 → 4") after marking tasks done. The
4994
4996
  // `tasks.todoClearDelay` setting is now inert; completed tasks survive
4995
- // until the next explicit `todo_write` call removes them via `rm`/`drop`.
4997
+ // until the next explicit `todo` call removes them via `rm`/`drop`.
4996
4998
 
4997
4999
  /**
4998
5000
  * Abort current operation and wait for agent to become idle.
@@ -5658,9 +5660,20 @@ export class AgentSession {
5658
5660
  // Compaction
5659
5661
  // =========================================================================
5660
5662
 
5663
+ /**
5664
+ * Append plan-read protection to a prune/shake config so the active plan
5665
+ * file survives compaction alongside skill reads (the config defaults
5666
+ * already carry skill protection). The matcher reads the current plan
5667
+ * reference path at match time, so retitled plans are covered.
5668
+ */
5669
+ #withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
5670
+ const planMatcher = createPlanReadMatcher(() => this.#planReferencePath);
5671
+ return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
5672
+ }
5673
+
5661
5674
  async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
5662
5675
  const branchEntries = this.sessionManager.getBranch();
5663
- const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
5676
+ const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG));
5664
5677
  if (result.prunedCount === 0) {
5665
5678
  return undefined;
5666
5679
  }
@@ -5741,7 +5754,7 @@ export class AgentSession {
5741
5754
  return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
5742
5755
  }
5743
5756
 
5744
- const config = opts.config ?? AGGRESSIVE_SHAKE_CONFIG;
5757
+ const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
5745
5758
  const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
5746
5759
  if (regions.length === 0) {
5747
5760
  return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
@@ -6540,16 +6553,16 @@ export class AgentSession {
6540
6553
  return undefined;
6541
6554
  }
6542
6555
 
6543
- if (!this.#toolRegistry.has("todo_write")) {
6544
- 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", {
6545
6558
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
6546
6559
  });
6547
6560
  return undefined;
6548
6561
  }
6549
6562
 
6550
- const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
6551
- if (!todoWriteToolChoice) {
6552
- 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", {
6553
6566
  modelApi: this.model?.api,
6554
6567
  modelId: this.model?.id,
6555
6568
  });
@@ -6567,7 +6580,7 @@ export class AgentSession {
6567
6580
  attribution: "agent",
6568
6581
  timestamp: Date.now(),
6569
6582
  },
6570
- toolChoice: todoWriteToolChoice,
6583
+ toolChoice: todoToolChoice,
6571
6584
  };
6572
6585
  }
6573
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
+ }