@oh-my-pi/pi-coding-agent 17.0.5 → 17.0.6

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 (46) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/dist/cli.js +2838 -2917
  3. package/dist/types/exec/bash-executor.d.ts +1 -0
  4. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  5. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  6. package/dist/types/modes/components/status-line/component.d.ts +8 -0
  7. package/dist/types/modes/components/usage-row.d.ts +1 -1
  8. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  9. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  10. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  11. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  12. package/dist/types/session/agent-session.d.ts +23 -5
  13. package/dist/types/session/session-paths.d.ts +21 -4
  14. package/dist/types/tools/xdev.d.ts +5 -3
  15. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  16. package/dist/types/vibe/runtime.d.ts +23 -0
  17. package/package.json +12 -12
  18. package/src/exec/bash-executor.ts +68 -8
  19. package/src/extensibility/extensions/load-errors.ts +13 -0
  20. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  21. package/src/main.ts +8 -0
  22. package/src/modes/components/chat-transcript-builder.ts +10 -1
  23. package/src/modes/components/status-line/component.ts +55 -1
  24. package/src/modes/components/usage-row.ts +17 -1
  25. package/src/modes/controllers/command-controller.ts +41 -1
  26. package/src/modes/controllers/event-controller.ts +6 -1
  27. package/src/modes/interactive-mode.ts +62 -52
  28. package/src/modes/noninteractive-dispose.test.ts +2 -0
  29. package/src/modes/print-mode.ts +60 -0
  30. package/src/modes/rpc/rpc-client.ts +76 -35
  31. package/src/modes/rpc/rpc-frame.ts +156 -0
  32. package/src/modes/rpc/rpc-mode.ts +4 -2
  33. package/src/modes/setup-wizard/index.ts +2 -0
  34. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  35. package/src/modes/utils/ui-helpers.ts +6 -1
  36. package/src/prompts/system/workflow-notice.md +89 -74
  37. package/src/session/agent-session.ts +89 -26
  38. package/src/session/session-manager.ts +34 -3
  39. package/src/session/session-paths.ts +38 -9
  40. package/src/task/executor.ts +45 -0
  41. package/src/tools/xdev.ts +11 -4
  42. package/src/tools/yield.ts +4 -1
  43. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  44. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  45. package/src/vibe/runtime.ts +50 -0
  46. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -45,4 +45,5 @@ export interface BashResult {
45
45
  }
46
46
  /** Translate `ShellMinimizerSettings` into native `MinimizerOptions`, or `undefined` when disabled. */
47
47
  export declare function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined;
48
+ export declare function isPersistentShellCdCommand(command: string): boolean;
48
49
  export declare function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult>;
@@ -0,0 +1,3 @@
1
+ import type { LoadExtensionsResult } from "./types.js";
2
+ /** Formats extension load failures for user-visible startup diagnostics. */
3
+ export declare function formatExtensionLoadNotifications(errors: LoadExtensionsResult["errors"]): string[];
@@ -1,4 +1,11 @@
1
1
  import * as path from "node:path";
2
+ declare const BUNDLED_VIRTUAL_NAMESPACE = "omp-legacy-pi-bundled";
3
+ interface BundledVirtualResolveResult {
4
+ path: string;
5
+ namespace: typeof BUNDLED_VIRTUAL_NAMESPACE;
6
+ }
7
+ /** Maps a bundled virtual specifier or registry key to Bun's plugin namespace shape. */
8
+ export declare function resolveBundledVirtualSpecifier(specifier: string): BundledVirtualResolveResult;
2
9
  /** Test seam for the virtual module's named/default export forwarding. */
3
10
  export declare function __synthesizeLegacyPiBundledSourceWithModules(moduleKey: string, modules: Readonly<Record<string, Readonly<Record<string, unknown>>>>): string;
4
11
  /** Test seam for the global bridge key shared with synthetic module source. */
@@ -65,3 +72,4 @@ export declare function loadLegacyPiModule(resolvedPath: string): Promise<unknow
65
72
  export declare function installLegacyPiSpecifierShim(): void;
66
73
  /** Test seam: clears the memoized canonical specifier resolutions. */
67
74
  export declare function __resetLegacyPiResolutionCache(): void;
75
+ export {};
@@ -64,6 +64,14 @@ export declare class StatusLineComponent implements Component {
64
64
  setVibeModeStatus(status: {
65
65
  enabled: boolean;
66
66
  } | undefined): void;
67
+ /**
68
+ * Inject the aggregator that returns the aggregate tok/s of this session's
69
+ * live vibe worker sessions (null when no workers are streaming). Wired by
70
+ * interactive-mode, which owns the VibeSessionRegistry coupling, so the
71
+ * render layer stays off the heavy vibe/task dependency graph. Pass
72
+ * `undefined` to clear.
73
+ */
74
+ setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void;
67
75
  setCollabStatus(status: CollabStatus | null): void;
68
76
  setHookStatus(key: string, text: string | undefined): void;
69
77
  watchBranch(onBranchChange: () => void): void;
@@ -1,3 +1,3 @@
1
1
  import type { Usage } from "@oh-my-pi/pi-ai";
2
2
  import { Container } from "@oh-my-pi/pi-tui";
3
- export declare function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number): Container;
3
+ export declare function createUsageRowBlock(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number): Container;
@@ -61,7 +61,7 @@ export declare class RpcClient {
61
61
  /**
62
62
  * Stop the RPC agent process.
63
63
  */
64
- stop(): void;
64
+ stop(): Promise<void>;
65
65
  /**
66
66
  * Stop the RPC agent process and clean up resources.
67
67
  */
@@ -0,0 +1,9 @@
1
+ /** Maximum UTF-8 size of one newline-delimited RPC frame, including the newline. */
2
+ export declare const MAX_RPC_FRAME_BYTES: number;
3
+ /** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */
4
+ export declare function encodeRpcFrame(frame: object, streamedMessageCount?: number, streamedMessages?: readonly unknown[]): string;
5
+ /** Stateful encoder that tracks which messages a client has already received. */
6
+ export declare class RpcFrameEncoder {
7
+ #private;
8
+ encode(frame: object): string;
9
+ }
@@ -5,7 +5,7 @@ import type { SetupScene } from "./scenes/types.js";
5
5
  export type { SetupScene, SetupSceneController, SetupSceneHost, SetupSceneResult } from "./scenes/types.js";
6
6
  export { runStartupSplash } from "./startup-splash.js";
7
7
  export { CURRENT_SETUP_VERSION };
8
- export declare const ALL_SCENES: readonly [SetupScene, SetupScene, SetupScene];
8
+ export declare const ALL_SCENES: readonly [SetupScene, SetupScene, SetupScene, SetupScene];
9
9
  export interface SetupSceneSelectionOptions {
10
10
  resuming?: boolean;
11
11
  isTTY?: boolean;
@@ -0,0 +1,3 @@
1
+ import type { SetupScene } from "./types.js";
2
+ /** Setup step that assigns one available model to the persisted default role. */
3
+ export declare const modelSetupScene: SetupScene;
@@ -13,7 +13,7 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
16
- import { Agent, type AgentEvent, type AgentMessage, type AgentState, type AgentTool, type StreamFn, ThinkingLevel, type ToolChoiceDirective } from "@oh-my-pi/pi-agent-core";
16
+ import { Agent, type AgentEvent, type AgentMessage, type AgentState, type AgentTool, type AgentToolResult, type StreamFn, ThinkingLevel, type ToolChoiceDirective } from "@oh-my-pi/pi-agent-core";
17
17
  import { type CompactionResult, type ShakeConfig } from "@oh-my-pi/pi-agent-core/compaction";
18
18
  import type { AssistantMessage, Context, ImageContent, Message, MessageAttribution, Model, ProviderSessionState, ResetCreditAccountStatus, ResetCreditRedeemOutcome, ResetCreditTarget, ServiceTier, ServiceTierByFamily, ServiceTierFamily, SimpleStreamOptions, TextContent, ToolChoice, UsageReport } from "@oh-my-pi/pi-ai";
19
19
  import { Effort } from "@oh-my-pi/pi-ai";
@@ -41,6 +41,7 @@ import type { Goal, GoalModeState } from "../goals/state.js";
41
41
  import type { HindsightSessionState } from "../hindsight/state.js";
42
42
  import { type IrcMessage } from "../irc/bus.js";
43
43
  import { type MnemopiSessionState } from "../mnemopi/state.js";
44
+ import { type PlanApprovalDetails } from "../plan-mode/approved-plan.js";
44
45
  import type { PlanModeState } from "../plan-mode/state.js";
45
46
  import { type SecretObfuscator } from "../secrets/obfuscator.js";
46
47
  import { type ConfiguredThinkingLevel } from "../thinking.js";
@@ -542,6 +543,8 @@ export declare class AgentSession {
542
543
  * is already armed and waiting.
543
544
  */
544
545
  armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void;
546
+ /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
547
+ preparePlanForReview(title: string): Promise<AgentToolResult<PlanApprovalDetails>>;
545
548
  constructor(config: AgentSessionConfig);
546
549
  /** Model registry for API key resolution and model discovery */
547
550
  get modelRegistry(): ModelRegistry;
@@ -730,6 +733,21 @@ export declare class AgentSession {
730
733
  * Changes take effect before the next model call.
731
734
  */
732
735
  setActiveToolsByName(toolNames: string[]): Promise<void>;
736
+ /**
737
+ * Restore an enabled tool set with its exact top-level versus `xd://` partition.
738
+ *
739
+ * Both inputs are required because {@link setActiveToolsByName} only receives the
740
+ * enabled name list and classifies mounts from the *current* `#mountedXdevToolNames`.
741
+ * Rollback/restore callers must pass the snapshotted mounted subset so names that
742
+ * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
743
+ * `xd://` remain mount-eligible, even when the live mount set has drifted.
744
+ *
745
+ * Names outside `mountedToolNames` are pinned top-level for this application;
746
+ * names in the mounted subset remain eligible for xdev mounting. Delegates the
747
+ * actual apply through `#applyActiveToolsByName` and restores the prior runtime
748
+ * selection if that apply throws.
749
+ */
750
+ setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void>;
733
751
  /** Rebuild the base system prompt using the current active tool set. */
734
752
  refreshBaseSystemPrompt(): Promise<void>;
735
753
  /**
@@ -1054,10 +1072,10 @@ export declare class AgentSession {
1054
1072
  */
1055
1073
  getAvailableModels(): Model[];
1056
1074
  /**
1057
- * Set the thinking level. `auto` enables per-turn classification; the selector
1058
- * itself is never written to the session log, but resolved concrete levels are
1059
- * persisted when real user turns are classified so resumed sessions keep the
1060
- * last resolved effort instead of reverting to pending auto.
1075
+ * Set the thinking level. `auto` enables per-turn classification. Entering
1076
+ * auto writes its provisional level plus `configured: "auto"` immediately,
1077
+ * giving external readers an authoritative selection receipt before the next
1078
+ * user turn. Later classifications persist only changed concrete resolutions.
1061
1079
  */
1062
1080
  setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist?: boolean): void;
1063
1081
  /**
@@ -10,16 +10,33 @@ export declare function computeDefaultSessionDir(cwd: string, storage: SessionSt
10
10
  * Write a breadcrumb linking the current terminal to a session file.
11
11
  * The breadcrumb contains the cwd and session path so --continue can
12
12
  * find "this terminal's last session" even when running concurrent instances.
13
+ *
14
+ * `fresh` marks a `/new` (or freshly-minted) session boundary whose JSONL is
15
+ * not yet materialized (new-session persistence is lazy until assistant output
16
+ * exists). A fresh breadcrumb is honored by {@link readTerminalBreadcrumbEntry}
17
+ * even when its target file is still absent, so relaunch/auto-resume reopens the
18
+ * post-`/new` session instead of falling back to the pre-`/new` transcript. Once
19
+ * the session materializes the caller rewrites the breadcrumb with `fresh:false`
20
+ * so a later external delete is still treated as a genuinely stale crumb.
13
21
  */
14
- export declare function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void;
22
+ export declare function writeTerminalBreadcrumb(cwd: string, sessionFile: string, fresh?: boolean): void;
15
23
  export interface TerminalBreadcrumb {
16
24
  cwd: string;
17
25
  sessionFile: string;
26
+ /** The recorded session file exists on disk right now. */
27
+ exists: boolean;
28
+ /** Recorded as a `/new` fresh-session boundary whose JSONL may not exist yet. */
29
+ fresh: boolean;
18
30
  }
19
31
  /**
20
32
  * Read the raw terminal breadcrumb for the current terminal.
21
- * Returns the recorded cwd + session file (verified to exist) regardless of
22
- * whether the recorded cwd still matches the current one. Callers decide how
23
- * to interpret a cwd mismatch (e.g. a moved/renamed worktree).
33
+ * Returns the recorded cwd + session file regardless of whether the recorded
34
+ * cwd still matches the current one. Callers decide how to interpret a cwd
35
+ * mismatch (e.g. a moved/renamed worktree).
36
+ *
37
+ * A missing target file yields `null` UNLESS the breadcrumb is a `fresh`
38
+ * boundary — a lazy `/new` session whose JSONL was never written — in which case
39
+ * the entry is returned with `exists:false` so the caller can distinguish it
40
+ * from a genuinely stale/deleted breadcrumb.
24
41
  */
25
42
  export declare function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null>;
@@ -32,9 +32,11 @@ import type { ToolRenderer } from "./renderers.js";
32
32
  /**
33
33
  * Discoverable built-ins that must stay top-level even when xdev mounting is
34
34
  * active: `todo` feeds the todo prelude/prewalk machinery, `ask` is the
35
- * model's user-interaction affordance, and `grep` is the redirect target of
36
- * the bash interceptor rules each loses its harness integration if hidden
37
- * behind dispatch.
35
+ * model's user-interaction affordance, `grep` is the redirect target of the
36
+ * bash interceptor rules, and `web_search` is invoked directly by most models
37
+ * (which have no notion of the `xd://` protocol) so hiding it behind dispatch
38
+ * makes it unreachable in practice (issue #5973) — each loses its harness
39
+ * integration or usability if hidden behind dispatch.
38
40
  */
39
41
  export declare const XDEV_KEEP_TOP_LEVEL: Record<string, true>;
40
42
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -83,6 +83,17 @@ export declare class VibeSessionRegistry {
83
83
  static global(): VibeSessionRegistry;
84
84
  /** Reset the global registry. Test-only. */
85
85
  static resetGlobalForTests(): void;
86
+ /**
87
+ * Insert a bare worker record without the spawn/job machinery. Test-only —
88
+ * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
89
+ * fake roster + AgentRegistry session without driving a real turn.
90
+ */
91
+ registerRecordForTests(record: {
92
+ id: string;
93
+ cli?: VibeCli;
94
+ ownerId: string;
95
+ state?: VibeSessionState;
96
+ }): void;
86
97
  listIds(owner: string): string[];
87
98
  /**
88
99
  * Live screen snapshots for rich rendering (the "TV wall"): one entry per
@@ -122,3 +133,15 @@ export declare class VibeSessionRegistry {
122
133
  /** Kill every session belonging to `owner` (vibe-mode exit / teardown). Returns the number killed. */
123
134
  killAll(owner: string, manager?: AsyncJobManager): Promise<number>;
124
135
  }
136
+ /**
137
+ * Aggregate tok/s across every live vibe worker session owned by `ownerId`.
138
+ * Returns null when no workers are streaming (so callers can fall back to
139
+ * their own rate unchanged). The director is often idle while workers stream,
140
+ * so without this aggregation the status-line tok/s badge would show a stale
141
+ * value while parallel work is actively generating tokens.
142
+ *
143
+ * Reads each worker's last assistant message via {@link calculateTokensPerSecond}
144
+ * — the same leaf calculator the main status line uses — so worker rates are
145
+ * computed identically to the main session's rate.
146
+ */
147
+ export declare function aggregateVibeWorkerTokensPerSecond(ownerId: string): number | null;
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.0.5",
4
+ "version": "17.0.6",
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",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.0.5",
56
- "@oh-my-pi/omp-stats": "17.0.5",
57
- "@oh-my-pi/pi-agent-core": "17.0.5",
58
- "@oh-my-pi/pi-ai": "17.0.5",
59
- "@oh-my-pi/pi-catalog": "17.0.5",
60
- "@oh-my-pi/pi-mnemopi": "17.0.5",
61
- "@oh-my-pi/pi-natives": "17.0.5",
62
- "@oh-my-pi/pi-tui": "17.0.5",
63
- "@oh-my-pi/pi-utils": "17.0.5",
64
- "@oh-my-pi/pi-wire": "17.0.5",
65
- "@oh-my-pi/snapcompact": "17.0.5",
55
+ "@oh-my-pi/hashline": "17.0.6",
56
+ "@oh-my-pi/omp-stats": "17.0.6",
57
+ "@oh-my-pi/pi-agent-core": "17.0.6",
58
+ "@oh-my-pi/pi-ai": "17.0.6",
59
+ "@oh-my-pi/pi-catalog": "17.0.6",
60
+ "@oh-my-pi/pi-mnemopi": "17.0.6",
61
+ "@oh-my-pi/pi-natives": "17.0.6",
62
+ "@oh-my-pi/pi-tui": "17.0.6",
63
+ "@oh-my-pi/pi-utils": "17.0.6",
64
+ "@oh-my-pi/pi-wire": "17.0.6",
65
+ "@oh-my-pi/snapcompact": "17.0.6",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -150,9 +150,56 @@ function isBashShell(shell: string): boolean {
150
150
  return basename.includes("bash");
151
151
  }
152
152
 
153
+ const UNSUPPORTED_UNQUOTED_CD_CHARS = "\\$`;&|<>(){}*?[]!#\"'";
154
+
155
+ function hasUnsupportedUnquotedCdSyntax(value: string): boolean {
156
+ for (const char of value) {
157
+ if (/\s/.test(char) || UNSUPPORTED_UNQUOTED_CD_CHARS.includes(char)) return true;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ export function isPersistentShellCdCommand(command: string): boolean {
163
+ if (/[\r\n]/.test(command)) return false;
164
+
165
+ const trimmed = command.trim();
166
+ if (trimmed === "cd") return true;
167
+ if (!trimmed.startsWith("cd") || !/[ \t]/.test(trimmed[2] ?? "")) return false;
168
+
169
+ let rest = trimmed.slice(2).trim();
170
+ if (rest === "" || rest === "--") return true;
171
+
172
+ let hasOptionTerminator = false;
173
+ if (/^--[ \t]/.test(rest)) {
174
+ hasOptionTerminator = true;
175
+ rest = rest.slice(2).trimStart();
176
+ }
177
+ if (rest === "") return true;
178
+
179
+ const quote = rest[0];
180
+ let target: string;
181
+ let quoted = false;
182
+ if (quote === `"` || quote === "'") {
183
+ if (rest.length < 2 || rest[rest.length - 1] !== quote) return false;
184
+ target = rest.slice(1, -1);
185
+ if (target.includes(quote)) return false;
186
+ if (quote === `"` && /[\\$`\r\n]/.test(target)) return false;
187
+ quoted = true;
188
+ } else {
189
+ if (hasUnsupportedUnquotedCdSyntax(rest)) return false;
190
+ target = rest;
191
+ }
192
+
193
+ if (target === "") return false;
194
+ if (/^[+-]\d+$/.test(target)) return false;
195
+ if (!hasOptionTerminator && target.startsWith("-") && target !== "-") return false;
196
+ if (!quoted && target.startsWith("~") && target !== "~" && !target.startsWith("~/")) return false;
197
+ return true;
198
+ }
199
+
153
200
  function needsInteractiveShellArg(shell: string): boolean {
154
201
  const basename = shellBasename(shell);
155
- return basename.includes("zsh");
202
+ return basename.includes("zsh") || basename.includes("fish");
156
203
  }
157
204
 
158
205
  function supportsAutoUserShell(shell: string): boolean {
@@ -165,19 +212,31 @@ function hasInteractiveShellArg(args: string[]): boolean {
165
212
  }
166
213
 
167
214
  function ensureInteractiveShellArgs(shell: string, args: string[]): string[] {
168
- if (!needsInteractiveShellArg(shell) || hasInteractiveShellArg(args)) return args;
215
+ if (!needsInteractiveShellArg(shell)) return args;
216
+
217
+ // fish sources the same config files (config.fish + conf.d) for interactive
218
+ // shells as for login shells, so the inherited `-l` adds nothing — it only
219
+ // marks the shell as login, firing `status is-login` blocks in user config
220
+ // (agent/keychain setup, path mutation) on every `!` command. zsh keeps `-l`
221
+ // because .zprofile is login-only. Args originate from procmgr's
222
+ // getShellArgs(), so login only ever appears as a standalone `-l`/`--login`.
223
+ const effectiveArgs = shellBasename(shell).includes("fish")
224
+ ? args.filter(arg => arg !== "-l" && arg !== "--login")
225
+ : args;
226
+
227
+ if (hasInteractiveShellArg(effectiveArgs)) return effectiveArgs;
169
228
 
170
- const commandIndex = args.findIndex(arg => arg === "-c" || arg === "--command");
229
+ const commandIndex = effectiveArgs.findIndex(arg => arg === "-c" || arg === "--command");
171
230
  if (commandIndex !== -1) {
172
- return [...args.slice(0, commandIndex), "-i", ...args.slice(commandIndex)];
231
+ return [...effectiveArgs.slice(0, commandIndex), "-i", ...effectiveArgs.slice(commandIndex)];
173
232
  }
174
233
 
175
- const compactCommandIndex = args.findIndex(arg => /^-[^-]*c[^-]*$/.test(arg));
234
+ const compactCommandIndex = effectiveArgs.findIndex(arg => /^-[^-]*c[^-]*$/.test(arg));
176
235
  if (compactCommandIndex !== -1) {
177
- return args.map((arg, index) => (index === compactCommandIndex ? arg.replace("c", "ic") : arg));
236
+ return effectiveArgs.map((arg, index) => (index === compactCommandIndex ? arg.replace("c", "ic") : arg));
178
237
  }
179
238
 
180
- return [...args, "-i"];
239
+ return [...effectiveArgs, "-i"];
181
240
  }
182
241
 
183
242
  function quoteShellArg(value: string): string {
@@ -224,8 +283,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
224
283
 
225
284
  // Apply command prefix if configured
226
285
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
286
+ const runCdInPersistentShell = options?.useUserShell === true && !prefix && isPersistentShellCdCommand(command);
227
287
  const finalCommand =
228
- options?.useUserShell === true && !bashShell
288
+ options?.useUserShell === true && !bashShell && !runCdInPersistentShell
229
289
  ? buildUserShellCommand(shell, args, prefixedCommand)
230
290
  : prefixedCommand;
231
291
 
@@ -0,0 +1,13 @@
1
+ import { replaceTabs, shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
2
+ import type { LoadExtensionsResult } from "./types";
3
+
4
+ /** Formats extension load failures for user-visible startup diagnostics. */
5
+ export function formatExtensionLoadNotifications(errors: LoadExtensionsResult["errors"]): string[] {
6
+ const messages: string[] = [];
7
+ for (const { path, error } of errors) {
8
+ const displayPath = truncateToWidth(replaceTabs(shortenPath(path)), TRUNCATE_LENGTHS.CONTENT);
9
+ const displayError = truncateToWidth(replaceTabs(error.replace(/\s+/g, " ").trim()), TRUNCATE_LENGTHS.LONG);
10
+ messages.push(`Failed to load extension ${displayPath}: ${displayError}`);
11
+ }
12
+ return messages;
13
+ }
@@ -32,6 +32,16 @@ type BundledModule = Readonly<Record<string, unknown>>;
32
32
  type BundledModules = Readonly<Record<string, BundledModule>>;
33
33
  type BundledModuleLoaders = Readonly<Record<string, () => Promise<BundledModule>>>;
34
34
 
35
+ interface LegacyPiResolveResult {
36
+ path: string;
37
+ namespace?: string;
38
+ }
39
+
40
+ interface BundledVirtualResolveResult {
41
+ path: string;
42
+ namespace: typeof BUNDLED_VIRTUAL_NAMESPACE;
43
+ }
44
+
35
45
  const loadedBundledModules: Record<string, BundledModule> = {};
36
46
  let bundledModuleLoadersPromise: Promise<BundledModuleLoaders> | null = null;
37
47
 
@@ -72,6 +82,25 @@ function isBundledVirtualSpecifier(value: string): boolean {
72
82
  return value.startsWith(BUNDLED_VIRTUAL_SCHEME);
73
83
  }
74
84
 
85
+ function toLegacyPiResolveResult(resolvedPath: string): LegacyPiResolveResult {
86
+ if (isBundledVirtualSpecifier(resolvedPath)) {
87
+ const registryKey = resolvedPath.slice(BUNDLED_VIRTUAL_SCHEME.length);
88
+ return { path: registryKey, namespace: BUNDLED_VIRTUAL_NAMESPACE };
89
+ }
90
+ return { path: resolvedPath };
91
+ }
92
+
93
+ /** Maps a bundled virtual specifier or registry key to Bun's plugin namespace shape. */
94
+ export function resolveBundledVirtualSpecifier(specifier: string): BundledVirtualResolveResult {
95
+ const registryKey = isBundledVirtualSpecifier(specifier)
96
+ ? specifier.slice(BUNDLED_VIRTUAL_SCHEME.length)
97
+ : specifier;
98
+ if (!registryKey) {
99
+ throw new Error("omp:legacy-pi-shim: bundled virtual specifier has no registry key");
100
+ }
101
+ return { path: registryKey, namespace: BUNDLED_VIRTUAL_NAMESPACE };
102
+ }
103
+
75
104
  /**
76
105
  * Build a synthetic ES module for one live bundled namespace. Every export
77
106
  * reads through the global bridge; no bunfs path or copied package is involved.
@@ -1611,7 +1640,7 @@ function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" {
1611
1640
  return "js";
1612
1641
  }
1613
1642
 
1614
- function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { path: string } | undefined {
1643
+ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): LegacyPiResolveResult | undefined {
1615
1644
  const remappedSpecifier = remapLegacyPiSpecifier(args.path);
1616
1645
  if (!remappedSpecifier) {
1617
1646
  return undefined;
@@ -1620,7 +1649,7 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
1620
1649
  // Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
1621
1650
  // location. Works in dev mode and in source-link installs.
1622
1651
  try {
1623
- return { path: resolveCanonicalPiSpecifier(remappedSpecifier) };
1652
+ return toLegacyPiResolveResult(resolveCanonicalPiSpecifier(remappedSpecifier));
1624
1653
  } catch {
1625
1654
  // Fallback for compiled binary mode: the bundled packages live inside
1626
1655
  // /$bunfs/root and aren't reachable by filesystem resolution. Prefer the
@@ -1630,10 +1659,10 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
1630
1659
  // @earendil-works peer deps.
1631
1660
  const importerDir = path.dirname(args.importer);
1632
1661
  try {
1633
- return { path: Bun.resolveSync(remappedSpecifier, importerDir) };
1662
+ return toLegacyPiResolveResult(Bun.resolveSync(remappedSpecifier, importerDir));
1634
1663
  } catch {
1635
1664
  try {
1636
- return { path: Bun.resolveSync(args.path, importerDir) };
1665
+ return toLegacyPiResolveResult(Bun.resolveSync(args.path, importerDir));
1637
1666
  } catch {
1638
1667
  return undefined;
1639
1668
  }
@@ -1641,8 +1670,8 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
1641
1670
  }
1642
1671
  }
1643
1672
 
1644
- function resolveTypeBoxSpecifier(): { path: string } | undefined {
1645
- return TYPEBOX_SHIM_PATH ? { path: TYPEBOX_SHIM_PATH } : undefined;
1673
+ function resolveTypeBoxSpecifier(): LegacyPiResolveResult | undefined {
1674
+ return TYPEBOX_SHIM_PATH ? toLegacyPiResolveResult(TYPEBOX_SHIM_PATH) : undefined;
1646
1675
  }
1647
1676
 
1648
1677
  export function installLegacyPiSpecifierShim(): void {
@@ -1656,6 +1685,12 @@ export function installLegacyPiSpecifierShim(): void {
1656
1685
  setup(build) {
1657
1686
  build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier);
1658
1687
  build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier);
1688
+ build.onResolve({ filter: /^omp-legacy-pi-bundled:.+$/, namespace: "file" }, args =>
1689
+ resolveBundledVirtualSpecifier(args.path),
1690
+ );
1691
+ build.onResolve({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, args =>
1692
+ resolveBundledVirtualSpecifier(args.path),
1693
+ );
1659
1694
  // Compiled mode serves `omp-legacy-pi-bundled:<key>` imports from
1660
1695
  // live host module references. No bunfs path leaves this loader.
1661
1696
  build.onLoad({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, async args => {
package/src/main.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  resolveActiveProjectRegistryPath,
51
51
  } from "./discovery/helpers";
52
52
  import { injectOmpExtensionCliRoots } from "./discovery/omp-extension-roots";
53
+ import { formatExtensionLoadNotifications } from "./extensibility/extensions/load-errors";
53
54
  import { ExtensionRunner } from "./extensibility/extensions/runner";
54
55
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
55
56
  import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update";
@@ -1433,6 +1434,13 @@ export async function runRootCommand(
1433
1434
  };
1434
1435
  const initialArgs = applyExtensionFlags(extensionFlagSink, rawArgs) ?? parsedArgs;
1435
1436
  normalizeContinueSessionArgs(initialArgs, rawArgs);
1437
+ for (const message of formatExtensionLoadNotifications(extensionsResult.errors)) {
1438
+ if (isInteractive) {
1439
+ notifs.push({ kind: "warn", message });
1440
+ } else {
1441
+ process.stderr.write(`${chalk.yellow(`${message}\n`)}`);
1442
+ }
1443
+ }
1436
1444
  // Fail fast on stale/typo flags (e.g. `omp --list-models`) now that we
1437
1445
  // know the real extension flag set. Without this check the unrecognized
1438
1446
  // token gets silently consumed and any following positional leaks as the
@@ -85,6 +85,7 @@ export class ChatTranscriptBuilder {
85
85
  #pendingUsage: Usage | undefined;
86
86
  #pendingUsageDuration: number | undefined;
87
87
  #pendingUsageTtft: number | undefined;
88
+ #pendingUsageTimestamp: number | undefined;
88
89
  #lastAssistantUsage: Usage | undefined;
89
90
  #waitingPoll: ToolExecutionComponent | null = null;
90
91
  #todoSnapshot: ToolExecutionComponent | null = null;
@@ -133,6 +134,7 @@ export class ChatTranscriptBuilder {
133
134
  this.#pendingUsage = undefined;
134
135
  this.#pendingUsageDuration = undefined;
135
136
  this.#pendingUsageTtft = undefined;
137
+ this.#pendingUsageTimestamp = undefined;
136
138
  this.#lastAssistantUsage = undefined;
137
139
  this.#waitingPoll = null;
138
140
  this.#todoSnapshot = null;
@@ -201,11 +203,17 @@ export class ChatTranscriptBuilder {
201
203
  this.#readGroup?.seal();
202
204
  this.#readGroup = null;
203
205
  this.container.addChild(
204
- createUsageRowBlock(this.#pendingUsage, this.#pendingUsageDuration, this.#pendingUsageTtft),
206
+ createUsageRowBlock(
207
+ this.#pendingUsage,
208
+ this.#pendingUsageDuration,
209
+ this.#pendingUsageTtft,
210
+ this.#pendingUsageTimestamp,
211
+ ),
205
212
  );
206
213
  this.#pendingUsage = undefined;
207
214
  this.#pendingUsageDuration = undefined;
208
215
  this.#pendingUsageTtft = undefined;
216
+ this.#pendingUsageTimestamp = undefined;
209
217
  }
210
218
 
211
219
  #appendChatMessage(message: AgentMessage): void {
@@ -381,6 +389,7 @@ export class ChatTranscriptBuilder {
381
389
  settings.get("display.showTokenUsage") && assistantUsageIsBilled(message.usage) ? message.usage : undefined;
382
390
  this.#pendingUsageDuration = message.duration;
383
391
  this.#pendingUsageTtft = message.ttft;
392
+ this.#pendingUsageTimestamp = message.timestamp;
384
393
  }
385
394
 
386
395
  #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {