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

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 (42) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli-commands.d.ts +19 -0
  3. package/dist/types/commands/install.d.ts +51 -0
  4. package/dist/types/discovery/index.d.ts +1 -0
  5. package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
  6. package/dist/types/discovery/omp-plugins.d.ts +1 -0
  7. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  9. package/dist/types/extensibility/plugins/loader.d.ts +12 -2
  10. package/dist/types/index.d.ts +3 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/ultrathink.d.ts +10 -0
  13. package/dist/types/session/redis-session-storage.d.ts +124 -0
  14. package/dist/types/session/sql-session-storage.d.ts +141 -0
  15. package/dist/types/tools/todo-write.d.ts +30 -0
  16. package/examples/sdk/12-redis-sessions.ts +54 -0
  17. package/examples/sdk/13-sql-sessions.ts +61 -0
  18. package/package.json +8 -8
  19. package/scripts/build-binary.ts +14 -9
  20. package/src/cli-commands.ts +44 -0
  21. package/src/cli.ts +2 -32
  22. package/src/commands/install.ts +107 -0
  23. package/src/discovery/index.ts +1 -0
  24. package/src/discovery/omp-extension-roots.ts +190 -0
  25. package/src/discovery/omp-plugins.ts +383 -0
  26. package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
  28. package/src/extensibility/plugins/loader.ts +43 -18
  29. package/src/index.ts +3 -0
  30. package/src/internal-urls/docs-index.generated.ts +2 -2
  31. package/src/main.ts +12 -0
  32. package/src/memories/index.ts +8 -3
  33. package/src/modes/components/custom-editor.ts +3 -0
  34. package/src/modes/interactive-mode.ts +243 -12
  35. package/src/modes/ultrathink.ts +79 -0
  36. package/src/prompts/system/ultrathink-notice.md +3 -0
  37. package/src/session/agent-session.ts +28 -0
  38. package/src/session/redis-session-storage.ts +481 -0
  39. package/src/session/sql-session-storage.ts +565 -0
  40. package/src/tools/read.ts +23 -6
  41. package/src/tools/todo-write.ts +64 -0
  42. package/src/tools/write.ts +40 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.12] - 2026-05-29
6
+
7
+ ### Added
8
+
9
+ - Added the `omp-plugins` discovery provider, which scans every extension package directory configured via `extensions:` (in `~/.omp/agent/settings.json` or `<cwd>/.omp/settings.json`) or `--extension`/`-e` on the CLI for `skills/`, `hooks/pre|post/`, `tools/`, `commands/`, `rules/`, `prompts/`, and `.mcp.json`. Prior to this, only the extension's TypeScript factory module ran; every sibling capability the docs (https://omp.sh/docs/extension-authoring) advertised was silently ignored ([#1496](https://github.com/can1357/oh-my-pi/issues/1496)).
10
+ - Added the top-level `omp install <target>` subcommand documented at https://omp.sh/docs/extension-authoring. Local paths route to `omp plugin link` (so the directory is symlinked into the plugin set), and npm/marketplace specs route to `omp plugin install`. Before this, `install` was not a registered subcommand and the CLI runner silently forwarded `install ./my-extension` to `launch` as an initial LLM prompt ([#1496](https://github.com/can1357/oh-my-pi/issues/1496)).
11
+
12
+ ### Changed
13
+
14
+ - Changed the sticky `Todos` panel above the editor to advance as tasks close, instead of pinning to the first 5 tasks of the active phase. `selectStickyTodoWindow` now shows up to 5 open (pending / in_progress) tasks in original phase order and reports the count of remaining open tasks for the `+N more` hint, so every `todo_write` flip produces a visible row shift. Closed-phase tail falls back to the last 5 tasks (with the `+N more` line suppressed) until `getActivePhase` walks to the next phase.
15
+ - Linked the sticky `Todos` panel to the live `SessionObserverRegistry` so pending todos that have an in-flight subagent doing their work light up green with an animated spinner — the same `theme.spinnerFrames` ("status" preset) the `task` tool uses for its agent rows — instead of staying greyed out as if nothing is happening. A new exported `todoMatchesAnyDescription(content, descriptions)` does case- and whitespace-insensitive equality first with a 6-char minimum-overlap substring fallback in either direction, so "Sonnet #2: shallow bug scan" and a subagent description of "Sonnet #2" still link up. Completed todos now render with `theme.status.success` (✔ / `\uf00c` / `[ok]` per symbol preset, still wrapped in the `success` colour so themed palettes can keep their purple/green/whatever) and in_progress rows render with `theme.status.running`, matching the `task` tool's icon vocabulary. The spinner interval only ticks while at least one visible open todo has a matched active subagent, and self-stops once subagents finish, so plain in_progress todos do not animate forever in the absence of subagent activity.
16
+ - Extracted the top-level CLI command table from `src/cli.ts` into a side-effect-free `src/cli-commands.ts` so test code can introspect the registered subcommands without triggering the entrypoint's top-level await.
17
+
18
+ ## [15.5.11] - 2026-05-29
19
+
20
+ ### Added
21
+
22
+ - Added `SqlSessionStorage`, a `bun:sql`-backed implementation of `SessionStorage` that persists session JSONL into PostgreSQL, MySQL/MariaDB, or SQLite. Pass a connected `Bun.SQL` instance (the constructor accepts `postgres://`, `mysql://`, or `sqlite:` URLs) to `SqlSessionStorage.create({ client, table?, adapter?, createTable? })` and hand the returned storage to any `SessionManager` factory. The dialect is auto-detected from `client.options.adapter` and used to pick the correct DDL plus upsert-with-append syntax (`ON CONFLICT … DO UPDATE` for PG/SQLite, `ON DUPLICATE KEY UPDATE` for MySQL), so the agent's append-only persist pattern works in a single round-trip per line. Same in-memory mirror and `drain()` semantics as the Redis backend; blobs and tool artifacts still live on disk via `ArtifactManager`/`BlobStore`.
23
+ - Added `RedisSessionStorage`, a `bun:redis`-backed implementation of the `SessionStorage` interface that lets API consumers route session JSONL through Redis instead of local disk. Pass a connected `Bun.RedisClient` (or any compatible adapter) to `RedisSessionStorage.create({ client, prefix? })` and hand the returned storage to `SessionManager.create(cwd, sessionDir, storage)` (or any other static factory that accepts a storage argument). An in-memory mirror is loaded on creation so the interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`, …) keep their contracts; `drain()` waits for queued background writes. Tool artifacts and image blobs still live on disk via `ArtifactManager`/`BlobStore` — Redis only owns the session JSONL keyspace under the configured prefix.
24
+ - Exported the `SessionStorage` / `SessionStorageWriter` / `FileSessionStorage` / `MemorySessionStorage` symbols (already reachable via the `./session/session-storage` subpath) from the package root so SDK consumers can construct alternative storage backends without deep-importing.
25
+ - Added a fresh `¶<relative-path>#TAG` snapshot header to the `write` tool's success text in hashline display mode, covering plain disk writes, ACP-bridge writes, and conflict resolutions (bulk resolutions emit a trailing `Snapshots:` block with one header per successfully written file). The header records a current snapshot in the file-snapshot store so the next `edit` can land without an extra `read` round-trip. Suppressed when the session is not in hashline mode and skipped for archive/SQLite writes and host-managed internal URL targets where hashline anchors do not apply.
26
+
27
+ ### Changed
28
+
29
+ - The `edit` tool's stale-snapshot rejection message now distinguishes "file changed between read and edit" (the section's hash was recorded in this session but the file has since drifted — a prior in-session edit advanced it, or an external write changed it) from "hash #X is not from this session" (a fabricated or carried-over cross-session tag), the latter carrying explicit "never invent the tag" guidance. Both messages include the current file hash plus 2 lines of context around each anchor so the next attempt has everything it needs. Snapshot-based recovery still runs first; the sharper diagnostics only surface when recovery cannot reconcile the edit.
30
+
31
+ ### Fixed
32
+
33
+ - Fixed Autonomous Memory phase 1/phase 2 failing with `Thinking effort low is not supported by <provider>/<model>` on models whose supported reasoning efforts exclude `low`/`medium` (e.g. `deepseek/deepseek-v4-pro`). Both stage1 (`Effort.Low`) and consolidation (`Effort.Medium`) call sites in `packages/coding-agent/src/memories/index.ts` now route through `clampThinkingLevelForModel`, lifting the requested effort to the model's lowest supported level instead of letting `requireSupportedEffort` throw ([#1480](https://github.com/can1357/oh-my-pi/issues/1480)).
34
+
5
35
  ## [15.5.10] - 2026-05-28
6
36
 
7
37
  ### Added
@@ -12,6 +42,10 @@
12
42
 
13
43
  - Fixed compaction surfacing raw HTTP 401/403 envelopes (e.g. `Compaction failed: 401 {"type":"error","error":{"type":"authentication_error",…}}`) instead of routing to an authenticated fallback model. The compaction layer now attaches the provider-reported HTTP status onto the thrown error, and `AgentSession`'s auth-failure detector branches on `error.status === 401 || 403` in addition to the existing `auth_unavailable` regex. When a fallback model role (e.g. `modelRoles.smol`) is configured, compaction retries it transparently; otherwise the user sees the actionable "Compaction requires usable credentials for …" hint instead of the raw provider envelope.
14
44
 
45
+ ### Fixed
46
+
47
+ - Fixed compiled-binary legacy plugin loading for `@earendil-works/*` imports of bundled package roots such as `@earendil-works/pi-coding-agent`; compat now rewrites all bundled pi package roots to bunfs entrypoints and resolves fallback peer dependencies through the canonical `@oh-my-pi/*` specifier.
48
+
15
49
  ## [15.5.8] - 2026-05-28
16
50
 
17
51
  ### Breaking Changes
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Top-level CLI command table.
3
+ *
4
+ * Lives in its own module (importable without side effects) so that tests can
5
+ * inspect the registered subcommands without triggering the side-effectful
6
+ * top-level await in `cli.ts`. Adding a new subcommand here is enough to make
7
+ * `runCli` route to it instead of forwarding the argv as a prompt to
8
+ * `launch` — see #1496 for the original "args silently leak to the LLM"
9
+ * regression that motivated the split.
10
+ */
11
+ import type { CommandEntry } from "@oh-my-pi/pi-utils/cli";
12
+ export declare const commands: CommandEntry[];
13
+ /**
14
+ * Return true when `first` matches a registered subcommand name or alias.
15
+ *
16
+ * Flags (`-…`) and `@file` arguments are never subcommands; for those the CLI
17
+ * runner skips ahead to the default `launch` command.
18
+ */
19
+ export declare function isSubcommand(first: string | undefined): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `omp install <target>` — top-level convenience over `omp plugin install` /
3
+ * `omp plugin link`.
4
+ *
5
+ * The docs (omp.sh/docs/extension-authoring) advertise
6
+ *
7
+ * omp install ./my-extension
8
+ *
9
+ * as a third loading mechanism that "symlinks the directory into the plugin
10
+ * set and watches it for changes". Before this command existed, `install` was
11
+ * not a registered subcommand, so the CLI runner forwarded the argv to the
12
+ * default `launch` command and the model received `install ./my-extension`
13
+ * as an initial prompt — see #1496.
14
+ *
15
+ * Local-path targets (`./foo`, `/abs/foo`, `~/foo`, or an existing directory)
16
+ * route to `plugin link` so they are symlinked into the plugin set, matching
17
+ * the documented behavior. Everything else (`pkg`, `pkg@1.2.3`,
18
+ * `name@marketplace`) routes to `plugin install`.
19
+ */
20
+ import { Command } from "@oh-my-pi/pi-utils/cli";
21
+ /**
22
+ * Heuristic used to decide whether `omp install <target>` should `link` a
23
+ * local directory or `install` a remote spec. Exported for tests.
24
+ */
25
+ export declare function looksLikeLocalPath(target: string): boolean;
26
+ export default class Install extends Command {
27
+ static description: string;
28
+ static args: {
29
+ targets: import("@oh-my-pi/pi-utils/cli").ArgDescriptor & {
30
+ description: string;
31
+ required: false;
32
+ multiple: true;
33
+ };
34
+ };
35
+ static flags: {
36
+ json: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
37
+ description: string;
38
+ };
39
+ force: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
40
+ description: string;
41
+ };
42
+ "dry-run": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
43
+ description: string;
44
+ };
45
+ scope: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
46
+ description: string;
47
+ options: string[];
48
+ };
49
+ };
50
+ run(): Promise<void>;
51
+ }
@@ -30,6 +30,7 @@ import "./gemini";
30
30
  import "./opencode";
31
31
  import "./github";
32
32
  import "./mcp-json";
33
+ import "./omp-plugins";
33
34
  import "./ssh";
34
35
  import "./vscode";
35
36
  import "./windsurf";
@@ -0,0 +1,43 @@
1
+ import type { LoadContext } from "../capability/types";
2
+ /** A resolved extension package directory wired into the discovery surfaces. */
3
+ export interface OmpExtensionRoot {
4
+ /** Absolute path to the package directory. */
5
+ path: string;
6
+ /** Stable display name (basename of the package directory). */
7
+ name: string;
8
+ /** Scope from which the path was sourced. */
9
+ level: "user" | "project";
10
+ }
11
+ /**
12
+ * Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
13
+ * so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
14
+ * etc. Paths that do not resolve to a directory are silently dropped — file
15
+ * entrypoints have no package sub-tree to scan.
16
+ *
17
+ * Call once during startup before any capability load. Repeated calls extend
18
+ * the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
19
+ */
20
+ export declare function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void;
21
+ /** Drop every CLI-injected root. Tests use this between cases. */
22
+ export declare function clearOmpExtensionCliRoots(): void;
23
+ /** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
24
+ export declare function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[];
25
+ /**
26
+ * Resolve every configured extension package directory for the given context.
27
+ *
28
+ * Sources, in order of precedence (later entries with the same absolute path
29
+ * are dropped):
30
+ *
31
+ * 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
32
+ * 2. Project `<cwd>/.omp/settings.json#extensions`
33
+ * 3. User `~/.omp/agent/settings.json#extensions`
34
+ * 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
35
+ * `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
36
+ *
37
+ * Only entries that resolve to a directory on disk are returned; file
38
+ * entrypoints contribute zero sub-discovery surface and are filtered out.
39
+ * Installed-plugin enumeration failures (missing lockfile, unreadable
40
+ * `package.json`, etc.) are logged at `debug` and degrade gracefully — the
41
+ * other sources still surface.
42
+ */
43
+ export declare function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Compatibility shim for legacy extensions importing the package root of
3
+ * `@oh-my-pi/pi-coding-agent` (or one of its aliased scopes like
4
+ * `@earendil-works/pi-coding-agent` or `@mariozechner/pi-coding-agent`).
5
+ *
6
+ * The coding-agent package's own barrel (`./src/index.ts`) cannot be listed
7
+ * as a `bun --compile` extra entrypoint alongside the CLI entry without
8
+ * silently breaking the main binary's startup (see issue #1474 follow-up).
9
+ * Routing legacy plugin imports through this sibling shim sidesteps that
10
+ * conflict: bun bundles a distinct entry whose path differs from the CLI
11
+ * entry, while still re-exporting the canonical surface so plugins observe
12
+ * the same module identity as a direct `@oh-my-pi/pi-coding-agent` import.
13
+ */
14
+ export * from "../index";
@@ -1,2 +1,4 @@
1
1
  export declare function loadLegacyPiModule(resolvedPath: string): Promise<unknown>;
2
2
  export declare function installLegacyPiSpecifierShim(): void;
3
+ /** Test seam: clears the memoized canonical specifier resolutions. */
4
+ export declare function __resetLegacyPiResolutionCache(): void;
@@ -1,9 +1,19 @@
1
1
  import type { InstalledPlugin } from "./types";
2
2
  /**
3
3
  * Get list of enabled plugins with their resolved configurations.
4
- * Respects both global runtime config and project overrides.
4
+ *
5
+ * Respects both global runtime config and project overrides. Iterates the
6
+ * union of `<plugins>/package.json#dependencies` (`bun install`-installed
7
+ * packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
8
+ * `plugin link`-symlinked extensions, which never get a dependency entry,
9
+ * are still discovered). The optional `home` parameter pins the plugins
10
+ * root for callers that need to enumerate plugins relative to a non-default
11
+ * home (tests with a tempdir, discovery loaders threaded with
12
+ * `LoadContext.home`).
5
13
  */
6
- export declare function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]>;
14
+ export declare function getEnabledPlugins(cwd: string, opts?: {
15
+ home?: string;
16
+ }): Promise<InstalledPlugin[]>;
7
17
  export declare function resolvePluginToolPaths(plugin: InstalledPlugin): string[];
8
18
  export declare function resolvePluginHookPaths(plugin: InstalledPlugin): string[];
9
19
  export declare function resolvePluginCommandPaths(plugin: InstalledPlugin): string[];
@@ -23,8 +23,11 @@ export * from "./sdk";
23
23
  export * from "./session/agent-session";
24
24
  export * from "./session/auth-storage";
25
25
  export * from "./session/messages";
26
+ export * from "./session/redis-session-storage";
26
27
  export * from "./session/session-dump-format";
27
28
  export * from "./session/session-manager";
29
+ export * from "./session/session-storage";
30
+ export * from "./session/sql-session-storage";
28
31
  export * from "./task/executor";
29
32
  export type * from "./task/types";
30
33
  export * from "./tools";
@@ -1,11 +1,14 @@
1
1
  import { Editor, type KeyId } from "@oh-my-pi/pi-tui";
2
2
  import type { AppKeybinding } from "../../config/keybindings";
3
+ import { highlightUltrathink } from "../ultrathink";
3
4
  type ConfigurableEditorAction = Extract<AppKeybinding, "app.interrupt" | "app.clear" | "app.exit" | "app.suspend" | "app.thinking.cycle" | "app.model.cycleForward" | "app.model.cycleBackward" | "app.model.select" | "app.model.selectTemporary" | "app.tools.expand" | "app.thinking.toggle" | "app.editor.external" | "app.history.search" | "app.message.dequeue" | "app.clipboard.pasteImage" | "app.clipboard.copyPrompt">;
4
5
  /**
5
6
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
6
7
  */
7
8
  export declare class CustomEditor extends Editor {
8
9
  #private;
10
+ /** Rainbow-highlight the "ultrathink" keyword as the user types it. */
11
+ decorateText: typeof highlightUltrathink;
9
12
  onEscape?: () => void;
10
13
  shouldBypassAutocompleteOnEscape?: () => boolean;
11
14
  onClear?: () => void;
@@ -0,0 +1,10 @@
1
+ /** Hidden system notice appended after a user message that mentions "ultrathink". */
2
+ export declare const ULTRATHINK_NOTICE: string;
3
+ /** Whether `text` contains the standalone keyword "ultrathink" (any case). */
4
+ export declare function containsUltrathink(text: string): boolean;
5
+ /**
6
+ * Rainbow-highlight every standalone "ultrathink" in `text` for editor display.
7
+ * Adds only zero-width SGR escapes — the visible width is unchanged — and returns
8
+ * the input untouched when the keyword is absent.
9
+ */
10
+ export declare function highlightUltrathink(text: string): string;
@@ -0,0 +1,124 @@
1
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
2
+ /**
3
+ * Minimal subset of the `bun:redis` `RedisClient` surface used by
4
+ * {@link RedisSessionStorage}. Keeping the contract narrow (and accepting any
5
+ * client that conforms) lets callers swap in test doubles or shared clients
6
+ * without dragging the entire Bun typings into this module.
7
+ */
8
+ export interface RedisSessionStorageClient {
9
+ get(key: string): Promise<string | null>;
10
+ set(key: string, value: string): Promise<unknown>;
11
+ append(key: string, value: string): Promise<number>;
12
+ del(...keys: string[]): Promise<number>;
13
+ rename(src: string, dst: string): Promise<unknown>;
14
+ scan(cursor: string, ...args: string[]): Promise<[string, string[]]>;
15
+ hset(key: string, field: string, value: string): Promise<unknown>;
16
+ hgetall(key: string): Promise<Record<string, string>>;
17
+ hdel(key: string, ...fields: string[]): Promise<unknown>;
18
+ }
19
+ export interface RedisSessionStorageOptions {
20
+ /** A connected `bun:redis` RedisClient (or any compatible adapter). */
21
+ client: RedisSessionStorageClient;
22
+ /**
23
+ * Key prefix applied to every Redis key this storage owns. Default `omp:sessions:`.
24
+ * Trailing colon is preserved verbatim — set to a project-scoped prefix to share
25
+ * one Redis instance between multiple agents.
26
+ */
27
+ prefix?: string;
28
+ /**
29
+ * Maximum number of keys returned per SCAN batch when warming the mirror.
30
+ * Default 500.
31
+ */
32
+ scanCount?: number;
33
+ }
34
+ /**
35
+ * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
36
+ * file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
37
+ * single sibling HASH. An in-memory mirror is loaded on construction so the
38
+ * interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`,
39
+ * `readTextSync`, `writeTextSync`) keep their contracts — Bun's Redis client
40
+ * is async only, and the persist hot path (`writer.writeLineSync`) cannot
41
+ * wait on a network round-trip.
42
+ *
43
+ * Trade-offs vs `FileSessionStorage`:
44
+ * - Mirror state is process-local. Two processes writing the same session key
45
+ * will diverge until one of them reloads via {@link refresh}. This matches
46
+ * `FileSessionStorage`'s existing single-writer assumption.
47
+ * - `writeLineSync` updates the mirror synchronously and queues an async
48
+ * `APPEND`. The promise is awaited by `flush()` / `close()` / {@link drain}.
49
+ * A SIGKILL landing between the sync mirror update and the network round
50
+ * trip loses the last line; the file-backed implementation survives that
51
+ * window because bytes are handed to the kernel page cache before
52
+ * returning.
53
+ * - Blobs (image data) and tool artifact files still live on disk via
54
+ * `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
55
+ */
56
+ export declare class RedisSessionStorage implements SessionStorage {
57
+ #private;
58
+ private constructor();
59
+ /**
60
+ * Warm the in-memory mirror with every existing session key under the
61
+ * configured prefix and return the ready-to-use storage. Must be awaited
62
+ * before passing the storage into `SessionManager.create()` so synchronous
63
+ * lookups (session resume, recent sessions, EPERM-backup recovery) see
64
+ * the existing keyspace.
65
+ */
66
+ static create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage>;
67
+ /**
68
+ * Re-scan Redis and replace the mirror's contents. Call this from a
69
+ * different process that took over a session keyspace, or after an
70
+ * out-of-band write made by another agent.
71
+ */
72
+ refresh(): Promise<void>;
73
+ /**
74
+ * Resolve once every pending background write (issued via `writeTextSync`
75
+ * or `writer.writeLineSync`) has been acknowledged by Redis. Throws if any
76
+ * background write failed since the last drain.
77
+ *
78
+ * Call this on graceful shutdown to avoid losing the last unflushed line.
79
+ * The session-manager's own `flush()` / `close()` already drain through
80
+ * the writer chain — this method exists for callers (test harnesses,
81
+ * subprocess-style consumers) that bypass the writer.
82
+ */
83
+ drain(): Promise<void>;
84
+ ensureDirSync(_dir: string): void;
85
+ existsSync(path: string): boolean;
86
+ writeTextSync(path: string, content: string): void;
87
+ readTextSync(path: string): string;
88
+ statSync(path: string): SessionStorageStat;
89
+ listFilesSync(dir: string, pattern: string): string[];
90
+ exists(path: string): Promise<boolean>;
91
+ readText(path: string): Promise<string>;
92
+ readTextPrefix(path: string, maxBytes: number): Promise<string>;
93
+ writeText(path: string, content: string): Promise<void>;
94
+ rename(src: string, dst: string): Promise<void>;
95
+ unlink(path: string): Promise<void>;
96
+ deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
97
+ openWriter(path: string, options?: {
98
+ flags?: "a" | "w";
99
+ onError?: (err: Error) => void;
100
+ }): SessionStorageWriter;
101
+ _writerClosed(writer: RedisSessionStorageWriter): void;
102
+ /** Mirror-only mutation, no Redis call. Used by writers to update local state synchronously. */
103
+ _mirrorAppend(path: string, line: string): void;
104
+ /** Mirror-only mutation, no Redis call. Used by writers opened with `flags: "w"` to truncate. */
105
+ _mirrorTruncate(path: string): void;
106
+ _remoteTruncate(path: string): Promise<void>;
107
+ _remoteAppend(path: string, line: string): Promise<void>;
108
+ /** Record a writer's pending promise on the storage-level tail so `drain()` waits for it. */
109
+ _attachPending(promise: Promise<void>): void;
110
+ }
111
+ declare class RedisSessionStorageWriter implements SessionStorageWriter {
112
+ #private;
113
+ constructor(storage: RedisSessionStorage, path: string, options?: {
114
+ flags?: "a" | "w";
115
+ onError?: (err: Error) => void;
116
+ });
117
+ writeLineSync(line: string): void;
118
+ writeLine(line: string): Promise<void>;
119
+ flush(): Promise<void>;
120
+ fsync(): Promise<void>;
121
+ close(): Promise<void>;
122
+ getError(): Error | undefined;
123
+ }
124
+ export {};
@@ -0,0 +1,141 @@
1
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
2
+ /**
3
+ * Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
4
+ * `client.options.adapter`; we detect it once at construction and pick the
5
+ * correct DDL / upsert / concat syntax for the underlying engine.
6
+ */
7
+ export type SqlSessionStorageAdapter = "postgres" | "mysql" | "sqlite";
8
+ /**
9
+ * Minimal subset of the `Bun.SQL` instance surface used by
10
+ * {@link SqlSessionStorage}. The real client exposes a callable
11
+ * tagged-template too; we only ever call `unsafe()` so the contract here is
12
+ * narrow — making it trivial to swap in a test double or wrap a pooled
13
+ * client.
14
+ */
15
+ export interface SqlSessionStorageClient {
16
+ unsafe(query: string, values?: unknown[]): Promise<unknown[]>;
17
+ /**
18
+ * `Bun.SQL` exposes the parsed connection options here. We only consult
19
+ * `adapter` to pick the dialect; the field is typed as
20
+ * `string | undefined` so the real `Bun.SQL` instance type slots in
21
+ * without casting (it reports `string | undefined` across adapters).
22
+ */
23
+ options: {
24
+ adapter?: string;
25
+ [key: string]: unknown;
26
+ };
27
+ end?(): Promise<void>;
28
+ }
29
+ export interface SqlSessionStorageOptions {
30
+ /** Connected `Bun.SQL` instance (PostgreSQL, MySQL, or SQLite). */
31
+ client: SqlSessionStorageClient;
32
+ /**
33
+ * Override the auto-detected adapter. Useful when the client is wrapped
34
+ * (e.g. by a pool) and `client.options.adapter` is unreliable.
35
+ */
36
+ adapter?: SqlSessionStorageAdapter;
37
+ /**
38
+ * Table name to use. Default: `omp_session_files`. Must match
39
+ * `[A-Za-z_][A-Za-z0-9_]{0,62}` — inlined into prepared statements at
40
+ * startup, so we accept identifier-safe inputs only (no quoted/dotted
41
+ * names).
42
+ */
43
+ table?: string;
44
+ /**
45
+ * If true, run `CREATE TABLE IF NOT EXISTS` during `create()`.
46
+ * Default: true. Disable when the table is owned by an external
47
+ * migration.
48
+ */
49
+ createTable?: boolean;
50
+ }
51
+ /**
52
+ * SQL-backed implementation of {@link SessionStorage} using `bun:sql`. Each
53
+ * session JSONL file maps to a row keyed by `path`; one table stores
54
+ * everything.
55
+ *
56
+ * Works against PostgreSQL, MySQL/MariaDB, and SQLite by selecting the
57
+ * dialect-correct DDL, upsert, and string-concat syntax at construction.
58
+ *
59
+ * Trade-offs vs `FileSessionStorage`:
60
+ * - An in-memory mirror is loaded on construction so the interface's
61
+ * synchronous methods (`existsSync`, `statSync`, `listFilesSync`, …) keep
62
+ * their contracts; `bun:sql` is async only. Mirror state is process-local,
63
+ * matching `FileSessionStorage`'s existing single-writer assumption — peer
64
+ * processes need {@link refresh} to pick up out-of-band writes.
65
+ * - `writeLineSync` updates the mirror synchronously and queues an async
66
+ * upsert that appends the line to the existing row (or inserts it as the
67
+ * first chunk). The promise is awaited by `flush()` / `close()` /
68
+ * {@link drain}. A SIGKILL between the sync mirror update and the network
69
+ * round-trip loses the last line.
70
+ * - Blobs (image data) and tool artifact files still live on disk via
71
+ * `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
72
+ */
73
+ export declare class SqlSessionStorage implements SessionStorage {
74
+ #private;
75
+ private constructor();
76
+ /**
77
+ * Apply the dialect-correct DDL (unless `createTable: false` is set) and
78
+ * warm the in-memory mirror with every existing row. Must be awaited
79
+ * before passing the storage into `SessionManager.create()`.
80
+ */
81
+ static create(options: SqlSessionStorageOptions): Promise<SqlSessionStorage>;
82
+ get adapter(): SqlSessionStorageAdapter;
83
+ get table(): string;
84
+ /**
85
+ * Re-load the mirror from the database. Call this from a different
86
+ * process that took over the table, or after an out-of-band write made
87
+ * by another agent.
88
+ */
89
+ refresh(): Promise<void>;
90
+ /**
91
+ * Resolve once every pending background write (issued via `writeTextSync`
92
+ * or `writer.writeLineSync`) has been acknowledged by the database.
93
+ * Throws if any background write failed since the last drain. Call on
94
+ * graceful shutdown to avoid losing the last unflushed line.
95
+ */
96
+ drain(): Promise<void>;
97
+ ensureDirSync(_dir: string): void;
98
+ existsSync(path: string): boolean;
99
+ writeTextSync(path: string, content: string): void;
100
+ readTextSync(path: string): string;
101
+ statSync(path: string): SessionStorageStat;
102
+ listFilesSync(dir: string, pattern: string): string[];
103
+ exists(path: string): Promise<boolean>;
104
+ readText(path: string): Promise<string>;
105
+ readTextPrefix(path: string, maxBytes: number): Promise<string>;
106
+ writeText(path: string, content: string): Promise<void>;
107
+ rename(src: string, dst: string): Promise<void>;
108
+ unlink(path: string): Promise<void>;
109
+ deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
110
+ openWriter(path: string, options?: {
111
+ flags?: "a" | "w";
112
+ onError?: (err: Error) => void;
113
+ }): SessionStorageWriter;
114
+ _writerClosed(writer: SqlSessionStorageWriter): void;
115
+ _mirrorAppend(path: string, line: string): {
116
+ content: string;
117
+ mtimeMs: number;
118
+ };
119
+ _mirrorTruncate(path: string): void;
120
+ _remoteTruncate(path: string): Promise<void>;
121
+ /**
122
+ * Append a chunk to the row at `path`, inserting if the row doesn't
123
+ * exist yet. Single round-trip via the dialect-specific `upsertAppend`.
124
+ */
125
+ _remoteAppend(path: string, line: string, mtimeMs: number): Promise<void>;
126
+ _attachPending(promise: Promise<void>): void;
127
+ }
128
+ declare class SqlSessionStorageWriter implements SessionStorageWriter {
129
+ #private;
130
+ constructor(storage: SqlSessionStorage, path: string, options?: {
131
+ flags?: "a" | "w";
132
+ onError?: (err: Error) => void;
133
+ });
134
+ writeLineSync(line: string): void;
135
+ writeLine(line: string): Promise<void>;
136
+ flush(): Promise<void>;
137
+ fsync(): Promise<void>;
138
+ close(): Promise<void>;
139
+ getError(): Error | undefined;
140
+ }
141
+ export {};
@@ -49,6 +49,36 @@ declare const todoWriteSchema: z.ZodObject<{
49
49
  type TodoWriteParams = z.infer<typeof todoWriteSchema>;
50
50
  export declare const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
51
51
  export declare function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[];
52
+ /**
53
+ * Pick the actionable window of tasks to display in the sticky todo panel.
54
+ *
55
+ * Returns up to `maxVisible` open (pending / in_progress) tasks in their
56
+ * original phase order, plus the count of remaining open tasks not shown so
57
+ * the caller can render a `+N more` hint. When every task in `tasks` is
58
+ * closed (completed or abandoned), returns the trailing `maxVisible` tasks
59
+ * with `hiddenOpenCount = 0`, so the panel keeps useful context until the
60
+ * active-phase pointer advances on the next `todo_write`.
61
+ *
62
+ * Task identity and order are preserved — this is a slice, never a sort.
63
+ */
64
+ export declare function selectStickyTodoWindow(tasks: TodoItem[], maxVisible?: number): {
65
+ visible: TodoItem[];
66
+ hiddenOpenCount: number;
67
+ };
68
+ /**
69
+ * Report whether `content` likely names the same work as any entry in
70
+ * `descriptions`. Used by the sticky todo panel to light up a pending todo
71
+ * when an in-flight subagent is doing the work for it, without requiring
72
+ * the caller to flip the todo's status.
73
+ *
74
+ * Matching is normalize-then-equal first (lowercased; punctuation and
75
+ * whitespace runs both collapsed to a single space; trimmed), with a
76
+ * substring fallback in either direction so minor wording drift
77
+ * ("Sonnet #2: bug scan" vs "Sonnet #2") still links up. The substring
78
+ * fallback requires at least {@link TODO_DESCRIPTION_MIN_OVERLAP} chars on
79
+ * the contained side.
80
+ */
81
+ export declare function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean;
52
82
  /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
53
83
  export declare function applyOpsToPhases(currentPhases: TodoPhase[], ops: TodoWriteParams["ops"]): {
54
84
  phases: TodoPhase[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Redis-Backed Sessions
3
+ *
4
+ * Store session JSONL in Redis (or Valkey) instead of the local filesystem.
5
+ * Useful when the agent runs in an ephemeral container, behind a load
6
+ * balancer, or anywhere a shared session store beats per-host disk state.
7
+ *
8
+ * The storage substrate is the only thing that changes — every other SDK
9
+ * surface (extensions, hooks, custom tools, slash commands, branching,
10
+ * `SessionManager.list`, …) continues to work unmodified.
11
+ *
12
+ * Tool artifacts and image blobs are out of scope: `ArtifactManager` /
13
+ * `BlobStore` keep writing to `~/.omp/agent/...`. Reach for an object store
14
+ * (S3, R2, GCS) if you need those off-host too.
15
+ */
16
+
17
+ import { createAgentSession, RedisSessionStorage, SessionManager } from "@oh-my-pi/pi-coding-agent";
18
+ import { RedisClient } from "bun";
19
+
20
+ // `bun:redis` picks up `REDIS_URL` / `VALKEY_URL` from the environment, or
21
+ // you can pass an explicit `redis://`/`rediss://` URL.
22
+ const redis = new RedisClient();
23
+ await redis.ping();
24
+
25
+ // `create()` warms an in-memory mirror with every existing key under the
26
+ // prefix so SessionManager's synchronous lookups (resume, recent sessions,
27
+ // list) work without per-call network round-trips.
28
+ const storage = await RedisSessionStorage.create({
29
+ client: redis,
30
+ prefix: "omp:sessions:", // optional, this is the default
31
+ });
32
+
33
+ const sessionDir = "/sessions/my-project";
34
+
35
+ // 1) Fresh persistent session, JSONL backed by Redis.
36
+ const { session } = await createAgentSession({
37
+ sessionManager: SessionManager.create(process.cwd(), sessionDir, storage),
38
+ });
39
+ console.log("New Redis session:", session.sessionFile);
40
+
41
+ // 2) Continue the most recent session for this `sessionDir`.
42
+ const { session: continued } = await createAgentSession({
43
+ sessionManager: await SessionManager.continueRecent(process.cwd(), sessionDir, storage),
44
+ });
45
+ console.log("Resumed:", continued.sessionFile);
46
+
47
+ // 3) List every Redis-backed session under this directory key prefix.
48
+ const sessions = await SessionManager.list(process.cwd(), sessionDir, storage);
49
+ console.log(`Found ${sessions.length} sessions under ${sessionDir}`);
50
+
51
+ // On graceful shutdown, drain any background writes the writer queued and
52
+ // close the Redis connection so containerized hosts can exit cleanly.
53
+ await storage.drain();
54
+ redis.close();