@oh-my-pi/pi-coding-agent 17.3.4 → 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.
Files changed (137) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-1hmwwt45.md} +88 -0
  3. package/dist/cli.js +3156 -3163
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/stats-cli.d.ts +1 -6
  6. package/dist/types/commands/stats.d.ts +4 -0
  7. package/dist/types/config/model-discovery.d.ts +2 -0
  8. package/dist/types/config/model-registry.d.ts +6 -1
  9. package/dist/types/config/settings-schema.d.ts +10 -0
  10. package/dist/types/debug/report-bundle.d.ts +7 -2
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +24 -6
  13. package/dist/types/extensibility/extensions/types.d.ts +60 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/launch/presence.d.ts +11 -0
  16. package/dist/types/lsp/client.d.ts +22 -0
  17. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  18. package/dist/types/mcp/config.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  21. package/dist/types/registry/agent-registry.d.ts +4 -0
  22. package/dist/types/session/agent-session-types.d.ts +0 -2
  23. package/dist/types/session/agent-session.d.ts +20 -0
  24. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  25. package/dist/types/session/irc-bridge.d.ts +2 -0
  26. package/dist/types/session/messages.d.ts +4 -0
  27. package/dist/types/session/session-loader.d.ts +18 -11
  28. package/dist/types/session/session-maintenance.d.ts +6 -2
  29. package/dist/types/session/session-manager.d.ts +19 -3
  30. package/dist/types/session/session-tools.d.ts +0 -1
  31. package/dist/types/session/turn-recovery.d.ts +7 -7
  32. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  33. package/dist/types/tools/browser/attach.d.ts +16 -0
  34. package/dist/types/tools/browser/launch.d.ts +2 -2
  35. package/dist/types/tools/browser.d.ts +1 -1
  36. package/dist/types/tools/builtin-names.d.ts +1 -1
  37. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  38. package/dist/types/tools/hub/jobs.d.ts +6 -0
  39. package/dist/types/tools/hub/types.d.ts +6 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/vibe/runtime.d.ts +3 -3
  43. package/package.json +13 -13
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/stats-cli.ts +6 -72
  47. package/src/commands/stats.ts +7 -4
  48. package/src/commit/analysis/conventional.ts +15 -9
  49. package/src/commit/analysis/summary.ts +15 -9
  50. package/src/commit/changelog/generate.ts +15 -9
  51. package/src/commit/map-reduce/map-phase.ts +3 -19
  52. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  53. package/src/config/model-discovery.ts +3 -3
  54. package/src/config/model-registry.ts +29 -16
  55. package/src/config/settings-schema.ts +13 -0
  56. package/src/debug/report-bundle.ts +25 -59
  57. package/src/discovery/claude-plugins.ts +2 -1
  58. package/src/discovery/claude.ts +4 -2
  59. package/src/discovery/helpers.ts +5 -0
  60. package/src/edit/hashline/filesystem.ts +9 -3
  61. package/src/edit/modes/patch.ts +31 -5
  62. package/src/edit/renderer.ts +48 -19
  63. package/src/extensibility/extensions/loader.ts +20 -5
  64. package/src/extensibility/extensions/runner.ts +239 -24
  65. package/src/extensibility/extensions/types.ts +62 -0
  66. package/src/extensibility/extensions/wrapper.ts +26 -11
  67. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  68. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  69. package/src/irc/bus.ts +1 -1
  70. package/src/launch/broker.ts +8 -1
  71. package/src/launch/presence.ts +77 -1
  72. package/src/lsp/client.ts +35 -3
  73. package/src/lsp/clients/biome-client.ts +47 -93
  74. package/src/lsp/servers.ts +17 -11
  75. package/src/lsp/tool.ts +2 -4
  76. package/src/lsp/workspace-diagnostics.ts +24 -2
  77. package/src/lsp/writethrough.ts +20 -10
  78. package/src/mcp/config.ts +50 -6
  79. package/src/memories/index.ts +29 -25
  80. package/src/mnemopi/backend.ts +13 -11
  81. package/src/modes/components/ask-dialog.ts +25 -5
  82. package/src/modes/components/tool-execution.ts +10 -6
  83. package/src/modes/components/welcome.ts +4 -1
  84. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  85. package/src/modes/controllers/selector-controller.ts +6 -0
  86. package/src/modes/controllers/tan-command-controller.ts +1 -0
  87. package/src/modes/interactive-mode.ts +49 -10
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  90. package/src/prompts/system/date-cwd-reminder.md +3 -0
  91. package/src/prompts/system/project-prompt.md +0 -1
  92. package/src/prompts/system/rewind-report.md +1 -3
  93. package/src/prompts/tools/browser.md +1 -0
  94. package/src/prompts/tools/rewind.md +1 -13
  95. package/src/registry/agent-lifecycle.ts +34 -0
  96. package/src/registry/agent-registry.ts +27 -2
  97. package/src/registry/persisted-agents.ts +40 -20
  98. package/src/sdk.ts +32 -2
  99. package/src/session/agent-session-types.ts +0 -2
  100. package/src/session/agent-session.ts +178 -46
  101. package/src/session/date-cwd-reminder.ts +77 -0
  102. package/src/session/irc-bridge.ts +5 -0
  103. package/src/session/messages.ts +5 -1
  104. package/src/session/session-loader.ts +106 -64
  105. package/src/session/session-maintenance.ts +189 -115
  106. package/src/session/session-manager.ts +142 -35
  107. package/src/session/session-persistence.ts +4 -4
  108. package/src/session/session-storage.ts +18 -5
  109. package/src/session/session-tools.ts +5 -10
  110. package/src/session/turn-recovery.ts +102 -20
  111. package/src/session/unexpected-stop-classifier.ts +33 -21
  112. package/src/slash-commands/builtin-session.ts +1 -1
  113. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  114. package/src/system-prompt.ts +0 -5
  115. package/src/task/executor.ts +4 -15
  116. package/src/task/persisted-revive.ts +3 -6
  117. package/src/tools/ask.ts +10 -3
  118. package/src/tools/browser/attach.ts +80 -25
  119. package/src/tools/browser/launch.ts +44 -15
  120. package/src/tools/browser/relay/daemon.ts +3 -7
  121. package/src/tools/browser/render.ts +1 -1
  122. package/src/tools/browser/shared-daemon.ts +3 -7
  123. package/src/tools/browser.ts +5 -5
  124. package/src/tools/builtin-names.ts +7 -3
  125. package/src/tools/checkpoint.ts +1 -7
  126. package/src/tools/file-write-fallback.ts +467 -0
  127. package/src/tools/hub/index.ts +1 -1
  128. package/src/tools/hub/jobs.ts +20 -3
  129. package/src/tools/hub/types.ts +6 -0
  130. package/src/tools/index.ts +1 -0
  131. package/src/tools/path-utils.ts +79 -0
  132. package/src/tools/render-utils.ts +53 -21
  133. package/src/tools/think.ts +15 -3
  134. package/src/tts/speech-enhancer.ts +19 -14
  135. package/src/utils/commit-message-generator.ts +14 -12
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
@@ -129,8 +129,8 @@ export declare class TurnRecovery {
129
129
  handleEmptyAssistantStop(message: AssistantMessage): Promise<"continue" | "terminal" | undefined>;
130
130
  /** Classifies suspicious terminal stops and schedules bounded recovery. */
131
131
  handleUnexpectedAssistantStop(message: AssistantMessage): Promise<boolean>;
132
- /** Removes a persisted failed assistant turn after its persistence slot settles. */
133
- dropPersistedAssistantTurn(message: AssistantMessage): Promise<void>;
132
+ /** Removes a persisted failed assistant turn after its persistence slot settles; returns the dropped branch entry id. */
133
+ dropPersistedAssistantTurn(message: AssistantMessage): Promise<string | undefined>;
134
134
  /** Runs recovery compaction and restores the failed turn when no rewrite occurs. */
135
135
  runRecoveryCompactionWithRollback(reason: "overflow" | "incomplete", message: AssistantMessage, allowDefer: boolean, options: {
136
136
  autoContinue: boolean;
@@ -174,7 +174,7 @@ export declare class TurnRecovery {
174
174
  * the Gemini header-runaway interrupt, which must not replay a partial,
175
175
  * loop-fueling thinking block.
176
176
  */
177
- discardAssistantTurn(assistantMessage: AssistantMessage): void;
177
+ discardAssistantTurn(assistantMessage: AssistantMessage): string | undefined;
178
178
  /**
179
179
  * Retry an empty, reason-less provider abort: a turn with no content that
180
180
  * carries the generic sentinel (bare `abort()`), whether the provider
@@ -198,10 +198,10 @@ export declare class TurnRecovery {
198
198
  */
199
199
  isRetryableError(message: AssistantMessage): boolean;
200
200
  /**
201
- * Classify a reasonless abort or stream stall whose emitted tool calls all
202
- * have results. The failed assistant/tool-result pair stays in context so
203
- * continuation cannot replay completed side effects; synthetic results tell
204
- * the next turn that an unexecuted call must be reissued.
201
+ * Classify a reasonless abort, idle stream stall, or HTTP/2 stream reset whose
202
+ * emitted tool calls all have results. The failed assistant/tool-result pair
203
+ * stays in context so continuation cannot replay completed side effects;
204
+ * synthetic results tell the next turn that an unexecuted call must be reissued.
205
205
  */
206
206
  classifyResolvedInterruptedToolTurn(message: AssistantMessage): "reasonless-abort" | "stream-stall" | undefined;
207
207
  /** Checks whether a provider error represents a classifier refusal. */
@@ -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;
@@ -5,6 +5,22 @@ import type { Browser, Page } from "puppeteer-core";
5
5
  * subsequent bind in the launched app, but Chromium's listener will retry.
6
6
  */
7
7
  export declare function findFreeCdpPort(): Promise<number>;
8
+ /**
9
+ * Loopback HTTP/1.1 GET that never routes through a proxy, resolving to the
10
+ * response status code (or null when the endpoint is unreachable, aborted,
11
+ * malformed, or slow past `timeoutMs`).
12
+ *
13
+ * Chrome's DevTools endpoint listens on loopback and speaks plain HTTP/1.1.
14
+ * Both `fetch` and Bun's `node:http` honor `HTTP_PROXY`/`HTTPS_PROXY` and
15
+ * forward even `127.0.0.1` requests to the proxy unless `NO_PROXY` covers them,
16
+ * so a local proxy that 502s internal addresses makes a healthy daemon look
17
+ * dead and the CDP readiness checks tear it down (issue #8567). Talking to the
18
+ * socket over raw TCP sidesteps proxy env entirely.
19
+ */
20
+ export declare function probeCdpStatus(url: string, opts: {
21
+ timeoutMs: number;
22
+ signal?: AbortSignal;
23
+ }): Promise<number | null>;
8
24
  /** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */
9
25
  export declare function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise<void>;
10
26
  /**
@@ -56,8 +56,8 @@ export interface SharedBrowserLaunchSpec {
56
56
  * Resolve the executable and complete argv for a shared Chromium the daemon
57
57
  * broker spawns directly (no puppeteer inside the broker). Mirrors
58
58
  * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the
59
- * stealth-suppressed set — plus `--remote-debugging-port=0` so every client
60
- * attaches over CDP. Returns null when no Chromium executable resolves;
59
+ * stealth-suppressed set — suppresses Puppeteer's unowned startup window, and
60
+ * exposes CDP on an ephemeral port. Returns null when no executable resolves;
61
61
  * callers fall back to a process-local launch.
62
62
  */
63
63
  export declare function resolveSharedBrowserLaunchSpec(opts: {
@@ -76,7 +76,7 @@ export interface BrowserToolDetails {
76
76
  /**
77
77
  * Browser tool: stateful, multi-tab. Three actions:
78
78
  * - `open` → acquire/create a named tab on a browser kind (headless | spawned | connected) and optionally goto a url.
79
- * - `close` → release a named tab (or all tabs); dispose browser when refcount hits 0.
79
+ * - `close` → release a named tab handle (or all handles); attached/relay pages remain open, and spawned pages remain unless killed.
80
80
  * - `run` → execute JS code against an existing tab with `page`/`browser`/`tab` helpers in scope.
81
81
  */
82
82
  export declare class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
@@ -2,7 +2,7 @@ export declare const BUILTIN_TOOL_NAMES: readonly ["read", "bash", "edit", "ast_
2
2
  export type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number];
3
3
  export declare const HIDDEN_TOOL_NAMES: readonly ["yield", "goal", "think"];
4
4
  export type HiddenToolName = (typeof HIDDEN_TOOL_NAMES)[number];
5
- /** Return the canonical tool name for current and legacy built-in tool IDs. */
5
+ /** Canonicalize built-in IDs and legacy aliases. Leave plugin names unchanged. */
6
6
  export declare function normalizeToolName(name: string): string;
7
7
  /** Normalize and deduplicate tool names while preserving first-seen order. */
8
8
  export declare function normalizeToolNames(names: Iterable<string>): 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 {};
@@ -36,6 +36,12 @@ export declare function visibleJobs(manager: AsyncJobManager, ids: string[], own
36
36
  * that activity instead of implying the system is quiet. Existence is
37
37
  * already public via the peer roster, so listing ids here leaks nothing new;
38
38
  * job *control* stays owner-scoped.
39
+ *
40
+ * Reporting deliberately uses the claimed `status`, not the session-corroborated
41
+ * `registry.isRunning` used by the wait-sustaining gates: a ref that claims
42
+ * `running` with no live turn is exactly the stale entry an operator must see
43
+ * here to cancel it (#8634). Hiding it would match the badge count to nothing
44
+ * and remove the only discovery path for the id.
39
45
  */
40
46
  export declare function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySnapshot[];
41
47
  interface TrackedJobLike {
@@ -55,6 +55,12 @@ export interface AgentActivitySnapshot {
55
55
  activity?: string;
56
56
  /** Time since the agent was registered. */
57
57
  ageMs: number;
58
+ /**
59
+ * Whether an attached session corroborates the `running` claim. False marks
60
+ * a ref that says `running` with no turn in flight — either a spawn still
61
+ * wiring up or a stale registration that `hub cancel <id>` clears (#8634).
62
+ */
63
+ live: boolean;
58
64
  }
59
65
  /** Result details for messaging and job ops; fields are disjoint per op. */
60
66
  export interface CoordinationDetails {
@@ -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;
@@ -102,15 +102,15 @@ export declare class VibeSessionRegistry {
102
102
  /** Reset the global registry. Test-only. */
103
103
  static resetGlobalForTests(): void;
104
104
  /**
105
- * Insert a bare worker record without the spawn/job machinery. Test-only —
106
- * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
107
- * fake roster + AgentRegistry session without driving a real turn.
105
+ * Insert a bare worker record without the spawn machinery. Test-only —
106
+ * lets focused runtime tests attach an optional synthetic in-flight job.
108
107
  */
109
108
  registerRecordForTests(record: {
110
109
  id: string;
111
110
  cli?: VibeCli;
112
111
  ownerId: string;
113
112
  state?: VibeSessionState;
113
+ jobId?: string;
114
114
  }): void;
115
115
  /** Override the teardown grace period for deterministic lifecycle tests. */
116
116
  setTeardownGraceForTesting(timeoutMs: number): void;
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",
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.4",
52
- "@oh-my-pi/omp-stats": "17.3.4",
53
- "@oh-my-pi/omptype": "17.3.4",
54
- "@oh-my-pi/pi-agent-core": "17.3.4",
55
- "@oh-my-pi/pi-ai": "17.3.4",
56
- "@oh-my-pi/pi-catalog": "17.3.4",
57
- "@oh-my-pi/pi-mnemopi": "17.3.4",
58
- "@oh-my-pi/pi-natives": "17.3.4",
59
- "@oh-my-pi/pi-tui": "17.3.4",
60
- "@oh-my-pi/pi-utils": "17.3.4",
61
- "@oh-my-pi/pi-wire": "17.3.4",
62
- "@oh-my-pi/snapcompact": "17.3.4",
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",
@@ -14,7 +14,7 @@
14
14
  * Throws on any failure (no model, no key, unparseable output, abort/timeout);
15
15
  * the caller falls back to a concrete level and continues the turn.
16
16
  */
17
- import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
17
+ import { type AssistantMessage, completeSimple, Effort, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai";
18
18
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
19
19
  import { prompt } from "@oh-my-pi/pi-utils";
20
20
 
@@ -60,14 +60,24 @@ function difficultySystemPromptFor(ceiling: Effort): string {
60
60
 
61
61
  /** Local classifiers occasionally need more room for chat-template boilerplate. */
62
62
  const LOCAL_ANSWER_MAX_TOKENS = 16;
63
+ /** On-device reasoning classifiers need room for the bucket keyword after the `<think>` preamble. */
64
+ const LOCAL_REASONING_MAX_TOKENS = 1024;
63
65
  /**
64
- * Online classifier budget. Sized to survive backends that ignore
65
- * `disableReasoning` (e.g. Qwen3 via llama.cpp catalogued `reasoning: false`
66
- * but still emitting thinking): the classifier keyword needs to land after any
67
- * unavoidable thinking preamble. `maxTokens` is a hard cap — non-thinking
68
- * completions still return in a handful of tokens (issue #4355).
66
+ * Online classifier budget. Sized against two independent constraints:
67
+ * - Backends that ignore `disableReasoning` still emit a thinking preamble
68
+ * (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` but still thinking;
69
+ * Anthropic via LiteLLM/Vertex, whose `openai-completions` route downgrades a
70
+ * disabled request to the lowest reasoning effort instead of turning thinking
71
+ * off). The classifier keyword must have room to land after that preamble
72
+ * (issue #4355).
73
+ * - Anthropic-dialect proxies reject `max_tokens <= thinking.budget_tokens`. The
74
+ * pinned lowest effort maps to at least Anthropic's 1024-token minimum budget,
75
+ * so the cap MUST comfortably exceed 1024 or every classifier call 400s with
76
+ * `max_tokens must be greater than thinking.budget_tokens` (issue #8610).
77
+ * `maxTokens` is a hard cap — non-thinking completions still return in a handful
78
+ * of tokens.
69
79
  */
70
- const REASONING_SAFE_MAX_TOKENS = 1024;
80
+ const ONLINE_REASONING_SAFE_MAX_TOKENS = 4096;
71
81
 
72
82
  export interface ClassifyDifficultyDeps {
73
83
  settings: Settings;
@@ -113,21 +123,25 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps, ceili
113
123
  }
114
124
  // Resolve metadata after getApiKey so the session-sticky credential is recorded first.
115
125
  const metadata = deps.metadataResolver?.(model.provider);
116
- const maxTokens = REASONING_SAFE_MAX_TOKENS;
117
-
118
- const response = await completeSimple(
119
- model,
120
- {
121
- systemPrompt: [difficultySystemPromptFor(ceiling)],
122
- messages: [{ role: "user", content: input, timestamp: Date.now() }],
123
- },
124
- {
125
- apiKey: deps.registry.resolver(model, deps.sessionId),
126
- maxTokens,
127
- disableReasoning: true,
128
- metadata,
129
- signal: deps.signal,
130
- },
126
+ const maxTokens = ONLINE_REASONING_SAFE_MAX_TOKENS;
127
+
128
+ const response = await retryTransientCompletion(
129
+ () =>
130
+ completeSimple(
131
+ model,
132
+ {
133
+ systemPrompt: [difficultySystemPromptFor(ceiling)],
134
+ messages: [{ role: "user", content: input, timestamp: Date.now() }],
135
+ },
136
+ {
137
+ apiKey: deps.registry.resolver(model, deps.sessionId),
138
+ maxTokens,
139
+ disableReasoning: true,
140
+ metadata,
141
+ signal: deps.signal,
142
+ },
143
+ ),
144
+ { signal: deps.signal },
131
145
  );
132
146
 
133
147
  if (response.stopReason === "error") {
@@ -147,7 +161,7 @@ async function classifyLocal(input: string, modelKey: string, deps: ClassifyDiff
147
161
  throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`);
148
162
  }
149
163
  const maxTokens = isTinyMemoryReasoningModelKey(modelKey)
150
- ? Math.max(LOCAL_ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
164
+ ? Math.max(LOCAL_ANSWER_MAX_TOKENS, LOCAL_REASONING_MAX_TOKENS)
151
165
  : LOCAL_ANSWER_MAX_TOKENS;
152
166
  const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input });
153
167
  const text = await tinyModelClient.complete(modelKey, builtPrompt, {
@@ -304,7 +304,7 @@ export async function runModelsListing(options: RunModelsListingOptions): Promis
304
304
  cwd,
305
305
  eventBus,
306
306
  disableExtensionDiscovery ? undefined : disabledExtensionIds,
307
- { ambient: !disableExtensionDiscovery },
307
+ { ambient: !disableExtensionDiscovery, includeAmbientHooks: false },
308
308
  );
309
309
  const extensionRunner =
310
310
  extensionsResult.extensions.length > 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
  }
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
3
- import { completeSimple } from "@oh-my-pi/pi-ai";
3
+ import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import analysisSystemPrompt from "../../commit/prompts/analysis-system.md" with { type: "text" };
6
6
  import analysisUserPrompt from "../../commit/prompts/analysis-user.md" with { type: "text" };
@@ -50,15 +50,21 @@ export async function generateConventionalAnalysis({
50
50
  diff,
51
51
  });
52
52
 
53
- const response = await completeSimple(
54
- model,
55
- {
56
- systemPrompt: [prompt.render(analysisSystemPrompt)],
57
- messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
58
- tools: [ConventionalAnalysisTool],
59
- },
60
- { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
53
+ const response = await retryTransientCompletion(() =>
54
+ completeSimple(
55
+ model,
56
+ {
57
+ systemPrompt: [prompt.render(analysisSystemPrompt)],
58
+ messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
59
+ tools: [ConventionalAnalysisTool],
60
+ },
61
+ { apiKey, maxTokens: 2400, reasoning: toReasoningEffort(thinkingLevel) },
62
+ ),
61
63
  );
62
64
 
65
+ if (response.stopReason === "error") {
66
+ throw new Error(response.errorMessage ?? "provider error");
67
+ }
68
+
63
69
  return parseConventionalAnalysisResponse(response, ConventionalAnalysisTool);
64
70
  }