@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.
- package/CHANGELOG.md +20 -0
- package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-1hmwwt45.md} +20 -0
- package/dist/cli.js +2839 -2839
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/extensibility/extensions/runner.d.ts +21 -3
- package/dist/types/extensibility/extensions/types.d.ts +58 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/package.json +13 -13
- package/src/cli/stats-cli.ts +6 -72
- package/src/commands/stats.ts +7 -4
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/extensibility/extensions/loader.ts +11 -0
- package/src/extensibility/extensions/runner.ts +115 -3
- package/src/extensibility/extensions/types.ts +60 -0
- package/src/extensibility/extensions/wrapper.ts +10 -1
- package/src/lsp/writethrough.ts +20 -10
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
|
@@ -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
|
|
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.
|
|
58
|
-
* when a handler fails so extension background work
|
|
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>;
|
|
@@ -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.
|
|
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.
|
|
52
|
-
"@oh-my-pi/omp-stats": "17.3.
|
|
53
|
-
"@oh-my-pi/omptype": "17.3.
|
|
54
|
-
"@oh-my-pi/pi-agent-core": "17.3.
|
|
55
|
-
"@oh-my-pi/pi-ai": "17.3.
|
|
56
|
-
"@oh-my-pi/pi-catalog": "17.3.
|
|
57
|
-
"@oh-my-pi/pi-mnemopi": "17.3.
|
|
58
|
-
"@oh-my-pi/pi-natives": "17.3.
|
|
59
|
-
"@oh-my-pi/pi-tui": "17.3.
|
|
60
|
-
"@oh-my-pi/pi-utils": "17.3.
|
|
61
|
-
"@oh-my-pi/pi-wire": "17.3.
|
|
62
|
-
"@oh-my-pi/snapcompact": "17.3.
|
|
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",
|
package/src/cli/stats-cli.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
|
|
8
|
-
import {
|
|
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 {
|
|
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 =
|
|
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
|
-
}
|
package/src/commands/stats.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
}
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -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
|
|
152
|
+
await writeFileWithFallback(path, await serializeEditFileText(path, path, content));
|
|
127
153
|
},
|
|
128
154
|
async delete(path: string): Promise<void> {
|
|
129
|
-
await
|
|
155
|
+
await deleteFileWithFallback(path);
|
|
130
156
|
},
|
|
131
157
|
async mkdir(path: string): Promise<void> {
|
|
132
|
-
await
|
|
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)
|
|
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
|
|
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(),
|