@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
package/src/task/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  // Import review tools for side effects (registers subagent tool handlers)
42
42
  import "../tools/review";
43
43
  import type { LocalProtocolOptions } from "../internal-urls";
44
+ import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
44
45
  import { generateCommitMessage } from "../utils/commit-message-generator";
45
46
  import * as git from "../utils/git";
46
47
  import { discoverAgents, getAgent } from "./discovery";
@@ -131,6 +132,37 @@ export {
131
132
  taskSchema,
132
133
  } from "./types";
133
134
 
135
+ // Built-in tools whose approval tier is "read" (see tool classes' `approval`).
136
+ // An agent is read-only iff its declared tools are a non-empty subset of this set.
137
+ // Fail-safe: any unknown tool makes the agent not read-only.
138
+ export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
139
+ "read",
140
+ "search",
141
+ "find",
142
+ "web_search",
143
+ "ast_grep",
144
+ "yield",
145
+ "irc",
146
+ "ask",
147
+ "job",
148
+ "todo",
149
+ "recall",
150
+ "reflect",
151
+ "retain",
152
+ "memory_edit",
153
+ "render_mermaid",
154
+ "inspect_image",
155
+ "checkpoint",
156
+ "rewind",
157
+ "resolve",
158
+ "report_finding",
159
+ "search_tool_bm25",
160
+ ]);
161
+
162
+ export function isReadOnlyAgent(agent: AgentDefinition): boolean {
163
+ return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
164
+ }
165
+
134
166
  /**
135
167
  * Render the tool description from a cached agent list and current settings.
136
168
  */
@@ -157,9 +189,14 @@ function renderDescription(
157
189
  );
158
190
  filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
159
191
  }
192
+ const renderedAgents = filteredAgents.map(agent => ({
193
+ name: agent.name,
194
+ description: agent.description,
195
+ readOnly: isReadOnlyAgent(agent),
196
+ }));
160
197
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
161
198
  return prompt.render(taskDescriptionTemplate, {
162
- agents: filteredAgents,
199
+ agents: renderedAgents,
163
200
  spawningDisabled,
164
201
  MAX_CONCURRENCY: maxConcurrency,
165
202
  isolationEnabled,
@@ -772,6 +809,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
772
809
  // across the whole tree and outputs land flat in the parent's dir.
773
810
  const parentArtifactManager = this.session.getArtifactManager?.() ?? undefined;
774
811
 
812
+ // When the session is executing an approved plan, hand the overall plan to
813
+ // every subagent so they share the main agent's plan context. Skipped in
814
+ // plan mode (read-only exploration uses planModeSubagentPrompt instead) and
815
+ // when no plan file exists at the session's reference path.
816
+ const planReference = planModeState?.enabled
817
+ ? undefined
818
+ : await loadOverallPlanReference(
819
+ this.session.getPlanReferencePath?.() ?? "local://PLAN.md",
820
+ localProtocolOptions,
821
+ );
822
+
775
823
  // Initialize progress tracking
776
824
  const progressMap = new Map<number, AgentProgress>();
777
825
 
@@ -898,6 +946,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
898
946
  task: renderSubagentUserPrompt(task.assignment, simpleMode),
899
947
  assignment: task.assignment.trim(),
900
948
  context: sharedContext,
949
+ planReference,
901
950
  description: task.description,
902
951
  index,
903
952
  id: task.id,
@@ -955,6 +1004,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
955
1004
  task: renderSubagentUserPrompt(task.assignment, simpleMode),
956
1005
  assignment: task.assignment.trim(),
957
1006
  context: sharedContext,
1007
+ planReference,
958
1008
  description: task.description,
959
1009
  index,
960
1010
  id: task.id,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OTLP trace export bootstrap.
3
+ *
4
+ * oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI
5
+ * spans through the global `@opentelemetry/api` tracer, but only when a
6
+ * TracerProvider is registered in the process — otherwise the API returns a
7
+ * no-op tracer and the spans are silently dropped. The shipped CLI never
8
+ * registered one, so headless / embedded hosts (e.g. an ACP harness that
9
+ * spawns `omp` as a child process) had no way to collect omp's internal traces.
10
+ *
11
+ * This module registers a NodeTracerProvider with an OTLP/proto exporter when
12
+ * the standard `OTEL_EXPORTER_OTLP_ENDPOINT` (or `..._TRACES_ENDPOINT`) env var
13
+ * is set, following the zero-code OTEL env contract: the exporter reads its
14
+ * endpoint, headers, and timeout from `OTEL_EXPORTER_OTLP_*` itself. The
15
+ * consuming process configures the destination entirely through env; omp stays
16
+ * provider-agnostic and ships no vendor coupling. Only the `http/protobuf`
17
+ * transport is supported — an `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or
18
+ * `http/json` declines rather than misrouting spans.
19
+ *
20
+ * The OTLP/proto exporter on the 2.x line is used deliberately: the 1.x line
21
+ * deadlocks under Bun — its `req.on('close')` handler fires a spurious failure
22
+ * after the success path. `exporter-trace-otlp-proto@0.218` paired with
23
+ * `sdk-trace-base@2.7` exports cleanly on Bun.
24
+ */
25
+ import { logger, postmortem } from "@oh-my-pi/pi-utils";
26
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
27
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
28
+ import { resourceFromAttributes } from "@opentelemetry/resources";
29
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
30
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
31
+
32
+ /**
33
+ * Periodic flush interval. A long-lived `omp` process (the ACP server is
34
+ * spawned once and reused across many turns) would otherwise hold finished
35
+ * spans until the batch window elapses or the process exits.
36
+ */
37
+ const FLUSH_INTERVAL_MS = 30_000;
38
+
39
+ let provider: NodeTracerProvider | undefined;
40
+
41
+ /**
42
+ * Whether {@link initTelemetryExport} registered a real provider. The CLI uses
43
+ * this to decide whether to switch on the agent loop's telemetry config — there
44
+ * is no point emitting spans into a no-op tracer.
45
+ */
46
+ export function isTelemetryExportEnabled(): boolean {
47
+ return provider !== undefined;
48
+ }
49
+
50
+ /**
51
+ * Register the global TracerProvider + OTLP exporter when an OTLP endpoint is
52
+ * configured via env. Idempotent, and a no-op when no endpoint is set (or when
53
+ * the OTEL kill-switches are engaged), so it is safe to call unconditionally at
54
+ * startup.
55
+ */
56
+ export function initTelemetryExport(): void {
57
+ if (provider) return;
58
+ // The OTEL env contract parses booleans and enum lists case-insensitively, so
59
+ // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
60
+ if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
61
+ if (tracesExporterDisabled(process.env.OTEL_TRACES_EXPORTER)) return;
62
+
63
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
64
+ if (!endpoint) return;
65
+
66
+ // We only ship the http/protobuf transport (the line validated on Bun). The
67
+ // OTEL contract lets OTEL_EXPORTER_OTLP*_PROTOCOL select grpc / http/json;
68
+ // rather than silently send protobuf-over-HTTP to a grpc :4317 port and lose
69
+ // every span, decline when an unsupported protocol is requested.
70
+ const protocol = (process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL)
71
+ ?.trim()
72
+ .toLowerCase();
73
+ if (protocol && protocol !== "http/protobuf") {
74
+ logger.warn(
75
+ `OTEL trace export disabled: OTEL_EXPORTER_OTLP_PROTOCOL=${protocol} is unsupported (only http/protobuf)`,
76
+ );
77
+ return;
78
+ }
79
+
80
+ // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
81
+ // so there is nothing to thread through here.
82
+ const exporter = new OTLPTraceExporter();
83
+ const tracerProvider = new NodeTracerProvider({
84
+ resource: resourceFromAttributes({
85
+ "service.name": process.env.OTEL_SERVICE_NAME ?? "oh-my-pi",
86
+ }),
87
+ spanProcessors: [new BatchSpanProcessor(exporter)],
88
+ });
89
+ // register() installs the global tracer provider and the W3C trace-context +
90
+ // baggage propagators; the explicit AsyncLocalStorage context manager keeps
91
+ // parent/child span linkage working under Bun.
92
+ tracerProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
93
+ provider = tracerProvider;
94
+
95
+ const flushTimer = setInterval(() => {
96
+ provider?.forceFlush().catch(() => {});
97
+ }, FLUSH_INTERVAL_MS);
98
+ flushTimer.unref();
99
+
100
+ // Shut down through postmortem rather than a bare signal listener. postmortem
101
+ // owns SIGINT/SIGTERM/SIGHUP/exit and quit(), and awaits registered cleanups
102
+ // before calling process.exit — so the batch processor's final OTLP export
103
+ // completes instead of being cut off mid-flight on the shutdown path.
104
+ postmortem.register("otel-trace-export", async () => {
105
+ clearInterval(flushTimer);
106
+ await provider?.shutdown();
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Parse the `OTEL_TRACES_EXPORTER` selection. The value is a case-insensitive,
112
+ * comma-separated list; the literal `none` disables span export entirely.
113
+ */
114
+ function tracesExporterDisabled(raw: string | undefined): boolean {
115
+ if (!raw) return false;
116
+ return raw.split(",").some(entry => entry.trim().toLowerCase() === "none");
117
+ }
118
+
119
+ /**
120
+ * Flush any buffered spans to the exporter. No-op when export is disabled.
121
+ * Hosts embedding the agent can call this at natural boundaries (e.g. the end
122
+ * of a turn) so traces surface promptly rather than on the batch interval.
123
+ */
124
+ export async function flushTelemetryExport(): Promise<void> {
125
+ await provider?.forceFlush();
126
+ }
@@ -0,0 +1,179 @@
1
+ import * as fs from "node:fs";
2
+ import * as Module from "node:module";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
7
+ * specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
8
+ * (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into
9
+ * a cache directory at runtime, and its graph (`@huggingface/transformers` →
10
+ * `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`)
11
+ * all point `main`/`exports` at nested files, so the stock resolver cannot load
12
+ * any of them. We patch `Module._resolveFilename` to resolve those bare
13
+ * specifiers against the cache ourselves, honoring `main`/`exports`.
14
+ *
15
+ * This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
16
+ * mutating the `node:module` resolver, so the resolution logic is unit-testable
17
+ * without a compiled binary.
18
+ */
19
+
20
+ /** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
21
+ const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
22
+
23
+ /** Extension probes appended to a `main`/`exports` target that lacks one. */
24
+ const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ /**
31
+ * Walk a conditional `exports` target (string, array of fallbacks, or a
32
+ * condition object) and return the first relative path that matches a runtime
33
+ * condition in declaration order. Returns `null` when nothing applies (e.g.
34
+ * `import`-only targets).
35
+ */
36
+ export function selectConditionalTarget(target: unknown): string | null {
37
+ if (typeof target === "string") return target;
38
+ if (Array.isArray(target)) {
39
+ for (const entry of target) {
40
+ const resolved = selectConditionalTarget(entry);
41
+ if (resolved) return resolved;
42
+ }
43
+ return null;
44
+ }
45
+ if (isRecord(target)) {
46
+ for (const condition in target) {
47
+ if (!RUNTIME_CONDITIONS[condition]) continue;
48
+ const resolved = selectConditionalTarget(target[condition]);
49
+ if (resolved) return resolved;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
56
+ function resolveFileTarget(pkgDir: string, relative: string): string | null {
57
+ const base = path.join(pkgDir, relative);
58
+ const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
59
+ for (const candidate of candidates) {
60
+ try {
61
+ const stat = fs.statSync(candidate);
62
+ if (stat.isFile()) return candidate;
63
+ if (stat.isDirectory()) {
64
+ const indexed = resolveFileTarget(candidate, "index");
65
+ if (indexed) return indexed;
66
+ }
67
+ } catch {
68
+ // missing candidate — keep probing
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function resolveExportsEntry(
75
+ pkgDir: string,
76
+ exports: Record<string, unknown>,
77
+ subpath: string | undefined,
78
+ ): string | null {
79
+ let subpathMap = false;
80
+ for (const key in exports) {
81
+ subpathMap = key === "." || key.startsWith("./");
82
+ break;
83
+ }
84
+ if (subpathMap) {
85
+ const key = subpath ? `./${subpath}` : ".";
86
+ if (!(key in exports)) return null;
87
+ const target = selectConditionalTarget(exports[key]);
88
+ return target ? resolveFileTarget(pkgDir, target) : null;
89
+ }
90
+ // A bare condition map only describes the package root, so a subpath
91
+ // request falls through to plain path joining at the call site.
92
+ if (subpath) return null;
93
+ const target = selectConditionalTarget(exports);
94
+ return target ? resolveFileTarget(pkgDir, target) : null;
95
+ }
96
+
97
+ /**
98
+ * Split a bare specifier into its package name and optional subpath, handling
99
+ * scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
100
+ */
101
+ export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
102
+ const segments = specifier.split("/");
103
+ const take = specifier.startsWith("@") ? 2 : 1;
104
+ const packageName = segments.slice(0, take).join("/");
105
+ const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
106
+ return { packageName, subpath };
107
+ }
108
+
109
+ /**
110
+ * Resolve a bare specifier against an installed `node_modules` directory,
111
+ * honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
112
+ * Returns an absolute file path, or `null` when the package/entry is absent.
113
+ */
114
+ export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
115
+ const { packageName, subpath } = splitBareSpecifier(specifier);
116
+ const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
117
+ const manifest = readManifest(pkgDir);
118
+ if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
119
+
120
+ const { exports } = manifest;
121
+ if (typeof exports === "string" || isRecord(exports)) {
122
+ const map = typeof exports === "string" ? { ".": exports } : exports;
123
+ const resolved = resolveExportsEntry(pkgDir, map, subpath);
124
+ if (resolved) return resolved;
125
+ }
126
+ if (subpath) return resolveFileTarget(pkgDir, subpath);
127
+ if (typeof manifest.main === "string") {
128
+ const resolved = resolveFileTarget(pkgDir, manifest.main);
129
+ if (resolved) return resolved;
130
+ }
131
+ return resolveFileTarget(pkgDir, "index.js");
132
+ }
133
+
134
+ function readManifest(pkgDir: string): Record<string, unknown> | null {
135
+ try {
136
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
137
+ return isRecord(parsed) ? parsed : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ interface ModuleResolver {
144
+ _resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
145
+ }
146
+
147
+ const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver");
148
+
149
+ interface ResolverOptions {
150
+ /** Absolute path to the runtime cache's `node_modules`. */
151
+ runtimeNodeModules: string;
152
+ /** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
153
+ stubs?: Record<string, string>;
154
+ }
155
+
156
+ /**
157
+ * Patch `node:module`'s resolver (idempotently) so bare specifiers that the
158
+ * stock compiled-binary resolver cannot find fall back to the runtime cache.
159
+ * Stock resolution is tried first, so this never changes behavior for modules
160
+ * that already resolve (the worker's own bundled imports, node builtins).
161
+ */
162
+ export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void {
163
+ const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
164
+ const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
165
+ if (target[PATCHED]) return;
166
+ const original = target._resolveFilename.bind(target);
167
+ target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
168
+ try {
169
+ return original(request, parent, isMain, options);
170
+ } catch (error) {
171
+ const stub = stubs[request];
172
+ if (stub) return stub;
173
+ const resolved = resolveRuntimeModule(runtimeNodeModules, request);
174
+ if (resolved) return resolved;
175
+ throw error;
176
+ }
177
+ };
178
+ target[PATCHED] = true;
179
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
11
11
  import packageJson from "../../package.json" with { type: "json" };
12
12
  import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
13
+ import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
13
14
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
14
15
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
15
16
  import {
@@ -28,6 +29,7 @@ const STOP_DECODE_WINDOW_TOKENS = 32;
28
29
  const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
29
30
  const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
30
31
  const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
32
+ const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
31
33
  const sourceRequire = createRequire(import.meta.url);
32
34
  const INSTALL_LOCK_ATTEMPTS = 240;
33
35
  const INSTALL_LOCK_SLEEP_MS = 250;
@@ -67,6 +69,7 @@ function resolveTransformersVersionSpec(): string {
67
69
  manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
68
70
  if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
69
71
  if (!versionSpec.startsWith("catalog:")) return versionSpec;
72
+ if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION;
70
73
  const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
71
74
  return installed.version;
72
75
  }
@@ -117,6 +120,7 @@ function getTinyTitleRuntimeDir(): string {
117
120
 
118
121
  async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
119
122
  const lockDir = `${runtimeDir}.lock`;
123
+ await fs.mkdir(path.dirname(lockDir), { recursive: true });
120
124
  for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
121
125
  try {
122
126
  await fs.mkdir(lockDir);
@@ -216,6 +220,23 @@ async function ensureCompiledTransformersRuntime(
216
220
  }
217
221
  }
218
222
 
223
+ /**
224
+ * Prepare the freshly-installed compiled runtime for loading: stub `sharp`
225
+ * (the tiny models are text-generation only, so the native image pipeline is
226
+ * dead weight) and patch the module resolver so Transformers.js's bare requires
227
+ * (`onnxruntime-node`, `onnxruntime-common`) resolve against the cache. Returns
228
+ * the absolute Transformers.js entrypoint to `require`.
229
+ */
230
+ async function prepareCompiledRuntime(runtimeDir: string): Promise<string> {
231
+ const nodeModules = path.join(runtimeDir, "node_modules");
232
+ const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs");
233
+ await Bun.write(sharpStub, "module.exports = {};\n");
234
+ installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
235
+ const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
236
+ if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`);
237
+ return entry;
238
+ }
239
+
219
240
  function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
220
241
  transformers.env.cacheDir = getTinyModelsCacheDir();
221
242
  transformers.env.allowLocalModels = false;
@@ -232,8 +253,9 @@ async function loadTransformers(
232
253
  transformersRuntime = (async () => {
233
254
  if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
234
255
  const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
235
- const require_ = createRequire(path.join(runtimeDir, "package.json"));
236
- return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime);
256
+ const entry = await prepareCompiledRuntime(runtimeDir);
257
+ const require_ = createRequire(entry);
258
+ return configureTransformers(require_(entry) as TransformersRuntime);
237
259
  })().catch(error => {
238
260
  transformersRuntime = null;
239
261
  throw error;