@oh-my-pi/pi-coding-agent 6.9.0 → 7.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 (143) hide show
  1. package/CHANGELOG.md +173 -51
  2. package/examples/sdk/04-skills.ts +1 -1
  3. package/package.json +6 -5
  4. package/src/cli/stats-cli.ts +191 -0
  5. package/src/core/agent-session.ts +214 -4
  6. package/src/core/auth-storage.ts +524 -202
  7. package/src/core/bash-executor.ts +1 -1
  8. package/src/core/extensions/index.ts +2 -0
  9. package/src/core/extensions/runner.ts +31 -0
  10. package/src/core/extensions/types.ts +24 -0
  11. package/src/core/messages.ts +48 -0
  12. package/src/core/model-registry.ts +7 -0
  13. package/src/core/python-executor.ts +29 -8
  14. package/src/core/python-gateway-coordinator.ts +55 -1
  15. package/src/core/python-prelude.py +201 -8
  16. package/src/core/session-manager.ts +10 -1
  17. package/src/core/tools/bash.ts +5 -7
  18. package/src/core/tools/find.ts +18 -5
  19. package/src/core/tools/index.ts +1 -1
  20. package/src/core/tools/lsp/index.ts +13 -2
  21. package/src/core/tools/patch/applicator.ts +115 -17
  22. package/src/core/tools/patch/index.ts +1 -1
  23. package/src/core/tools/patch/normalize.ts +185 -10
  24. package/src/core/tools/python.ts +445 -86
  25. package/src/core/tools/read.ts +4 -4
  26. package/src/core/tools/task/executor.ts +2 -6
  27. package/src/core/tools/task/index.ts +30 -12
  28. package/src/core/tools/task/render.ts +163 -30
  29. package/src/core/tools/task/template.ts +37 -0
  30. package/src/core/tools/task/types.ts +6 -2
  31. package/src/core/tools/task/worker.ts +1 -1
  32. package/src/index.ts +2 -0
  33. package/src/main.ts +12 -0
  34. package/src/modes/interactive/components/python-execution.ts +180 -0
  35. package/src/modes/interactive/components/welcome.ts +1 -0
  36. package/src/modes/interactive/controllers/command-controller.ts +395 -0
  37. package/src/modes/interactive/controllers/input-controller.ts +83 -8
  38. package/src/modes/interactive/interactive-mode.ts +16 -1
  39. package/src/modes/interactive/theme/dark.json +2 -9
  40. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  41. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  42. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  43. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  44. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  45. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  46. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  47. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  48. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  49. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  50. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  51. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  52. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  53. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  55. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  56. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  57. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  58. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  59. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  60. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  61. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  62. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  63. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  64. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  65. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  66. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  67. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  68. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  69. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  70. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  71. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  72. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  73. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  74. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  75. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  76. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  77. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  78. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  79. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  80. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  81. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  82. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  83. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  84. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  85. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  86. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  87. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  88. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  89. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  90. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  91. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  92. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  93. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  94. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  95. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  96. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  97. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  98. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  99. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  100. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  101. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  103. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  106. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  107. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  108. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  111. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  112. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  113. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  114. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  115. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  116. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  117. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  118. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  119. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  120. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  121. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  122. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  123. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  124. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  125. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  126. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  127. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  128. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  129. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  130. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  131. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  132. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  133. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  134. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  135. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  136. package/src/modes/interactive/theme/light.json +2 -8
  137. package/src/modes/interactive/theme/theme-schema.json +5 -0
  138. package/src/modes/interactive/theme/theme.ts +7 -0
  139. package/src/modes/interactive/types.ts +7 -1
  140. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  141. package/src/prompts/system/system-prompt.md +88 -78
  142. package/src/prompts/tools/python.md +39 -2
  143. package/src/prompts/tools/task.md +8 -13
@@ -56,7 +56,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
56
56
  cancelled: false,
57
57
  ...(await sink.dump()),
58
58
  };
59
- } catch (err) {
59
+ } catch (err: unknown) {
60
60
  // Exception covers NonZeroExitError, AbortError, TimeoutError
61
61
  if (err instanceof Exception) {
62
62
  if (err.aborted) {
@@ -98,6 +98,8 @@ export type {
98
98
  TurnStartEvent,
99
99
  UserBashEvent,
100
100
  UserBashEventResult,
101
+ UserPythonEvent,
102
+ UserPythonEventResult,
101
103
  WriteToolResultEvent,
102
104
  } from "./types";
103
105
  // Type guards
@@ -40,6 +40,8 @@ import type {
40
40
  ToolResultEventResult,
41
41
  UserBashEvent,
42
42
  UserBashEventResult,
43
+ UserPythonEvent,
44
+ UserPythonEventResult,
43
45
  } from "./types";
44
46
 
45
47
  /** Combined result from all before_agent_start handlers */
@@ -461,6 +463,35 @@ export class ExtensionRunner {
461
463
  return undefined;
462
464
  }
463
465
 
466
+ async emitUserPython(event: UserPythonEvent): Promise<UserPythonEventResult | undefined> {
467
+ const ctx = this.createContext();
468
+
469
+ for (const ext of this.extensions) {
470
+ const handlers = ext.handlers.get("user_python");
471
+ if (!handlers || handlers.length === 0) continue;
472
+
473
+ for (const handler of handlers) {
474
+ try {
475
+ const handlerResult = await handler(event, ctx);
476
+ if (handlerResult) {
477
+ return handlerResult as UserPythonEventResult;
478
+ }
479
+ } catch (err) {
480
+ const message = err instanceof Error ? err.message : String(err);
481
+ const stack = err instanceof Error ? err.stack : undefined;
482
+ this.emitError({
483
+ extensionPath: ext.path,
484
+ event: "user_python",
485
+ error: message,
486
+ stack,
487
+ });
488
+ }
489
+ }
490
+ }
491
+
492
+ return undefined;
493
+ }
494
+
464
495
  /** Emit input event. Transforms chain, "handled" short-circuits. */
465
496
  async emitInput(
466
497
  text: string,
@@ -21,6 +21,7 @@ import type { ExecOptions, ExecResult } from "../exec";
21
21
  import type { KeybindingsManager } from "../keybindings";
22
22
  import type { CustomMessage } from "../messages";
23
23
  import type { ModelRegistry } from "../model-registry";
24
+ import type { PythonResult } from "../python-executor";
24
25
  import type {
25
26
  BranchSummaryEntry,
26
27
  CompactionEntry,
@@ -405,6 +406,21 @@ export interface UserBashEvent {
405
406
  cwd: string;
406
407
  }
407
408
 
409
+ // ============================================================================
410
+ // User Python Events
411
+ // ============================================================================
412
+
413
+ /** Fired when user executes Python code via $ or $$ prefix */
414
+ export interface UserPythonEvent {
415
+ type: "user_python";
416
+ /** The Python code to execute */
417
+ code: string;
418
+ /** True if $$ prefix was used (excluded from LLM context) */
419
+ excludeFromContext: boolean;
420
+ /** Current working directory */
421
+ cwd: string;
422
+ }
423
+
408
424
  // ============================================================================
409
425
  // Input Events
410
426
  // ============================================================================
@@ -521,6 +537,7 @@ export type ExtensionEvent =
521
537
  | TurnStartEvent
522
538
  | TurnEndEvent
523
539
  | UserBashEvent
540
+ | UserPythonEvent
524
541
  | InputEvent
525
542
  | ToolCallEvent
526
543
  | ToolResultEvent;
@@ -554,6 +571,12 @@ export interface UserBashEventResult {
554
571
  result?: BashResult;
555
572
  }
556
573
 
574
+ /** Result from user_python event handler */
575
+ export interface UserPythonEventResult {
576
+ /** Full replacement: extension handled execution, use this result */
577
+ result?: PythonResult;
578
+ }
579
+
557
580
  export interface ToolResultEventResult {
558
581
  content?: (TextContent | ImageContent)[];
559
582
  details?: unknown;
@@ -671,6 +694,7 @@ export interface ExtensionAPI {
671
694
  on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
672
695
  on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
673
696
  on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
697
+ on(event: "user_python", handler: ExtensionHandler<UserPythonEvent, UserPythonEventResult>): void;
674
698
 
675
699
  // =========================================================================
676
700
  // Tool Registration
@@ -39,6 +39,23 @@ export interface BashExecutionMessage {
39
39
  excludeFromContext?: boolean;
40
40
  }
41
41
 
42
+ /**
43
+ * Message type for user-initiated Python executions via the $ command.
44
+ * Shares the same kernel session as the agent's Python tool.
45
+ */
46
+ export interface PythonExecutionMessage {
47
+ role: "pythonExecution";
48
+ code: string;
49
+ output: string;
50
+ exitCode: number | undefined;
51
+ cancelled: boolean;
52
+ truncated: boolean;
53
+ fullOutputPath?: string;
54
+ timestamp: number;
55
+ /** If true, this message is excluded from LLM context ($$ prefix) */
56
+ excludeFromContext?: boolean;
57
+ }
58
+
42
59
  /**
43
60
  * Message type for extension-injected messages via sendMessage().
44
61
  */
@@ -95,6 +112,7 @@ export interface FileMentionMessage {
95
112
  declare module "@oh-my-pi/pi-agent-core" {
96
113
  interface CustomAgentMessages {
97
114
  bashExecution: BashExecutionMessage;
115
+ pythonExecution: PythonExecutionMessage;
98
116
  custom: CustomMessage;
99
117
  hookMessage: HookMessage;
100
118
  branchSummary: BranchSummaryMessage;
@@ -124,6 +142,27 @@ export function bashExecutionToText(msg: BashExecutionMessage): string {
124
142
  return text;
125
143
  }
126
144
 
145
+ /**
146
+ * Convert a PythonExecutionMessage to user message text for LLM context.
147
+ */
148
+ export function pythonExecutionToText(msg: PythonExecutionMessage): string {
149
+ let text = `Ran Python:\n\`\`\`python\n${msg.code}\n\`\`\`\n`;
150
+ if (msg.output) {
151
+ text += `Output:\n\`\`\`\n${msg.output}\n\`\`\``;
152
+ } else {
153
+ text += "(no output)";
154
+ }
155
+ if (msg.cancelled) {
156
+ text += "\n\n(execution cancelled)";
157
+ } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
158
+ text += `\n\nExecution failed with code ${msg.exitCode}`;
159
+ }
160
+ if (msg.truncated && msg.fullOutputPath) {
161
+ text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
162
+ }
163
+ return text;
164
+ }
165
+
127
166
  export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage {
128
167
  return {
129
168
  role: "branchSummary",
@@ -185,6 +224,15 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
185
224
  content: [{ type: "text", text: bashExecutionToText(m) }],
186
225
  timestamp: m.timestamp,
187
226
  };
227
+ case "pythonExecution":
228
+ if (m.excludeFromContext) {
229
+ return undefined;
230
+ }
231
+ return {
232
+ role: "user",
233
+ content: [{ type: "text", text: pythonExecutionToText(m) }],
234
+ timestamp: m.timestamp,
235
+ };
188
236
  case "custom":
189
237
  case "hookMessage": {
190
238
  const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
@@ -432,6 +432,13 @@ export class ModelRegistry {
432
432
  return this.models.find((m) => m.provider === provider && m.id === modelId);
433
433
  }
434
434
 
435
+ /**
436
+ * Get the base URL associated with a provider, if any model defines one.
437
+ */
438
+ getProviderBaseUrl(provider: string): string | undefined {
439
+ return this.models.find((m) => m.provider === provider && m.baseUrl)?.baseUrl;
440
+ }
441
+
435
442
  /**
436
443
  * Get API key for a model.
437
444
  */
@@ -27,6 +27,8 @@ export interface PythonExecutorOptions {
27
27
  reset?: boolean;
28
28
  /** Use shared gateway across pi instances (default: true) */
29
29
  useSharedGateway?: boolean;
30
+ /** Session file path for accessing task outputs */
31
+ sessionFile?: string;
30
32
  }
31
33
 
32
34
  export interface PythonKernelExecutor {
@@ -79,6 +81,7 @@ export async function warmPythonEnvironment(
79
81
  cwd: string,
80
82
  sessionId?: string,
81
83
  useSharedGateway?: boolean,
84
+ sessionFile?: string,
82
85
  ): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
83
86
  try {
84
87
  await ensureKernelAvailable(cwd);
@@ -97,6 +100,7 @@ export async function warmPythonEnvironment(
97
100
  cwd,
98
101
  async (kernel) => kernel.introspectPrelude(),
99
102
  useSharedGateway,
103
+ sessionFile,
100
104
  );
101
105
  cachedPreludeDocs = docs;
102
106
  return { ok: true, docs };
@@ -115,8 +119,14 @@ export function resetPreludeDocsCache(): void {
115
119
  cachedPreludeDocs = null;
116
120
  }
117
121
 
118
- async function createKernelSession(sessionId: string, cwd: string, useSharedGateway?: boolean): Promise<KernelSession> {
119
- const kernel = await PythonKernel.start({ cwd, useSharedGateway });
122
+ async function createKernelSession(
123
+ sessionId: string,
124
+ cwd: string,
125
+ useSharedGateway?: boolean,
126
+ sessionFile?: string,
127
+ ): Promise<KernelSession> {
128
+ const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
129
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
120
130
  const session: KernelSession = {
121
131
  id: sessionId,
122
132
  kernel,
@@ -137,7 +147,12 @@ async function createKernelSession(sessionId: string, cwd: string, useSharedGate
137
147
  return session;
138
148
  }
139
149
 
140
- async function restartKernelSession(session: KernelSession, cwd: string, useSharedGateway?: boolean): Promise<void> {
150
+ async function restartKernelSession(
151
+ session: KernelSession,
152
+ cwd: string,
153
+ useSharedGateway?: boolean,
154
+ sessionFile?: string,
155
+ ): Promise<void> {
141
156
  session.restartCount += 1;
142
157
  if (session.restartCount > 1) {
143
158
  throw new Error("Python kernel restarted too many times in this session");
@@ -147,7 +162,8 @@ async function restartKernelSession(session: KernelSession, cwd: string, useShar
147
162
  } catch (err) {
148
163
  logger.warn("Failed to shutdown crashed kernel", { error: err instanceof Error ? err.message : String(err) });
149
164
  }
150
- const kernel = await PythonKernel.start({ cwd, useSharedGateway });
165
+ const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
166
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
151
167
  session.kernel = kernel;
152
168
  session.dead = false;
153
169
  session.lastUsedAt = Date.now();
@@ -170,17 +186,18 @@ async function withKernelSession<T>(
170
186
  cwd: string,
171
187
  handler: (kernel: PythonKernel) => Promise<T>,
172
188
  useSharedGateway?: boolean,
189
+ sessionFile?: string,
173
190
  ): Promise<T> {
174
191
  let session = kernelSessions.get(sessionId);
175
192
  if (!session) {
176
- session = await createKernelSession(sessionId, cwd, useSharedGateway);
193
+ session = await createKernelSession(sessionId, cwd, useSharedGateway, sessionFile);
177
194
  kernelSessions.set(sessionId, session);
178
195
  }
179
196
 
180
197
  const run = async (): Promise<T> => {
181
198
  session!.lastUsedAt = Date.now();
182
199
  if (session!.dead || !session!.kernel.isAlive()) {
183
- await restartKernelSession(session!, cwd, useSharedGateway);
200
+ await restartKernelSession(session!, cwd, useSharedGateway, sessionFile);
184
201
  }
185
202
  try {
186
203
  const result = await handler(session!.kernel);
@@ -190,7 +207,7 @@ async function withKernelSession<T>(
190
207
  if (!session!.dead && session!.kernel.isAlive()) {
191
208
  throw err;
192
209
  }
193
- await restartKernelSession(session!, cwd, useSharedGateway);
210
+ await restartKernelSession(session!, cwd, useSharedGateway, sessionFile);
194
211
  const result = await handler(session!.kernel);
195
212
  session!.restartCount = 0;
196
213
  return result;
@@ -273,8 +290,11 @@ export async function executePython(code: string, options?: PythonExecutorOption
273
290
 
274
291
  const kernelMode = options?.kernelMode ?? "session";
275
292
  const useSharedGateway = options?.useSharedGateway;
293
+ const sessionFile = options?.sessionFile;
294
+
276
295
  if (kernelMode === "per-call") {
277
- const kernel = await PythonKernel.start({ cwd, useSharedGateway });
296
+ const env = sessionFile ? { OMP_SESSION_FILE: sessionFile } : undefined;
297
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
278
298
  try {
279
299
  return await executeWithKernel(kernel, code, options);
280
300
  } finally {
@@ -294,5 +314,6 @@ export async function executePython(code: string, options?: PythonExecutorOption
294
314
  cwd,
295
315
  async (kernel) => executeWithKernel(kernel, code, options),
296
316
  useSharedGateway,
317
+ sessionFile,
297
318
  );
298
319
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "node:fs";
14
14
  import { createServer } from "node:net";
15
15
  import { delimiter, join } from "node:path";
16
- import { logger } from "@oh-my-pi/pi-utils";
16
+ import { logger, postmortem } from "@oh-my-pi/pi-utils";
17
17
  import type { Subprocess } from "bun";
18
18
  import { getAgentDir } from "../config";
19
19
  import { getShellConfig, killProcessTree } from "../utils/shell";
@@ -161,6 +161,58 @@ let localGatewayUrl: string | null = null;
161
161
  let idleShutdownTimer: ReturnType<typeof setTimeout> | null = null;
162
162
  let isCoordinatorInitialized = false;
163
163
  let localClientFile: string | null = null;
164
+ let postmortemRegistered = false;
165
+
166
+ /**
167
+ * Register cleanup handler for process exit. Called lazily on first gateway acquisition.
168
+ * Ensures the gateway process we spawned is killed when omp exits, preventing orphaned processes.
169
+ */
170
+ function ensurePostmortemCleanup(): void {
171
+ if (postmortemRegistered) return;
172
+ postmortemRegistered = true;
173
+
174
+ postmortem.register("shared-gateway", async () => {
175
+ cancelIdleShutdown();
176
+
177
+ // Clean up our client file first so refcount is accurate
178
+ if (localClientFile) {
179
+ try {
180
+ unlinkSync(localClientFile);
181
+ } catch {
182
+ // Ignore cleanup errors
183
+ }
184
+ localClientFile = null;
185
+ }
186
+
187
+ // If we spawned the gateway, kill it only if no other clients remain
188
+ if (localGatewayProcess) {
189
+ const clients = pruneStaleClientInfos(listClientInfos());
190
+ const remainingRefs = clients.reduce((sum, c) => sum + c.info.refCount, 0);
191
+
192
+ if (remainingRefs === 0) {
193
+ logger.debug("Cleaning up shared gateway on process exit", { pid: localGatewayProcess.pid });
194
+ try {
195
+ await killProcessTree(localGatewayProcess.pid);
196
+ } catch (err) {
197
+ logger.warn("Failed to kill shared gateway on exit", {
198
+ error: err instanceof Error ? err.message : String(err),
199
+ });
200
+ }
201
+ clearGatewayInfo();
202
+ } else {
203
+ logger.debug("Leaving shared gateway running for other clients", {
204
+ pid: localGatewayProcess.pid,
205
+ remainingRefs,
206
+ });
207
+ }
208
+
209
+ localGatewayProcess = null;
210
+ localGatewayUrl = null;
211
+ }
212
+
213
+ isCoordinatorInitialized = false;
214
+ });
215
+ }
164
216
 
165
217
  function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
166
218
  const filtered: Record<string, string | undefined> = {};
@@ -665,6 +717,8 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
665
717
  return null;
666
718
  }
667
719
 
720
+ ensurePostmortemCleanup();
721
+
668
722
  try {
669
723
  return await withGatewayLock(async () => {
670
724
  const existingInfo = readGatewayInfo();
@@ -24,14 +24,6 @@ if "__omp_prelude_loaded__" not in globals():
24
24
  _emit_status("pwd", path=str(p))
25
25
  return p
26
26
 
27
- @_category("Navigation")
28
- def cd(path: str | Path) -> Path:
29
- """Change directory."""
30
- p = Path(path).expanduser().resolve()
31
- os.chdir(p)
32
- _emit_status("cd", path=str(p))
33
- return p
34
-
35
27
  @_category("Shell")
36
28
  def env(key: str | None = None, value: str | None = None):
37
29
  """Get/set environment variables."""
@@ -914,6 +906,207 @@ if "__omp_prelude_loaded__" not in globals():
914
906
  _emit_status("git_has_changes", has_changes=has_changes)
915
907
  return has_changes
916
908
 
909
+ @_category("Agent")
910
+ def output(
911
+ *ids: str,
912
+ format: str = "raw",
913
+ query: str | None = None,
914
+ offset: int | None = None,
915
+ limit: int | None = None,
916
+ ) -> str | dict | list[dict]:
917
+ """Read task/agent output by ID. Returns text or JSON depending on format.
918
+
919
+ Args:
920
+ *ids: Output IDs to read (e.g., 'explore_0', 'reviewer_1')
921
+ format: 'raw' (default), 'json' (dict with metadata), 'stripped' (no ANSI)
922
+ query: jq-like query for JSON outputs (e.g., '.endpoints[0].file')
923
+ offset: Line number to start reading from (1-indexed)
924
+ limit: Maximum number of lines to read
925
+
926
+ Returns:
927
+ Single ID: str (format='raw'/'stripped') or dict (format='json')
928
+ Multiple IDs: list of dict with 'id' and 'content'/'data' keys
929
+
930
+ Examples:
931
+ output('explore_0') # Read as raw text
932
+ output('reviewer_0', format='json') # Read with metadata
933
+ output('explore_0', query='.files[0]') # Extract JSON field
934
+ output('explore_0', offset=10, limit=20) # Lines 10-29
935
+ output('explore_0', 'reviewer_1') # Read multiple outputs
936
+ """
937
+ session_file = os.environ.get("OMP_SESSION_FILE")
938
+ if not session_file:
939
+ _emit_status("output", error="No session file available")
940
+ raise RuntimeError("No session - output artifacts unavailable")
941
+
942
+ artifacts_dir = session_file.rsplit(".", 1)[0] # Strip .jsonl extension
943
+ if not Path(artifacts_dir).exists():
944
+ _emit_status("output", error="Artifacts directory not found", path=artifacts_dir)
945
+ raise RuntimeError(f"No artifacts directory found: {artifacts_dir}")
946
+
947
+ if not ids:
948
+ _emit_status("output", error="No IDs provided")
949
+ raise ValueError("At least one output ID is required")
950
+
951
+ if query and (offset is not None or limit is not None):
952
+ _emit_status("output", error="query cannot be combined with offset/limit")
953
+ raise ValueError("query cannot be combined with offset/limit")
954
+
955
+ results: list[dict] = []
956
+ not_found: list[str] = []
957
+
958
+ for output_id in ids:
959
+ output_path = Path(artifacts_dir) / f"{output_id}.md"
960
+ if not output_path.exists():
961
+ not_found.append(output_id)
962
+ continue
963
+
964
+ raw_content = output_path.read_text(encoding="utf-8")
965
+ raw_lines = raw_content.splitlines()
966
+ total_lines = len(raw_lines)
967
+
968
+ selected_content = raw_content
969
+ range_info: dict | None = None
970
+
971
+ # Handle query
972
+ if query:
973
+ try:
974
+ json_value = json.loads(raw_content)
975
+ except json.JSONDecodeError as e:
976
+ _emit_status("output", id=output_id, error=f"Not valid JSON: {e}")
977
+ raise ValueError(f"Output {output_id} is not valid JSON: {e}")
978
+
979
+ # Apply jq-like query
980
+ result_value = _apply_query(json_value, query)
981
+ try:
982
+ selected_content = json.dumps(result_value, indent=2) if result_value is not None else "null"
983
+ except (TypeError, ValueError):
984
+ selected_content = str(result_value)
985
+
986
+ # Handle offset/limit
987
+ elif offset is not None or limit is not None:
988
+ start_line = max(1, offset or 1)
989
+ if start_line > total_lines:
990
+ _emit_status("output", id=output_id, error=f"Offset {start_line} beyond end ({total_lines} lines)")
991
+ raise ValueError(f"Offset {start_line} is beyond end of output ({total_lines} lines) for {output_id}")
992
+
993
+ effective_limit = limit if limit is not None else total_lines - start_line + 1
994
+ end_line = min(total_lines, start_line + effective_limit - 1)
995
+ selected_lines = raw_lines[start_line - 1 : end_line]
996
+ selected_content = "\n".join(selected_lines)
997
+ range_info = {"start_line": start_line, "end_line": end_line, "total_lines": total_lines}
998
+
999
+ # Strip ANSI codes if requested
1000
+ if format == "stripped":
1001
+ import re
1002
+ selected_content = re.sub(r"\x1b\[[0-9;]*m", "", selected_content)
1003
+
1004
+ # Build result
1005
+ if format == "json":
1006
+ result_data = {
1007
+ "id": output_id,
1008
+ "path": str(output_path),
1009
+ "line_count": total_lines if not query else len(selected_content.splitlines()),
1010
+ "char_count": len(raw_content) if not query else len(selected_content),
1011
+ "content": selected_content,
1012
+ }
1013
+ if range_info:
1014
+ result_data["range"] = range_info
1015
+ if query:
1016
+ result_data["query"] = query
1017
+ results.append(result_data)
1018
+ else:
1019
+ results.append({"id": output_id, "content": selected_content})
1020
+
1021
+ # Handle not found
1022
+ if not_found:
1023
+ available = sorted(
1024
+ [f.stem for f in Path(artifacts_dir).glob("*.md")]
1025
+ )
1026
+ error_msg = f"Output not found: {', '.join(not_found)}"
1027
+ if available:
1028
+ error_msg += f"\n\nAvailable outputs: {', '.join(available[:20])}"
1029
+ if len(available) > 20:
1030
+ error_msg += f" (and {len(available) - 20} more)"
1031
+ _emit_status("output", not_found=not_found, available_count=len(available))
1032
+ raise FileNotFoundError(error_msg)
1033
+
1034
+ # Return format
1035
+ if len(ids) == 1:
1036
+ if format == "json":
1037
+ _emit_status("output", id=ids[0], chars=results[0]["char_count"])
1038
+ return results[0]
1039
+ _emit_status("output", id=ids[0], chars=len(results[0]["content"]))
1040
+ return results[0]["content"]
1041
+
1042
+ # Multiple IDs
1043
+ if format == "json":
1044
+ total_chars = sum(r["char_count"] for r in results)
1045
+ _emit_status("output", count=len(results), total_chars=total_chars)
1046
+ return results
1047
+
1048
+ combined_output: list[dict] = []
1049
+ for r in results:
1050
+ combined_output.append({"id": r["id"], "content": r["content"]})
1051
+ total_chars = sum(len(r["content"]) for r in combined_output)
1052
+ _emit_status("output", count=len(combined_output), total_chars=total_chars)
1053
+ return combined_output
1054
+
1055
+ def _apply_query(data: any, query: str) -> any:
1056
+ """Apply jq-like query to data. Supports .key, [index], and chaining."""
1057
+ if not query:
1058
+ return data
1059
+
1060
+ query = query.strip()
1061
+ if query.startswith("."):
1062
+ query = query[1:]
1063
+ if not query:
1064
+ return data
1065
+
1066
+ # Parse query into tokens
1067
+ tokens = []
1068
+ current_token = ""
1069
+ i = 0
1070
+ while i < len(query):
1071
+ ch = query[i]
1072
+ if ch == ".":
1073
+ if current_token:
1074
+ tokens.append(("key", current_token))
1075
+ current_token = ""
1076
+ elif ch == "[":
1077
+ if current_token:
1078
+ tokens.append(("key", current_token))
1079
+ current_token = ""
1080
+ # Find matching ]
1081
+ j = i + 1
1082
+ while j < len(query) and query[j] != "]":
1083
+ j += 1
1084
+ bracket_content = query[i+1:j]
1085
+ if bracket_content.startswith('"') and bracket_content.endswith('"'):
1086
+ tokens.append(("key", bracket_content[1:-1]))
1087
+ else:
1088
+ tokens.append(("index", int(bracket_content)))
1089
+ i = j
1090
+ else:
1091
+ current_token += ch
1092
+ i += 1
1093
+ if current_token:
1094
+ tokens.append(("key", current_token))
1095
+
1096
+ # Apply tokens
1097
+ current = data
1098
+ for token_type, value in tokens:
1099
+ if token_type == "index":
1100
+ if not isinstance(current, list) or value >= len(current):
1101
+ return None
1102
+ current = current[value]
1103
+ elif token_type == "key":
1104
+ if not isinstance(current, dict) or value not in current:
1105
+ return None
1106
+ current = current[value]
1107
+
1108
+ return current
1109
+
917
1110
  def __omp_prelude_docs__() -> list[dict[str, str]]:
918
1111
  """Return prelude helper docs for templating. Discovers functions by _omp_category attribute."""
919
1112
  helpers: list[dict[str, str]] = []
@@ -13,6 +13,7 @@ import {
13
13
  createCustomMessage,
14
14
  type FileMentionMessage,
15
15
  type HookMessage,
16
+ type PythonExecutionMessage,
16
17
  } from "./messages";
17
18
  import type { SessionStorage, SessionStorageWriter } from "./session-storage";
18
19
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
@@ -1306,7 +1307,15 @@ export class SessionManager {
1306
1307
  * so it is easier to find them.
1307
1308
  * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1308
1309
  */
1309
- appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage | FileMentionMessage): string {
1310
+ appendMessage(
1311
+ message:
1312
+ | Message
1313
+ | CustomMessage
1314
+ | HookMessage
1315
+ | BashExecutionMessage
1316
+ | PythonExecutionMessage
1317
+ | FileMentionMessage,
1318
+ ): string {
1310
1319
  const entry: SessionMessageEntry = {
1311
1320
  type: "message",
1312
1321
  id: generateId(this.byId),