@oh-my-pi/pi-coding-agent 3.37.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +193 -47
  9. package/src/core/auth-storage.ts +16 -3
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +1 -1
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
@@ -19,7 +19,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-a
19
19
  import type { Rule } from "../capability/rule";
20
20
  import { getAuthPath } from "../config";
21
21
  import { theme } from "../modes/interactive/theme/theme";
22
- import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
22
+ import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor";
23
23
  import {
24
24
  type CompactionResult,
25
25
  calculateContextTokens,
@@ -52,9 +52,11 @@ import { parseModelString } from "./model-resolver";
52
52
  import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates";
53
53
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
54
54
  import type { SettingsManager, SkillsSettings } from "./settings-manager";
55
+ import type { Skill, SkillWarning } from "./skills";
55
56
  import { expandSlashCommand, type FileSlashCommand } from "./slash-commands";
56
57
  import { closeAllConnections } from "./ssh/connection-manager";
57
58
  import { unmountAll } from "./ssh/sshfs-mount";
59
+ import type { BashOperations } from "./tools/bash";
58
60
  import type { TtsrManager } from "./ttsr";
59
61
 
60
62
  /** Session-specific events that extend the core AgentEvent */
@@ -85,6 +87,10 @@ export interface AgentSessionConfig {
85
87
  slashCommands?: FileSlashCommand[];
86
88
  /** Extension runner (created in main.ts with wrapped tools) */
87
89
  extensionRunner?: ExtensionRunner;
90
+ /** Loaded skills (already discovered by SDK) */
91
+ skills?: Skill[];
92
+ /** Skill loading warnings (already captured by SDK) */
93
+ skillWarnings?: SkillWarning[];
88
94
  /** Custom commands (TypeScript slash commands) */
89
95
  customCommands?: LoadedCustomCommand[];
90
96
  skillsSettings?: Required<SkillsSettings>;
@@ -154,9 +160,9 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "hi
154
160
  const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
155
161
 
156
162
  const noOpUIContext: ExtensionUIContext = {
157
- select: async () => undefined,
158
- confirm: async () => false,
159
- input: async () => undefined,
163
+ select: async (_title, _options, _dialogOptions) => undefined,
164
+ confirm: async (_title, _message, _dialogOptions) => false,
165
+ input: async (_title, _placeholder, _dialogOptions) => undefined,
160
166
  notify: () => {},
161
167
  setStatus: () => {},
162
168
  setWidget: () => {},
@@ -168,6 +174,12 @@ const noOpUIContext: ExtensionUIContext = {
168
174
  get theme() {
169
175
  return theme;
170
176
  },
177
+ getAllThemes: () => [],
178
+ getTheme: () => undefined,
179
+ setTheme: (_theme) => ({ success: false, error: "UI not available" }),
180
+ setFooter: () => {},
181
+ setHeader: () => {},
182
+ setEditorComponent: () => {},
171
183
  };
172
184
 
173
185
  async function cleanupSshResources(): Promise<void> {
@@ -224,6 +236,9 @@ export class AgentSession {
224
236
  private _extensionRunner: ExtensionRunner | undefined = undefined;
225
237
  private _turnIndex = 0;
226
238
 
239
+ private _skills: Skill[];
240
+ private _skillWarnings: SkillWarning[];
241
+
227
242
  // Custom commands (TypeScript slash commands)
228
243
  private _customCommands: LoadedCustomCommand[] = [];
229
244
 
@@ -250,6 +265,8 @@ export class AgentSession {
250
265
  this._promptTemplates = config.promptTemplates ?? [];
251
266
  this._slashCommands = config.slashCommands ?? [];
252
267
  this._extensionRunner = config.extensionRunner;
268
+ this._skills = config.skills ?? [];
269
+ this._skillWarnings = config.skillWarnings ?? [];
253
270
  this._customCommands = config.customCommands ?? [];
254
271
  this._skillsSettings = config.skillsSettings;
255
272
  this._modelRegistry = config.modelRegistry;
@@ -578,6 +595,11 @@ export class AgentSession {
578
595
  return this.agent.state.isStreaming;
579
596
  }
580
597
 
598
+ /** Current retry attempt (0 if not retrying) */
599
+ get retryAttempt(): number {
600
+ return this._retryAttempt;
601
+ }
602
+
581
603
  /**
582
604
  * Get the names of currently active tools.
583
605
  * Returns the names of tools currently set on the agent.
@@ -788,7 +810,11 @@ export class AgentSession {
788
810
 
789
811
  // Emit before_agent_start extension event
790
812
  if (this._extensionRunner) {
791
- const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images);
813
+ const result = await this._extensionRunner.emitBeforeAgentStart(
814
+ expandedText,
815
+ options?.images,
816
+ this._baseSystemPrompt,
817
+ );
792
818
  if (result?.messages) {
793
819
  for (const msg of result.messages) {
794
820
  messages.push({
@@ -802,8 +828,8 @@ export class AgentSession {
802
828
  }
803
829
  }
804
830
 
805
- if (result?.systemPromptAppend) {
806
- this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`);
831
+ if (result?.systemPrompt !== undefined) {
832
+ this.agent.setSystemPrompt(result.systemPrompt);
807
833
  } else {
808
834
  this.agent.setSystemPrompt(this._baseSystemPrompt);
809
835
  }
@@ -861,6 +887,10 @@ export class AgentSession {
861
887
  void this.abort();
862
888
  },
863
889
  hasPendingMessages: () => this.queuedMessageCount > 0,
890
+ shutdown: () => {
891
+ void this.dispose();
892
+ process.exit(0);
893
+ },
864
894
  hasQueuedMessages: () => this.queuedMessageCount > 0,
865
895
  waitForIdle: () => this.agent.waitForIdle(),
866
896
  newSession: async (options) => {
@@ -905,7 +935,7 @@ export class AgentSession {
905
935
  const ctx = {
906
936
  ...baseCtx,
907
937
  hasQueuedMessages: baseCtx.hasPendingMessages,
908
- } as HookCommandContext;
938
+ } as unknown as HookCommandContext;
909
939
 
910
940
  try {
911
941
  const args = parseCommandArgs(argsString);
@@ -1052,6 +1082,45 @@ export class AgentSession {
1052
1082
  );
1053
1083
  }
1054
1084
 
1085
+ /**
1086
+ * Send a user message to the agent. Always triggers a turn.
1087
+ * When the agent is streaming, use deliverAs to specify how to queue the message.
1088
+ *
1089
+ * @param content User message content (string or content array)
1090
+ * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp"
1091
+ */
1092
+ async sendUserMessage(
1093
+ content: string | (TextContent | ImageContent)[],
1094
+ options?: { deliverAs?: "steer" | "followUp" },
1095
+ ): Promise<void> {
1096
+ // Normalize content to text string + optional images
1097
+ let text: string;
1098
+ let images: ImageContent[] | undefined;
1099
+
1100
+ if (typeof content === "string") {
1101
+ text = content;
1102
+ } else {
1103
+ const textParts: string[] = [];
1104
+ images = [];
1105
+ for (const part of content) {
1106
+ if (part.type === "text") {
1107
+ textParts.push(part.text);
1108
+ } else {
1109
+ images.push(part);
1110
+ }
1111
+ }
1112
+ text = textParts.join("\n");
1113
+ if (images.length === 0) images = undefined;
1114
+ }
1115
+
1116
+ // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
1117
+ await this.prompt(text, {
1118
+ expandPromptTemplates: false,
1119
+ streamingBehavior: options?.deliverAs,
1120
+ images,
1121
+ });
1122
+ }
1123
+
1055
1124
  /**
1056
1125
  * Clear queued messages and return them.
1057
1126
  * Useful for restoring to editor when user aborts.
@@ -1075,10 +1144,40 @@ export class AgentSession {
1075
1144
  return { steering: this._steeringMessages, followUp: this._followUpMessages };
1076
1145
  }
1077
1146
 
1147
+ /**
1148
+ * Pop the last queued message (steering first, then follow-up).
1149
+ * Used by dequeue keybinding to restore messages to editor one at a time.
1150
+ */
1151
+ popLastQueuedMessage(): string | undefined {
1152
+ // Pop from steering first (LIFO)
1153
+ if (this._steeringMessages.length > 0) {
1154
+ const message = this._steeringMessages.pop();
1155
+ this.agent.popLastSteer();
1156
+ return message;
1157
+ }
1158
+ // Then from follow-up
1159
+ if (this._followUpMessages.length > 0) {
1160
+ const message = this._followUpMessages.pop();
1161
+ this.agent.popLastFollowUp();
1162
+ return message;
1163
+ }
1164
+ return undefined;
1165
+ }
1166
+
1078
1167
  get skillsSettings(): Required<SkillsSettings> | undefined {
1079
1168
  return this._skillsSettings;
1080
1169
  }
1081
1170
 
1171
+ /** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */
1172
+ get skills(): readonly Skill[] {
1173
+ return this._skills;
1174
+ }
1175
+
1176
+ /** Skill loading warnings captured by SDK */
1177
+ get skillWarnings(): readonly SkillWarning[] {
1178
+ return this._skillWarnings;
1179
+ }
1180
+
1082
1181
  /**
1083
1182
  * Abort current operation and wait for agent to become idle.
1084
1183
  */
@@ -1115,6 +1214,7 @@ export class AgentSession {
1115
1214
  this.agent.reset();
1116
1215
  await this.sessionManager.flush();
1117
1216
  this.sessionManager.newSession(options);
1217
+ this.agent.sessionId = this.sessionManager.getSessionId();
1118
1218
  this._steeringMessages = [];
1119
1219
  this._followUpMessages = [];
1120
1220
  this._pendingNextTurnMessages = [];
@@ -1311,16 +1411,12 @@ export class AgentSession {
1311
1411
 
1312
1412
  /**
1313
1413
  * Set thinking level.
1314
- * Clamps to model capabilities: "off" if no reasoning, "high" if xhigh unsupported.
1414
+ * Clamps to model capabilities based on available thinking levels.
1315
1415
  * Saves to session and settings.
1316
1416
  */
1317
1417
  setThinkingLevel(level: ThinkingLevel): void {
1318
- let effectiveLevel = level;
1319
- if (!this.supportsThinking()) {
1320
- effectiveLevel = "off";
1321
- } else if (level === "xhigh" && !this.supportsXhighThinking()) {
1322
- effectiveLevel = "high";
1323
- }
1418
+ const availableLevels = this.getAvailableThinkingLevels();
1419
+ const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
1324
1420
  this.agent.setThinkingLevel(effectiveLevel);
1325
1421
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
1326
1422
  this.settingsManager.setDefaultThinkingLevel(effectiveLevel);
@@ -1344,8 +1440,10 @@ export class AgentSession {
1344
1440
 
1345
1441
  /**
1346
1442
  * Get available thinking levels for current model.
1443
+ * The provider will clamp to what the specific model supports internally.
1347
1444
  */
1348
1445
  getAvailableThinkingLevels(): ThinkingLevel[] {
1446
+ if (!this.supportsThinking()) return ["off"];
1349
1447
  return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
1350
1448
  }
1351
1449
 
@@ -1363,6 +1461,24 @@ export class AgentSession {
1363
1461
  return !!this.model?.reasoning;
1364
1462
  }
1365
1463
 
1464
+ private _clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel {
1465
+ const ordered = THINKING_LEVELS_WITH_XHIGH;
1466
+ const available = new Set(availableLevels);
1467
+ const requestedIndex = ordered.indexOf(level);
1468
+ if (requestedIndex === -1) {
1469
+ return availableLevels[0] ?? "off";
1470
+ }
1471
+ for (let i = requestedIndex; i < ordered.length; i++) {
1472
+ const candidate = ordered[i];
1473
+ if (available.has(candidate)) return candidate;
1474
+ }
1475
+ for (let i = requestedIndex - 1; i >= 0; i--) {
1476
+ const candidate = ordered[i];
1477
+ if (available.has(candidate)) return candidate;
1478
+ }
1479
+ return availableLevels[0] ?? "off";
1480
+ }
1481
+
1366
1482
  // =========================================================================
1367
1483
  // Message Queue Mode Management
1368
1484
  // =========================================================================
@@ -1548,8 +1664,24 @@ export class AgentSession {
1548
1664
 
1549
1665
  const contextWindow = this.model?.contextWindow ?? 0;
1550
1666
 
1667
+ // Skip overflow check if the message came from a different model.
1668
+ // This handles the case where user switched from a smaller-context model (e.g. opus)
1669
+ // to a larger-context model (e.g. codex) - the overflow error from the old model
1670
+ // shouldn't trigger compaction for the new model.
1671
+ const sameModel =
1672
+ this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
1673
+
1674
+ // Skip overflow check if the error is from before a compaction in the current path.
1675
+ // This handles the case where an error was kept after compaction (in the "kept" region).
1676
+ // The error shouldn't trigger another compaction since we already compacted.
1677
+ // Example: opus fails → switch to codex → compact → switch back to opus → opus error
1678
+ // is still in context but shouldn't trigger compaction again.
1679
+ const compactionEntry = this.sessionManager.getBranch().find((e) => e.type === "compaction");
1680
+ const errorIsFromBeforeCompaction =
1681
+ compactionEntry && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
1682
+
1551
1683
  // Case 1: Overflow - LLM returned context overflow error
1552
- if (isContextOverflow(assistantMessage, contextWindow)) {
1684
+ if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
1553
1685
  // Remove the error message from agent state (it IS saved to session for history,
1554
1686
  // but we don't want it in context for the retry)
1555
1687
  const messages = this.agent.state.messages;
@@ -2005,7 +2137,7 @@ export class AgentSession {
2005
2137
  */
2006
2138
  abortRetry(): void {
2007
2139
  this._retryAbortController?.abort();
2008
- this._retryAttempt = 0;
2140
+ // Note: _retryAttempt is reset in the catch block of _autoRetry
2009
2141
  this._resolveRetry();
2010
2142
  }
2011
2143
 
@@ -2046,51 +2178,63 @@ export class AgentSession {
2046
2178
  * @param command The bash command to execute
2047
2179
  * @param onChunk Optional streaming callback for output
2048
2180
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
2181
+ * @param options.operations Custom BashOperations for remote execution
2049
2182
  */
2050
2183
  async executeBash(
2051
2184
  command: string,
2052
2185
  onChunk?: (chunk: string) => void,
2053
- options?: { excludeFromContext?: boolean },
2186
+ options?: { excludeFromContext?: boolean; operations?: BashOperations },
2054
2187
  ): Promise<BashResult> {
2055
2188
  this._bashAbortController = new AbortController();
2056
2189
 
2057
2190
  try {
2058
- const result = await executeBashCommand(command, {
2059
- onChunk,
2060
- signal: this._bashAbortController.signal,
2061
- });
2062
-
2063
- // Create and save message
2064
- const bashMessage: BashExecutionMessage = {
2065
- role: "bashExecution",
2066
- command,
2067
- output: result.output,
2068
- exitCode: result.exitCode,
2069
- cancelled: result.cancelled,
2070
- truncated: result.truncated,
2071
- fullOutputPath: result.fullOutputPath,
2072
- timestamp: Date.now(),
2073
- excludeFromContext: options?.excludeFromContext,
2074
- };
2075
-
2076
- // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
2077
- if (this.isStreaming) {
2078
- // Queue for later - will be flushed on agent_end
2079
- this._pendingBashMessages.push(bashMessage);
2080
- } else {
2081
- // Add to agent state immediately
2082
- this.agent.appendMessage(bashMessage);
2083
-
2084
- // Save to session
2085
- this.sessionManager.appendMessage(bashMessage);
2086
- }
2191
+ const result = options?.operations
2192
+ ? await executeBashWithOperations(command, process.cwd(), options.operations, {
2193
+ onChunk,
2194
+ signal: this._bashAbortController.signal,
2195
+ })
2196
+ : await executeBashCommand(command, {
2197
+ onChunk,
2198
+ signal: this._bashAbortController.signal,
2199
+ });
2087
2200
 
2201
+ this.recordBashResult(command, result, options);
2088
2202
  return result;
2089
2203
  } finally {
2090
2204
  this._bashAbortController = undefined;
2091
2205
  }
2092
2206
  }
2093
2207
 
2208
+ /**
2209
+ * Record a bash execution result in session history.
2210
+ * Used by executeBash and by extensions that handle bash execution themselves.
2211
+ */
2212
+ recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
2213
+ const bashMessage: BashExecutionMessage = {
2214
+ role: "bashExecution",
2215
+ command,
2216
+ output: result.output,
2217
+ exitCode: result.exitCode,
2218
+ cancelled: result.cancelled,
2219
+ truncated: result.truncated,
2220
+ fullOutputPath: result.fullOutputPath,
2221
+ timestamp: Date.now(),
2222
+ excludeFromContext: options?.excludeFromContext,
2223
+ };
2224
+
2225
+ // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
2226
+ if (this.isStreaming) {
2227
+ // Queue for later - will be flushed on agent_end
2228
+ this._pendingBashMessages.push(bashMessage);
2229
+ } else {
2230
+ // Add to agent state immediately
2231
+ this.agent.appendMessage(bashMessage);
2232
+
2233
+ // Save to session
2234
+ this.sessionManager.appendMessage(bashMessage);
2235
+ }
2236
+ }
2237
+
2094
2238
  /**
2095
2239
  * Cancel running bash command.
2096
2240
  */
@@ -2163,6 +2307,7 @@ export class AgentSession {
2163
2307
 
2164
2308
  // Set new session
2165
2309
  await this.sessionManager.setSessionFile(sessionPath);
2310
+ this.agent.sessionId = this.sessionManager.getSessionId();
2166
2311
 
2167
2312
  // Reload messages
2168
2313
  const sessionContext = this.sessionManager.buildSessionContext();
@@ -2247,6 +2392,7 @@ export class AgentSession {
2247
2392
  } else {
2248
2393
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
2249
2394
  }
2395
+ this.agent.sessionId = this.sessionManager.getSessionId();
2250
2396
 
2251
2397
  // Reload messages from entries (works for both file and in-memory mode)
2252
2398
  const sessionContext = this.sessionManager.buildSessionContext();
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Credential storage for API keys and OAuth tokens.
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
+ *
5
+ * Uses file locking to prevent race conditions when multiple pi instances
6
+ * try to refresh tokens simultaneously.
4
7
  */
5
8
 
6
9
  import {
@@ -434,6 +437,10 @@ export class AuthStorage {
434
437
  onAuth: (info: { url: string; instructions?: string }) => void;
435
438
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
436
439
  onProgress?: (message: string) => void;
440
+ /** For providers with local callback servers (e.g., openai-codex), races with browser callback */
441
+ onManualCodeInput?: () => Promise<string>;
442
+ /** For cancellation support (e.g., github-copilot polling) */
443
+ signal?: AbortSignal;
437
444
  },
438
445
  ): Promise<void> {
439
446
  let credentials: OAuthCredentials;
@@ -450,16 +457,22 @@ export class AuthStorage {
450
457
  onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
451
458
  onPrompt: callbacks.onPrompt,
452
459
  onProgress: callbacks.onProgress,
460
+ signal: callbacks.signal,
453
461
  });
454
462
  break;
455
463
  case "google-gemini-cli":
456
- credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
464
+ credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
457
465
  break;
458
466
  case "google-antigravity":
459
- credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
467
+ credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
460
468
  break;
461
469
  case "openai-codex":
462
- credentials = await loginOpenAICodex(callbacks);
470
+ credentials = await loginOpenAICodex({
471
+ onAuth: callbacks.onAuth,
472
+ onPrompt: callbacks.onPrompt,
473
+ onProgress: callbacks.onProgress,
474
+ onManualCodeInput: callbacks.onManualCodeInput,
475
+ });
463
476
  break;
464
477
  default:
465
478
  throw new Error(`Unknown OAuth provider: ${provider}`);
@@ -14,6 +14,7 @@ import { nanoid } from "nanoid";
14
14
  import stripAnsi from "strip-ansi";
15
15
  import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
16
16
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
17
+ import type { BashOperations } from "./tools/bash";
17
18
  import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate";
18
19
  import { ScopeSignal } from "./utils";
19
20
 
@@ -59,6 +60,19 @@ function createSanitizer(): TransformStream<Uint8Array, string> {
59
60
  });
60
61
  }
61
62
 
63
+ async function pumpStream(readable: ReadableStream<Uint8Array>, writer: WritableStreamDefaultWriter<string>) {
64
+ const reader = readable.pipeThrough(createSanitizer()).getReader();
65
+ try {
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ await writer.write(value);
70
+ }
71
+ } finally {
72
+ reader.releaseLock();
73
+ }
74
+ }
75
+
62
76
  function createOutputSink(
63
77
  spillThreshold: number,
64
78
  maxBuffer: number,
@@ -156,21 +170,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
156
170
 
157
171
  const writer = sink.getWriter();
158
172
  try {
159
- async function pumpStream(readable: ReadableStream<Uint8Array>) {
160
- const reader = readable.pipeThrough(createSanitizer()).getReader();
161
- try {
162
- while (true) {
163
- const { done, value } = await reader.read();
164
- if (done) break;
165
- await writer.write(value);
166
- }
167
- } finally {
168
- reader.releaseLock();
169
- }
170
- }
171
173
  await Promise.all([
172
- pumpStream(child.stdout as ReadableStream<Uint8Array>),
173
- pumpStream(child.stderr as ReadableStream<Uint8Array>),
174
+ pumpStream(child.stdout as ReadableStream<Uint8Array>, writer),
175
+ pumpStream(child.stderr as ReadableStream<Uint8Array>, writer),
174
176
  ]);
175
177
  } finally {
176
178
  await writer.close();
@@ -196,3 +198,66 @@ export async function executeBash(command: string, options?: BashExecutorOptions
196
198
  ...sink.dump(),
197
199
  };
198
200
  }
201
+
202
+ /**
203
+ * Execute a bash command using custom BashOperations.
204
+ * Used for remote execution (SSH, containers, etc.).
205
+ */
206
+ export async function executeBashWithOperations(
207
+ command: string,
208
+ cwd: string,
209
+ operations: BashOperations,
210
+ options?: BashExecutorOptions,
211
+ ): Promise<BashResult> {
212
+ const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
213
+ const writer = sink.getWriter();
214
+
215
+ // Create a ReadableStream from the callback-based operations.exec
216
+ let streamController: ReadableStreamDefaultController<Uint8Array>;
217
+ const dataStream = new ReadableStream<Uint8Array>({
218
+ start(controller) {
219
+ streamController = controller;
220
+ },
221
+ });
222
+
223
+ const onData = (data: Buffer) => {
224
+ streamController.enqueue(new Uint8Array(data));
225
+ };
226
+
227
+ // Start pumping the stream (will complete when stream closes)
228
+ const pumpPromise = pumpStream(dataStream, writer);
229
+
230
+ try {
231
+ const result = await operations.exec(command, cwd, {
232
+ onData,
233
+ signal: options?.signal,
234
+ timeout: options?.timeout,
235
+ });
236
+
237
+ streamController!.close();
238
+ await pumpPromise;
239
+ await writer.close();
240
+
241
+ const cancelled = options?.signal?.aborted ?? false;
242
+
243
+ return {
244
+ exitCode: cancelled ? undefined : (result.exitCode ?? undefined),
245
+ cancelled,
246
+ ...sink.dump(),
247
+ };
248
+ } catch (err) {
249
+ streamController!.close();
250
+ await pumpPromise;
251
+ await writer.close();
252
+
253
+ if (options?.signal?.aborted) {
254
+ return {
255
+ exitCode: undefined,
256
+ cancelled: true,
257
+ ...sink.dump(),
258
+ };
259
+ }
260
+
261
+ throw err;
262
+ }
263
+ }
@@ -23,7 +23,7 @@ export interface CustomCommandAPI {
23
23
  /** Injected @sinclair/typebox module */
24
24
  typebox: typeof import("@sinclair/typebox");
25
25
  /** Injected pi-coding-agent exports */
26
- pi: typeof import("../../index.js");
26
+ pi: typeof import("../../index");
27
27
  }
28
28
 
29
29
  /**
@@ -40,7 +40,7 @@ export interface CustomToolAPI {
40
40
  /** Injected @sinclair/typebox module */
41
41
  typebox: typeof import("@sinclair/typebox");
42
42
  /** Injected pi-coding-agent exports */
43
- pi: typeof import("../../index.js");
43
+ pi: typeof import("../../index");
44
44
  }
45
45
 
46
46
  /**
@@ -1,6 +1,7 @@
1
1
  import { existsSync, writeFileSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import type { AgentState } from "@oh-my-pi/pi-agent-core";
3
+ import type { AgentState, AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import { buildCodexPiBridge, getCodexInstructions } from "@oh-my-pi/pi-ai";
4
5
  import { APP_NAME } from "../../config";
5
6
  import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme";
6
7
  import { SessionManager } from "../session-manager";
@@ -13,6 +14,33 @@ export interface ExportOptions {
13
14
  themeName?: string;
14
15
  }
15
16
 
17
+ /** Info about Codex injection to show inline with model_change entries. */
18
+ interface CodexInjectionInfo {
19
+ /** Codex instructions text. */
20
+ instructions: string;
21
+ /** Bridge text (tool list). */
22
+ bridge: string;
23
+ }
24
+
25
+ /** Build Codex injection info for display inline with model_change entries. */
26
+ async function buildCodexInjectionInfo(tools?: AgentTool[]): Promise<CodexInjectionInfo | undefined> {
27
+ let instructions: string | null = null;
28
+ try {
29
+ instructions = await getCodexInstructions("gpt-5.1-codex");
30
+ } catch {
31
+ // Cache miss is expected before the first Codex request.
32
+ }
33
+
34
+ const bridgeText = buildCodexPiBridge(tools);
35
+ const instructionsText =
36
+ instructions ?? "(Codex instructions not cached. Run a Codex request to populate the local cache.)";
37
+
38
+ return {
39
+ instructions: instructionsText,
40
+ bridge: bridgeText,
41
+ };
42
+ }
43
+
16
44
  /** Parse a color string to RGB values. */
17
45
  function parseColor(color: string): { r: number; g: number; b: number } | undefined {
18
46
  const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
@@ -97,6 +125,8 @@ interface SessionData {
97
125
  entries: ReturnType<SessionManager["getEntries"]>;
98
126
  leafId: string | null;
99
127
  systemPrompt?: string;
128
+ /** Info for rendering Codex injection inline with model_change entries. */
129
+ codexInjectionInfo?: CodexInjectionInfo;
100
130
  tools?: { name: string; description: string }[];
101
131
  }
102
132
 
@@ -128,6 +158,7 @@ export async function exportSessionToHtml(
128
158
  entries: sm.getEntries(),
129
159
  leafId: sm.getLeafId(),
130
160
  systemPrompt: state?.systemPrompt,
161
+ codexInjectionInfo: await buildCodexInjectionInfo(state?.tools),
131
162
  tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
132
163
  };
133
164
 
@@ -149,6 +180,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
149
180
  header: sm.getHeader(),
150
181
  entries: sm.getEntries(),
151
182
  leafId: sm.getLeafId(),
183
+ codexInjectionInfo: await buildCodexInjectionInfo(),
152
184
  };
153
185
 
154
186
  const html = generateHtml(sessionData, opts.themeName);