@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.7

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.
@@ -5,13 +5,8 @@
5
5
  */
6
6
  export interface StatsCommandArgs {
7
7
  port: number;
8
+ host: string;
8
9
  json: boolean;
9
10
  summary: boolean;
10
11
  }
11
- /**
12
- * Parse stats subcommand arguments.
13
- * Returns undefined if not a stats command.
14
- */
15
- export declare function parseStatsArgs(args: string[]): StatsCommandArgs | undefined;
16
12
  export declare function runStatsCommand(cmd: StatsCommandArgs): Promise<void>;
17
- export declare function printStatsHelp(): void;
@@ -10,6 +10,10 @@ export default class Stats extends Command {
10
10
  description: string;
11
11
  default: number;
12
12
  };
13
+ host: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
14
+ description: string;
15
+ default: string;
16
+ };
13
17
  json: import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
14
18
  char: string;
15
19
  description: string;
@@ -52,10 +52,12 @@ export type SwitchSessionHandler = (sessionPath: string) => Promise<{
52
52
  }>;
53
53
  export type ShutdownHandler = () => void;
54
54
  /**
55
- * Emit `session_shutdown` and clear timers owned by an extension runner.
55
+ * Emit `session_shutdown`, dispose file-write-fallback registrations, and clear
56
+ * timers owned by an extension runner.
56
57
  *
57
- * Returns whether any shutdown handlers were present. Timer cleanup runs even
58
- * when a handler fails so extension background work cannot outlive its host.
58
+ * Returns whether any shutdown handlers were present. Fallback disposal and timer
59
+ * cleanup run even when a handler fails so extension background work and a
60
+ * fallback bound to this session's context — cannot outlive its host.
59
61
  */
60
62
  export declare function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean>;
61
63
  export declare class ExtensionRunner {
@@ -115,6 +117,15 @@ export declare class ExtensionRunner {
115
117
  * leak into another's `ctx.cwd` by reading a shared global.
116
118
  */
117
119
  get cwd(): string;
120
+ /**
121
+ * Stable id of the session this runner serves. Read through `sessionManager`
122
+ * for the same reason as {@link cwd}: it is this session's own, never a
123
+ * process-global, so a subagent runner reports itself and not its parent.
124
+ *
125
+ * Used to attribute a denied file write or delete to the session that issued
126
+ * it, since the fallback registry those handlers live in is process-wide.
127
+ */
128
+ get sessionId(): string;
118
129
  initialize(actions: ExtensionActions, contextActions: ExtensionContextActions, commandContextActions?: ExtensionCommandContextActions, uiContext?: ExtensionUIContext, mode?: ExtensionMode): void;
119
130
  /**
120
131
  * Forward a `credential_disabled` event from `AuthStorage` to extension handlers.
@@ -218,6 +229,13 @@ export declare class ExtensionRunner {
218
229
  * firing against a disposed session).
219
230
  */
220
231
  clearManagedTimers(): void;
232
+ /**
233
+ * Remove every file write and delete fallback this runner installed into the
234
+ * process-wide registries. Called on session shutdown (and before reinstalling
235
+ * on a re-{@link initialize}) so a handler bound to a torn-down session's
236
+ * context can never fire for another session sharing this process.
237
+ */
238
+ disposeFileFallbacks(): void;
221
239
  createCommandContext(): ExtensionCommandContext;
222
240
  emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>>;
223
241
  emitToolResult(event: ToolResultEvent): Promise<ToolResultEventResult | undefined>;
@@ -33,6 +33,7 @@ import type { CustomMessage, CustomMessagePayload } from "../../session/messages
33
33
  import type { ReadonlySessionManager, SessionManager } from "../../session/session-manager.js";
34
34
  import type { BashToolDetails, BashToolInput, GlobToolDetails, GlobToolInput, GrepToolDetails, GrepToolInput, ReadToolDetails, ReadToolInput, WriteToolInput } from "../../tools/index.js";
35
35
  import type { ApprovalMode } from "../../tools/approval.js";
36
+ import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback.js";
36
37
  import type { EventBus } from "../../utils/event-bus.js";
37
38
  import type { AgentEndEvent, AgentStartEvent, AutoCompactionEndEvent, AutoCompactionStartEvent, AutoRetryEndEvent, AutoRetryStartEvent, ContextEvent, GoalUpdatedEvent, RetryFallbackAppliedEvent, RetryFallbackSucceededEvent, SessionBeforeBranchEvent, SessionBeforeBranchResult, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionBranchEvent, SessionCompactEvent, SessionCompactingEvent, SessionCompactingResult, SessionEvent, SessionShutdownEvent, SessionStartEvent, SessionStopEvent, SessionStopEventResult, SessionSwitchEvent, SessionTreeEvent, TodoReminderEvent, ToolCallEventResult, ToolResultEventResult, TtsrTriggeredEvent, TurnEndEvent, TurnStartEvent } from "../shared-events.js";
38
39
  import type { SlashCommandInfo } from "../slash-commands.js";
@@ -834,6 +835,61 @@ export interface ExtensionAPI {
834
835
  on(event: "mcp_notification", handler: ExtensionHandler<McpNotificationEvent>): void;
835
836
  /** Register a tool that the LLM can call. */
836
837
  registerTool<TParams extends TSchema = TSchema, TDetails = unknown>(tool: ToolDefinition<TParams, TDetails>): void;
838
+ /**
839
+ * Register a fallback writer consulted when a native `write`/`edit` byte-write is
840
+ * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Every other write
841
+ * error is unaffected. Handlers run in registration order; the first one to
842
+ * resolve `true` counts as the bytes being durably on disk, and the native tool
843
+ * continues as if its own write had succeeded — including recording its file
844
+ * snapshot under the real destination path, so a later hashline `edit` on that
845
+ * path keeps working. Intended for a host embedding the agent inside a sandbox
846
+ * that denies direct filesystem writes but exposes a privileged write channel.
847
+ *
848
+ * A denial that `Bun.write` masks as `ENOENT` — a write into a directory the host
849
+ * may not create — also diverts here, with `req.dst`'s parent absent and the
850
+ * handler responsible for creating it.
851
+ *
852
+ * `req.dst` is symlink-RESOLVED: the path the failed write itself acted on, not
853
+ * the one the tool was given. A link anywhere in a lexical path redirects the
854
+ * bytes while still passing a prefix allowlist, so treat `req.dst` as
855
+ * authoritative. A destination that cannot be resolved is never brokered.
856
+ *
857
+ * Call this during extension load, like the other `register*` methods: handlers
858
+ * are installed when the runner initializes, so an extension that has registered
859
+ * none by then is skipped and a first registration made later never takes effect.
860
+ *
861
+ * The underlying registry is process-wide, so a handler may be consulted for a
862
+ * denied write from any session in the process, not only its own.
863
+ * `req.sessionId` names the session that issued the write and
864
+ * `ctx.sessionManager.getSessionId()` names the handler's own; compare them
865
+ * before prompting, because `ctx.ui` belongs to the latter. See
866
+ * `docs/extensions.md`.
867
+ */
868
+ registerFileWriteFallback(handler: FileWriteFallbackHandler): void;
869
+ /**
870
+ * Register a fallback deleter consulted when a native `edit`/`apply_patch` unlink is
871
+ * denied with a permission error (`EPERM`/`EACCES`/`EROFS`). Covers `edit`'s `REM`,
872
+ * the source side of a hashline `MV`, and `apply_patch`'s delete op. Return `true`
873
+ * once `dst` is gone from disk.
874
+ *
875
+ * A handler MUST remove `dst` with a plain unlink and MUST NOT fall back to a
876
+ * recursive removal. `unlink` on a directory reports `EPERM` on Darwin, so the seam
877
+ * checks the target before diverting — but when the target's own metadata is behind
878
+ * the same boundary that denied the unlink, which is the common sandbox case, that
879
+ * check cannot be resolved and `dst` may be a directory. `req.confirmedFile` says
880
+ * which situation the handler is in.
881
+ *
882
+ * `req.dst` resolves every component ABOVE the last, for the same reason the
883
+ * write seam resolves all of them; the last is left alone because `unlink`
884
+ * removes a link rather than its target, so `req.dst` may name a link.
885
+ *
886
+ * Separate from {@link registerFileWriteFallback} on purpose. A write handler
887
+ * brokers `req.content` to `req.dst`, so a delete request reaching it with no
888
+ * content invites brokering an empty write and truncating the file instead of
889
+ * removing it. Registering for deletes is therefore an explicit opt-in, and the
890
+ * same load-time and process-wide notes above apply.
891
+ */
892
+ registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void;
837
893
  /** Register a custom command. */
838
894
  registerCommand(name: string, options: {
839
895
  description?: string;
@@ -1142,6 +1198,8 @@ export interface Extension {
1142
1198
  tools: Map<string, RegisteredTool<any, any>>;
1143
1199
  toolRegistrationListeners?: Set<ToolRegistrationListener>;
1144
1200
  assistantThinkingRenderers: AssistantThinkingRenderer[];
1201
+ fileWriteFallbackHandlers: FileWriteFallbackHandler[];
1202
+ fileDeleteFallbackHandlers: FileDeleteFallbackHandler[];
1145
1203
  messageRenderers: Map<string, MessageRenderer>;
1146
1204
  commands: Map<string, RegisteredCommand>;
1147
1205
  flags: Map<string, ExtensionFlag>;
@@ -1,6 +1,7 @@
1
1
  export declare const DEFAULT_STATS_DASHBOARD_PORT = 3847;
2
2
  export interface StatsDashboardArgs {
3
3
  port: number;
4
+ host: string;
4
5
  }
5
6
  export interface StatsDashboardLaunchResult {
6
7
  url: string;
@@ -0,0 +1,124 @@
1
+ import type { BunFile } from "bun";
2
+ import type { ExtensionContext } from "../extensibility/extensions/types.js";
3
+ /** A denied write, captured for a registered fallback to retry through a privileged channel. */
4
+ export interface FileWriteFallbackRequest {
5
+ /**
6
+ * Absolute, symlink-resolved path to write the bytes to.
7
+ *
8
+ * This is where the failed in-process write would itself have landed, which is
9
+ * not necessarily the path the tool was given: `open` follows every component,
10
+ * so a link anywhere in that path redirects the bytes. Resolving it here is
11
+ * what lets a handler's allowlist see the real destination instead of a
12
+ * lexically innocent path, so a handler MUST treat this as authoritative and
13
+ * MUST NOT re-derive the target from anything else.
14
+ */
15
+ dst: string;
16
+ /**
17
+ * Session the denied write was issued from, or `undefined` when the mutation
18
+ * did not happen inside a tool call (an external `applyPatch` caller, a test).
19
+ *
20
+ * The registry is process-wide, so a handler can be consulted for a write from
21
+ * a session other than the one whose extension registered it. Compare this with
22
+ * `ctx.sessionManager.getSessionId()` to tell the two apart — a handler that
23
+ * prompts through `ctx.ui` needs to, since that UI belongs to ITS session and
24
+ * not necessarily to the one being asked about.
25
+ */
26
+ sessionId: string | undefined;
27
+ /** The exact bytes the tool intended to write. */
28
+ content: string;
29
+ /**
30
+ * The error that proves the write hit a permission boundary. Usually the write's
31
+ * own `EPERM`/`EACCES`/`EROFS`; for a write into a directory the host may not
32
+ * create, the denial raised by creating that directory, in which case `dst`'s
33
+ * parent may not exist yet and the handler is responsible for creating it.
34
+ */
35
+ cause: unknown;
36
+ }
37
+ /** Extension-authored handler. Return `true` once `content` is durably on disk at `dst`. */
38
+ export type FileWriteFallbackHandler = (req: FileWriteFallbackRequest, ctx: ExtensionContext) => Promise<boolean>;
39
+ /** A handler already bound to its owning extension's live context. */
40
+ type BoundFileWriteFallbackHandler = (req: FileWriteFallbackRequest) => Promise<boolean>;
41
+ /** A denied unlink, captured for a registered fallback to perform through a privileged channel. */
42
+ export interface FileDeleteFallbackRequest {
43
+ /**
44
+ * Absolute, symlink-resolved path the unlink was denied for.
45
+ *
46
+ * Every component ABOVE the last is resolved, so a handler cannot be walked
47
+ * outside its allowed roots through a link in the path. The last component is
48
+ * deliberately NOT resolved, because `unlink` removes a link itself rather
49
+ * than its target — which is also why this may still name a symlink.
50
+ */
51
+ dst: string;
52
+ /** The `EPERM`/`EACCES`/`EROFS` that proves the unlink hit a permission boundary. */
53
+ cause: unknown;
54
+ /**
55
+ * Whether `dst` was confirmed to be a plain regular file before diverting.
56
+ *
57
+ * `false` means the seam could not establish that, either because the target's
58
+ * own metadata is behind the same boundary that denied the unlink — the common
59
+ * sandbox case, since `unlink` on a directory also reports `EPERM` on Darwin —
60
+ * or because `dst` is a symlink.
61
+ *
62
+ * A handler MUST remove `dst` with a plain unlink. It MUST NOT remove it
63
+ * recursively, and MUST NOT resolve the path first: when this is `false` the
64
+ * target may be a directory, and resolving a symlink would delete whatever it
65
+ * points at instead of the link.
66
+ */
67
+ confirmedFile: boolean;
68
+ /** See {@link FileWriteFallbackRequest.sessionId}. */
69
+ sessionId: string | undefined;
70
+ }
71
+ /** Extension-authored handler. Return `true` once `dst` is gone from disk. */
72
+ export type FileDeleteFallbackHandler = (req: FileDeleteFallbackRequest, ctx: ExtensionContext) => Promise<boolean>;
73
+ /** A handler already bound to its owning extension's live context. */
74
+ type BoundFileDeleteFallbackHandler = (req: FileDeleteFallbackRequest) => Promise<boolean>;
75
+ /** True for `EPERM`, `EACCES`, and `EROFS` — the sandbox-boundary write failures this seam exists for. */
76
+ export declare function isPermissionDeniedError(error: unknown): boolean;
77
+ /** Whether any fallback is registered. Lets a caller skip work that only this seam needs. */
78
+ export declare function hasFileWriteFallback(): boolean;
79
+ /**
80
+ * Append a fallback writer, consulted in registration order when a direct write is
81
+ * permission-denied. Returns a disposer that removes this exact registration; the
82
+ * runner calls it on session shutdown so no handler outlives its session.
83
+ */
84
+ export declare function addFileWriteFallback(handler: BoundFileWriteFallbackHandler): () => void;
85
+ /** Whether any delete fallback is registered. */
86
+ export declare function hasFileDeleteFallback(): boolean;
87
+ /**
88
+ * Append a fallback deleter, consulted in registration order when a direct unlink is
89
+ * permission-denied. Deliberately a separate registry from
90
+ * {@link addFileWriteFallback}: a write handler brokers `content` to `dst`, and
91
+ * handing it a request with no content would let it "broker" an empty write and
92
+ * truncate the file it was asked to remove. Opting in is explicit for that reason.
93
+ */
94
+ export declare function addFileDeleteFallback(handler: BoundFileDeleteFallbackHandler): () => void;
95
+ /**
96
+ * Name the session whose tool call is about to run, so a denied mutation inside it
97
+ * can tell a handler where the request came from.
98
+ *
99
+ * Entered once per tool call by `ExtensionToolWrapper` (`extensibility/extensions/
100
+ * wrapper.ts`), which `sdk.ts` puts around the whole tool registry whenever an
101
+ * `ExtensionRunner` exists — so the component that owns the handlers is the one
102
+ * naming its own session, and no caller has to thread an `AgentToolContext`
103
+ * through for attribution to work.
104
+ *
105
+ * That covers the deferred LSP write batch too: a batch id belongs to one
106
+ * assistant turn of one session, and its flush is awaited inside a tool call of
107
+ * that same session, so a write performed during a later call of the group is
108
+ * still attributed to the session that issued it.
109
+ *
110
+ * Deliberately NOT a general "current session" accessor: nothing else enters this
111
+ * scope, so outside a tool call it is empty by design — an external `applyPatch`
112
+ * caller reports `undefined` rather than borrowing someone else's identity.
113
+ */
114
+ export declare function withFileMutationSession<T>(sessionId: string | undefined, fn: () => T): T;
115
+ /**
116
+ * Remove a file, consulting registered delete fallbacks when the unlink is denied.
117
+ *
118
+ * Unlike the write path there is no masked-`ENOENT` case to see through: nothing is
119
+ * created on the way, so an `ENOENT` here means the file genuinely is not there and
120
+ * must propagate — `edit`'s `REM` turns it into a `NotFoundError`.
121
+ */
122
+ export declare function deleteFileWithFallback(dst: string, file?: BunFile): Promise<void>;
123
+ export declare function writeFileWithFallback(dst: string, content: string, file?: BunFile): Promise<void>;
124
+ export {};
@@ -50,6 +50,7 @@ export * from "./debug.js";
50
50
  export * from "./essential-tools.js";
51
51
  export * from "./eval.js";
52
52
  export * from "./eval-backends.js";
53
+ export * from "./file-write-fallback.js";
53
54
  export * from "./gh.js";
54
55
  export * from "./glob.js";
55
56
  export * from "./grep.js";
@@ -155,6 +155,29 @@ export declare function resolveToCwd(filePath: string, cwd: string): string;
155
155
  * The cwd itself is rejected: a download names a file, never the directory.
156
156
  */
157
157
  export declare function confineToWorkspace(filePath: string, cwd: string): string | null;
158
+ /**
159
+ * Resolve the path a syscall on `filePath` would really act on, or `null` when
160
+ * that cannot be established.
161
+ *
162
+ * A lexical path is not a destination. The kernel follows every component above
163
+ * the last, so `ws/link/file` under a `ws/link -> /elsewhere` link lands outside
164
+ * `ws` while still looking relative and `..`-free. Handing such a path to a
165
+ * privileged helper defeats the defence a helper author reaches for first — a
166
+ * prefix allowlist passes, because the link sits inside the allowed root while
167
+ * its target does not. Callers that hand a path to something more privileged
168
+ * than the syscall that just failed resolve it here first.
169
+ *
170
+ * Rejecting symlinked components outright is not an option: `/var` and `/tmp`
171
+ * are links on macOS, so every path under `os.tmpdir()` traverses one. They are
172
+ * resolved instead, and only a path whose real destination cannot be established
173
+ * is refused, because "where would this land" then has no answer to hand over.
174
+ * {@link confineToWorkspace} refuses an unresolvable link for the same reason.
175
+ *
176
+ * @param followFinal `true` for a syscall that follows a link at the final
177
+ * component (`open`, so every write), `false` for one that acts on the link
178
+ * itself (`unlink`) and therefore needs it left alone.
179
+ */
180
+ export declare function resolveSyscallTarget(filePath: string, followFinal: boolean): Promise<string | null>;
158
181
  export declare function formatPathRelativeToCwd(filePath: string, cwd: string, options?: {
159
182
  trailingSlash?: boolean;
160
183
  }): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.3.5",
4
+ "version": "17.3.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -48,18 +48,18 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@babel/parser": "^7.29.7",
51
- "@oh-my-pi/hashline": "17.3.5",
52
- "@oh-my-pi/omp-stats": "17.3.5",
53
- "@oh-my-pi/omptype": "17.3.5",
54
- "@oh-my-pi/pi-agent-core": "17.3.5",
55
- "@oh-my-pi/pi-ai": "17.3.5",
56
- "@oh-my-pi/pi-catalog": "17.3.5",
57
- "@oh-my-pi/pi-mnemopi": "17.3.5",
58
- "@oh-my-pi/pi-natives": "17.3.5",
59
- "@oh-my-pi/pi-tui": "17.3.5",
60
- "@oh-my-pi/pi-utils": "17.3.5",
61
- "@oh-my-pi/pi-wire": "17.3.5",
62
- "@oh-my-pi/snapcompact": "17.3.5",
51
+ "@oh-my-pi/hashline": "17.3.7",
52
+ "@oh-my-pi/omp-stats": "17.3.7",
53
+ "@oh-my-pi/omptype": "17.3.7",
54
+ "@oh-my-pi/pi-agent-core": "17.3.7",
55
+ "@oh-my-pi/pi-ai": "17.3.7",
56
+ "@oh-my-pi/pi-catalog": "17.3.7",
57
+ "@oh-my-pi/pi-mnemopi": "17.3.7",
58
+ "@oh-my-pi/pi-natives": "17.3.7",
59
+ "@oh-my-pi/pi-tui": "17.3.7",
60
+ "@oh-my-pi/pi-utils": "17.3.7",
61
+ "@oh-my-pi/pi-wire": "17.3.7",
62
+ "@oh-my-pi/snapcompact": "17.3.7",
63
63
  "@opentelemetry/api": "^1.9.1",
64
64
  "@opentelemetry/api-logs": "^0.220.0",
65
65
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
8
- import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
8
+ import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
9
9
  import chalk from "@oh-my-pi/pi-utils/chalk";
10
10
  import { openPath } from "../utils/open";
11
11
 
@@ -57,45 +57,11 @@ function shortenSessionFile(p: string): string {
57
57
 
58
58
  export interface StatsCommandArgs {
59
59
  port: number;
60
+ host: string;
60
61
  json: boolean;
61
62
  summary: boolean;
62
63
  }
63
64
 
64
- // =============================================================================
65
- // Argument Parser
66
- // =============================================================================
67
-
68
- /**
69
- * Parse stats subcommand arguments.
70
- * Returns undefined if not a stats command.
71
- */
72
- export function parseStatsArgs(args: string[]): StatsCommandArgs | undefined {
73
- if (args.length === 0 || args[0] !== "stats") {
74
- return undefined;
75
- }
76
-
77
- const result: StatsCommandArgs = {
78
- port: 3847,
79
- json: false,
80
- summary: false,
81
- };
82
-
83
- for (let i = 1; i < args.length; i++) {
84
- const arg = args[i];
85
- if (arg === "--json" || arg === "-j") {
86
- result.json = true;
87
- } else if (arg === "--summary" || arg === "-s") {
88
- result.summary = true;
89
- } else if ((arg === "--port" || arg === "-p") && i + 1 < args.length) {
90
- result.port = parseInt(args[++i], 10);
91
- } else if (arg.startsWith("--port=")) {
92
- result.port = parseInt(arg.split("=")[1], 10);
93
- }
94
- }
95
-
96
- return result;
97
- }
98
-
99
65
  function formatCost(n: number): string {
100
66
  if (n < 0.01) return `$${n.toFixed(4)}`;
101
67
  if (n < 1) return `$${n.toFixed(3)}`;
@@ -112,9 +78,8 @@ function normalizePremiumRequests(n: number): number {
112
78
 
113
79
  export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
114
80
  // Lazy import to avoid loading stats module when not needed
115
- const { getDashboardStats, syncAllSessions, getTotalMessageCount, startServer, closeDb } = await import(
116
- "@oh-my-pi/omp-stats"
117
- );
81
+ const { closeDb, formatStatsDashboardUrl, getDashboardStats, getTotalMessageCount, startServer, syncAllSessions } =
82
+ await import("@oh-my-pi/omp-stats");
118
83
 
119
84
  // Sync session files first
120
85
  const progress = createSyncProgressReporter();
@@ -136,8 +101,8 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
136
101
  }
137
102
 
138
103
  // Start the dashboard server
139
- const { hostname, port } = await startServer(cmd.port);
140
- const url = `http://${hostname}:${port}`;
104
+ const { hostname, port } = await startServer(cmd.port, cmd.host);
105
+ const url = formatStatsDashboardUrl(hostname, port);
141
106
  console.log(chalk.green(`Dashboard available at: ${url}`));
142
107
 
143
108
  // Open browser
@@ -197,34 +162,3 @@ async function printStatsSummary(): Promise<void> {
197
162
 
198
163
  console.log("");
199
164
  }
200
-
201
- // =============================================================================
202
- // Help
203
- // =============================================================================
204
-
205
- export function printStatsHelp(): void {
206
- console.log(`${chalk.bold(`${APP_NAME} stats`)} - AI Usage Statistics Dashboard
207
-
208
- ${chalk.bold("Usage:")}
209
- ${APP_NAME} stats [options]
210
-
211
- ${chalk.bold("Options:")}
212
- -p, --port <port> Port for the dashboard server (default: 3847)
213
- -j, --json Output stats as JSON and exit
214
- -s, --summary Print summary to console and exit
215
- -h, --help Show this help message
216
-
217
- ${chalk.bold("Examples:")}
218
- ${APP_NAME} stats # Start dashboard server
219
- ${APP_NAME} stats --json # Print stats as JSON
220
- ${APP_NAME} stats --summary # Print summary to console
221
- ${APP_NAME} stats --port 8080 # Start on custom port
222
-
223
- ${chalk.bold("Metrics:")}
224
- - Total requests and error rate
225
- - Token usage (input, output, cache)
226
- - Cost breakdown
227
- - Average duration and time to first token (TTFT)
228
- - Tokens per second throughput
229
- `);
230
- }
@@ -4,13 +4,15 @@
4
4
 
5
5
  import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
6
6
  import { statsHelp as commandHelp } from "../cli/command-help";
7
- import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli";
8
- import { initTheme } from "../modes/theme/theme";
7
+ import type { StatsCommandArgs } from "../cli/stats-cli";
8
+ import * as statsCli from "../cli/stats-cli";
9
+ import * as theme from "../modes/theme/theme";
9
10
 
10
11
  export default class Stats extends Command {
11
12
  static description = commandHelp.description;
12
13
  static flags = {
13
14
  port: Flags.integer({ char: "p", description: "Port for the dashboard server", default: 3847 }),
15
+ host: Flags.string({ description: "Host to bind", default: "127.0.0.1" }),
14
16
  json: Flags.boolean({ char: "j", description: "Output stats as JSON", default: false }),
15
17
  summary: Flags.boolean({ char: "s", description: "Print summary to console", default: false }),
16
18
  };
@@ -20,11 +22,12 @@ export default class Stats extends Command {
20
22
 
21
23
  const cmd: StatsCommandArgs = {
22
24
  port: flags.port,
25
+ host: flags.host ?? "127.0.0.1",
23
26
  json: flags.json,
24
27
  summary: flags.summary,
25
28
  };
26
29
 
27
- await initTheme();
28
- await runStatsCommand(cmd);
30
+ await theme.initTheme();
31
+ await statsCli.runStatsCommand(cmd);
29
32
  }
30
33
  }
@@ -25,6 +25,7 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
25
25
  import type { ToolSession } from "../../tools";
26
26
  import { routeWriteThroughBridge } from "../../tools/acp-bridge";
27
27
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
28
+ import { deleteFileWithFallback, writeFileWithFallback } from "../../tools/file-write-fallback";
28
29
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
29
30
  import { isInternalUrlPath } from "../../tools/path-utils";
30
31
  import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard";
@@ -153,7 +154,7 @@ export class HashlineFilesystem extends Filesystem {
153
154
  enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
154
155
  const absolutePath = this.resolveAbsolute(relativePath);
155
156
  try {
156
- await fs.rm(absolutePath);
157
+ await deleteFileWithFallback(absolutePath);
157
158
  } catch (error) {
158
159
  if (isEnoent(error)) throw new NotFoundError(relativePath, error);
159
160
  throw error;
@@ -173,8 +174,13 @@ export class HashlineFilesystem extends Filesystem {
173
174
  const fromAbsolute = this.resolveAbsolute(fromRelative);
174
175
  const toAbsolute = this.resolveAbsolute(toRelative);
175
176
  if (content !== undefined) {
176
- await Bun.write(toAbsolute, content);
177
- await fs.rm(fromAbsolute);
177
+ // The one `edit` write that does not pass through the writethrough, so it
178
+ // routes to the fallback seam directly. `patcher.ts` always supplies
179
+ // `content` for a hashline `MV`, making this the live branch. The source
180
+ // unlink is a separate primitive with its own seam, so a move out of the
181
+ // workspace and a move out of denied territory both complete.
182
+ await writeFileWithFallback(toAbsolute, content);
183
+ await deleteFileWithFallback(fromAbsolute);
178
184
  } else {
179
185
  await fs.rename(fromAbsolute, toAbsolute);
180
186
  }
@@ -20,6 +20,12 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
20
20
  import type { ToolSession } from "../../tools";
21
21
  import { routeWriteThroughBridge } from "../../tools/acp-bridge";
22
22
  import { assertEditableFile } from "../../tools/auto-generated-guard";
23
+ import {
24
+ deleteFileWithFallback,
25
+ hasFileWriteFallback,
26
+ isPermissionDeniedError,
27
+ writeFileWithFallback,
28
+ } from "../../tools/file-write-fallback";
23
29
  import {
24
30
  invalidateFsScanAfterDelete,
25
31
  invalidateFsScanAfterRename,
@@ -111,6 +117,26 @@ export interface ApplyPatchOptions {
111
117
  // Default File System
112
118
  // ═══════════════════════════════════════════════════════════════════════════
113
119
 
120
+ /**
121
+ * Create a patch target's parent directory, tolerating a permission denial when a
122
+ * file-write fallback is registered.
123
+ *
124
+ * `apply_patch` mkdirs the parent before writing, so under a sandbox that denies
125
+ * the out-of-tree path this throws before the write — and therefore before
126
+ * {@link writeFileWithFallback} — is ever reached, leaving the fallback unable to
127
+ * broker a `create` or a rename-move into a new directory. Swallowing only a
128
+ * permission denial, and only with a handler installed, hands control to the write,
129
+ * which reports the denial through the seam. Without a handler the error propagates
130
+ * exactly as before.
131
+ */
132
+ async function mkdirAllowingFallback(dir: string): Promise<void> {
133
+ try {
134
+ await fs.promises.mkdir(dir, { recursive: true });
135
+ } catch (error) {
136
+ if (!hasFileWriteFallback() || !isPermissionDeniedError(error)) throw error;
137
+ }
138
+ }
139
+
114
140
  /** Default filesystem implementation using Bun APIs */
115
141
  export const defaultFileSystem: FileSystem = {
116
142
  async exists(path: string): Promise<boolean> {
@@ -123,13 +149,13 @@ export const defaultFileSystem: FileSystem = {
123
149
  return fs.promises.readFile(path);
124
150
  },
125
151
  async write(path: string, content: string): Promise<void> {
126
- await Bun.write(path, await serializeEditFileText(path, path, content));
152
+ await writeFileWithFallback(path, await serializeEditFileText(path, path, content));
127
153
  },
128
154
  async delete(path: string): Promise<void> {
129
- await fs.promises.unlink(path);
155
+ await deleteFileWithFallback(path);
130
156
  },
131
157
  async mkdir(path: string): Promise<void> {
132
- await fs.promises.mkdir(path, { recursive: true });
158
+ await mkdirAllowingFallback(path);
133
159
  },
134
160
  };
135
161
 
@@ -1753,7 +1779,7 @@ class LspFileSystem implements FileSystem {
1753
1779
  }
1754
1780
 
1755
1781
  async delete(path: string): Promise<void> {
1756
- await this.#getFile(path).unlink();
1782
+ await deleteFileWithFallback(path, this.#getFile(path));
1757
1783
  if (this.session.enableLsp ?? true) {
1758
1784
  await notifyWorkspaceWatchedFiles(
1759
1785
  this.session.cwd,
@@ -1764,7 +1790,7 @@ class LspFileSystem implements FileSystem {
1764
1790
  }
1765
1791
 
1766
1792
  async mkdir(path: string): Promise<void> {
1767
- await fs.promises.mkdir(path, { recursive: true });
1793
+ await mkdirAllowingFallback(path);
1768
1794
  }
1769
1795
 
1770
1796
  getDiagnostics(): FileDiagnosticsResult | undefined {
@@ -28,6 +28,7 @@ import { execCommand } from "../../exec/exec";
28
28
  // Runtime self-reference: dereference this namespace only inside loader functions to keep the index.ts cycle safe.
29
29
  import * as PiCodingAgent from "../../index";
30
30
  import type { CustomMessagePayload } from "../../session/messages";
31
+ import type { FileDeleteFallbackHandler, FileWriteFallbackHandler } from "../../tools/file-write-fallback";
31
32
  import { EventBus } from "../../utils/event-bus";
32
33
  import * as TypeBox from "../legacy-typebox";
33
34
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "../plugins/legacy-pi-compat";
@@ -183,6 +184,14 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
183
184
  for (const listener of this.extension.toolRegistrationListeners ?? []) listener(tool.name);
184
185
  }
185
186
 
187
+ registerFileWriteFallback(handler: FileWriteFallbackHandler): void {
188
+ this.extension.fileWriteFallbackHandlers.push(handler);
189
+ }
190
+
191
+ registerFileDeleteFallback(handler: FileDeleteFallbackHandler): void {
192
+ this.extension.fileDeleteFallbackHandlers.push(handler);
193
+ }
194
+
186
195
  registerCommand(
187
196
  name: string,
188
197
  options: {
@@ -320,6 +329,8 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension
320
329
  tools: new Map(),
321
330
  toolRegistrationListeners: new Set(),
322
331
  assistantThinkingRenderers: [],
332
+ fileWriteFallbackHandlers: [],
333
+ fileDeleteFallbackHandlers: [],
323
334
  messageRenderers: new Map(),
324
335
  commands: new Map(),
325
336
  flags: new Map(),